Files
AstralRinth/packages/ui/src/layouts/shared/content-tab/components/ContentSelectionBar.vue
T

279 lines
8.5 KiB
Vue

<script setup lang="ts">
import { PowerIcon, PowerOffIcon, XIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
import type { BulkOperationType } from '../composables/bulk-operations'
import type { ContentItem } from '../types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
selectedCount: {
id: 'content.selection-bar.selected-count',
defaultMessage: '{count, number} {contentType} selected',
},
selectedCountSimple: {
id: 'content.selection-bar.selected-count-simple',
defaultMessage: '{count, number} selected',
},
bulkEnabling: {
id: 'content.selection-bar.bulk.enabling',
defaultMessage: 'Enabling {progress}/{total} {contentType}...',
},
bulkEnablingWaiting: {
id: 'content.selection-bar.bulk.enabling-waiting',
defaultMessage: 'Enabling {contentType}...',
},
bulkDisabling: {
id: 'content.selection-bar.bulk.disabling',
defaultMessage: 'Disabling {progress}/{total} {contentType}...',
},
bulkDisablingWaiting: {
id: 'content.selection-bar.bulk.disabling-waiting',
defaultMessage: 'Disabling {contentType}...',
},
bulkUpdating: {
id: 'content.selection-bar.bulk.updating',
defaultMessage: 'Updating {progress}/{total} {contentType}...',
},
bulkUpdatingWaiting: {
id: 'content.selection-bar.bulk.updating-waiting',
defaultMessage: 'Updating {contentType}...',
},
bulkDeleting: {
id: 'content.selection-bar.bulk.deleting',
defaultMessage: 'Deleting {progress}/{total} {contentType}...',
},
bulkDeletingWaiting: {
id: 'content.selection-bar.bulk.deleting-waiting',
defaultMessage: 'Deleting {contentType}...',
},
allAlreadyEnabled: {
id: 'content.selection-bar.all-already-enabled',
defaultMessage: 'All selected content is already enabled',
},
allAlreadyDisabled: {
id: 'content.selection-bar.all-already-disabled',
defaultMessage: 'All selected content is already disabled',
},
})
interface Props {
selectedItems: ContentItem[]
contentTypeLabel?: string
isBusy?: boolean
busyTooltip?: string | null
isBulkOperating?: boolean
bulkOperation?: BulkOperationType | null
bulkProgress?: number
bulkTotal?: number
bulkWaiting?: boolean
ariaLabel?: string
getItemId?: (item: ContentItem) => string
}
const props = withDefaults(defineProps<Props>(), {
contentTypeLabel: undefined,
isBusy: false,
busyTooltip: undefined,
isBulkOperating: false,
bulkOperation: null,
bulkProgress: 0,
bulkTotal: 0,
bulkWaiting: false,
ariaLabel: undefined,
getItemId: undefined,
})
const emit = defineEmits<{
clear: []
enable: []
disable: []
}>()
const shown = computed(() => props.selectedItems.length > 0 || props.isBulkOperating)
const iconStackOffset = 24
const visibleItems = computed(() => props.selectedItems.slice(0, 3))
const overflowCount = computed(() => Math.max(0, props.selectedItems.length - 3))
const iconStackWidth = computed(() => {
if (props.selectedItems.length === 0) return 0
return 32 + (visibleItems.value.length - 1 + (overflowCount.value > 0 ? 1 : 0)) * iconStackOffset
})
function resolveItemId(item: ContentItem) {
return props.getItemId?.(item) ?? item.file_path ?? item.file_name ?? item.id
}
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
const allEnabled = computed(() => props.selectedItems.every((m) => m.enabled))
const selectedCountText = computed(() => {
const count = props.selectedItems.length || props.bulkTotal
if (props.contentTypeLabel) {
return formatMessage(messages.selectedCount, {
count,
contentType: formatContentTypeSentence(formatMessage, props.contentTypeLabel, count),
})
}
return formatMessage(messages.selectedCountSimple, { count })
})
const bulkProgressMessage = computed(() => {
if (!props.bulkOperation) return ''
const messageMap = {
enable: props.bulkWaiting ? messages.bulkEnablingWaiting : messages.bulkEnabling,
disable: props.bulkWaiting ? messages.bulkDisablingWaiting : messages.bulkDisabling,
update: props.bulkWaiting ? messages.bulkUpdatingWaiting : messages.bulkUpdating,
delete: props.bulkWaiting ? messages.bulkDeletingWaiting : messages.bulkDeleting,
}
return formatMessage(messageMap[props.bulkOperation], {
progress: props.bulkProgress,
total: props.bulkTotal,
contentType: formatContentTypeSentence(formatMessage, props.contentTypeLabel, props.bulkTotal),
})
})
</script>
<template>
<FloatingActionBar :shown="shown" :aria-label="ariaLabel" hide-when-modal-open>
<div class="flex items-center gap-0.5">
<div
v-if="selectedItems.length > 0"
class="relative h-8 shrink-0"
:style="{ width: `${iconStackWidth}px` }"
aria-hidden="true"
>
<div
v-for="(item, index) in visibleItems"
:key="resolveItemId(item)"
v-tooltip="item.project?.title ?? item.file_name"
class="absolute top-0 flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4"
:style="{ left: `${index * iconStackOffset}px`, zIndex: visibleItems.length - index }"
>
<Avatar
:src="item.project?.icon_url"
:alt="item.project?.title ?? item.file_name"
:tint-by="resolveItemId(item)"
size="100%"
no-shadow
class="selected-content-avatar"
/>
</div>
<div
v-if="overflowCount > 0"
class="absolute top-0 flex h-8 w-8 items-center justify-center rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4 text-xs font-bold text-contrast"
:style="{ left: `${visibleItems.length * iconStackOffset}px`, zIndex: 0 }"
>
+{{ overflowCount }}
</div>
</div>
<span class="px-3 py-2 text-base font-semibold text-contrast tabular-nums">
{{ selectedCountText }}
</span>
<div class="mx-0.5 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
<button
v-tooltip="formatMessage(commonMessages.clearButton)"
class="!text-primary"
:disabled="isBulkOperating"
:class="{ 'opacity-60 pointer-events-none': isBulkOperating }"
@click="emit('clear')"
>
<XIcon class="hidden cq-show-icon" />
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
</button>
</ButtonStyled>
</div>
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
<slot name="actions" />
<ButtonStyled type="transparent">
<button
v-tooltip="
isBusy && busyTooltip
? busyTooltip
: allEnabled
? formatMessage(messages.allAlreadyEnabled)
: formatMessage(commonMessages.enableButton)
"
:disabled="isBusy || allEnabled"
@click="emit('enable')"
>
<PowerIcon />
<span class="bar-label">{{ formatMessage(commonMessages.enableButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
v-tooltip="
isBusy && busyTooltip
? busyTooltip
: allDisabled
? formatMessage(messages.allAlreadyDisabled)
: formatMessage(commonMessages.disableButton)
"
:disabled="isBusy || allDisabled"
@click="emit('disable')"
>
<PowerOffIcon />
<span class="bar-label">{{ formatMessage(commonMessages.disableButton) }}</span>
</button>
</ButtonStyled>
<slot name="actions-end" />
</div>
<div v-else class="ml-auto flex items-center" aria-live="polite">
<span class="px-4 py-2.5 text-base font-semibold text-secondary tabular-nums">
{{ bulkProgressMessage }}
</span>
</div>
<div v-if="isBulkOperating" class="absolute bottom-0 left-0 right-0 h-1">
<div
class="h-full rounded-l-full bg-brand transition-[width] duration-200 ease-in-out"
:class="{ 'animate-indeterminate': bulkWaiting }"
:style="
!bulkWaiting
? { width: `${bulkTotal > 0 ? (bulkProgress / bulkTotal) * 100 : 0}%` }
: undefined
"
role="progressbar"
:aria-valuenow="bulkWaiting ? undefined : bulkProgress"
:aria-valuemin="0"
:aria-valuemax="bulkTotal"
style="box-shadow: 0px -2px 4px 0px rgba(27, 217, 106, 0.1)"
/>
</div>
</FloatingActionBar>
</template>
<style scoped>
@keyframes indeterminate {
0% {
width: 20%;
margin-left: -20%;
}
100% {
width: 60%;
margin-left: 100%;
}
}
.animate-indeterminate {
animation: indeterminate 1.5s ease-in-out infinite;
}
:deep(.selected-content-avatar) {
background-color: var(--color-button-bg);
}
</style>