refactor: instance export modal (#6543)

* refactor: export modal w the design system

* fix: lint

* fix: rev

* fix: lint

* fix: qa
This commit is contained in:
Calum H.
2026-06-29 17:01:00 +00:00
committed by GitHub
parent c2f4835fe7
commit 862b54f00d
33 changed files with 857 additions and 290 deletions
@@ -0,0 +1,604 @@
<template>
<div class="flex w-full min-w-0 flex-col gap-2">
<div
class="flex w-full min-w-0 flex-col rounded-[20px] border border-solid border-surface-4 shadow-sm overflow-clip"
>
<div
class="flex h-10 w-full min-w-0 select-none flex-row items-center justify-between bg-surface-3 px-3 text-sm font-medium"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Checkbox
:model-value="allVisibleSelected"
:indeterminate="someVisibleSelected && !allVisibleSelected"
:disabled="visibleSelectableEntries.length === 0"
@update:model-value="toggleAllVisible"
/>
<button
type="button"
class="flex min-w-0 appearance-none items-center gap-1.5 border-0 bg-transparent p-0 font-semibold hover:text-primary"
:class="sortField === 'name' ? 'text-contrast' : 'text-secondary'"
@click="handleSort('name')"
>
<span class="min-w-0 truncate">{{ formatMessage(messages.name) }}</span>
<ChevronUpIcon
v-if="sortField === 'name' && !sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'name' && sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
</button>
</div>
<div class="ml-2 flex shrink-0 items-center gap-4">
<button
type="button"
class="hidden w-[92px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
@click="handleSort('size')"
>
<span>{{ formatMessage(messages.size) }}</span>
<ChevronUpIcon
v-if="sortField === 'size' && !sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'size' && sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
</button>
<button
type="button"
class="hidden w-[132px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
@click="handleSort('modified')"
>
<span>{{ formatMessage(messages.modified) }}</span>
<ChevronUpIcon
v-if="sortField === 'modified' && !sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'modified' && sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
</button>
<span class="size-4 shrink-0" aria-hidden="true" />
</div>
</div>
<div
v-if="!isHomePath"
role="button"
tabindex="0"
class="group flex w-full min-w-0 cursor-pointer select-none items-center gap-2 border-0 border-t border-solid border-surface-4 bg-surface-2 px-3 py-2 text-left hover:bg-surface-2.5 focus:!outline-none"
@click="navigateTo(parentPath)"
@keydown="(event) => event.key === 'Enter' && navigateTo(parentPath)"
>
<span class="size-5 shrink-0" aria-hidden="true" />
<div class="flex size-4 shrink-0 items-center justify-center text-secondary">
<UndoIcon class="size-4 group-hover:text-contrast group-focus:text-contrast" />
</div>
<span
class="min-w-0 flex-1 truncate text-sm font-medium text-primary group-hover:text-contrast group-focus:text-contrast"
>
{{ formatMessage(messages.parentFolder) }}
</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
<span class="hidden w-[132px] text-left text-sm text-secondary sm:block" />
<span class="size-4 shrink-0" aria-hidden="true" />
</div>
</div>
<div
v-if="entries.length === 0"
class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-secondary"
>
<FileIcon class="size-4 shrink-0" />
<span>{{ formatMessage(messages.emptyFolderTitle) }}</span>
</div>
<div
v-for="(entry, i) in entries"
:key="`${entry.type}:${entry.path}`"
role="button"
tabindex="0"
class="group flex w-full min-w-0 select-none items-center gap-2 border-0 border-t border-solid border-surface-4 px-3 py-2 text-left first:border-t-0 focus:!outline-none"
:class="[
getRowBackgroundClass(i + (isHomePath ? 0 : 1)),
entry.disabled && entry.type === 'file'
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-surface-2.5',
]"
@click="selectEntry(entry)"
@keydown="(event) => event.key === 'Enter' && selectEntry(entry)"
>
<Checkbox
class="shrink-0"
:model-value="entry.checked"
:indeterminate="entry.indeterminate"
:disabled="entry.disabled"
:description="entry.name"
@click.stop
@update:model-value="toggleEntry(entry, $event)"
/>
<div class="flex size-4 shrink-0 items-center justify-center text-secondary">
<component
:is="entry.icon"
class="size-4 group-hover:text-contrast group-focus:text-contrast"
/>
</div>
<span
:ref="(element) => setEntryNameRef(entry.path, element)"
v-tooltip="truncatedTooltip(entryNameRefs[entry.path], entry.name)"
class="min-w-0 flex-1 truncate text-sm font-medium text-primary group-hover:text-contrast group-focus:text-contrast"
>
{{ entry.name }}
</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block">
{{ formatSize(entry) }}
</span>
<span class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block">
{{ formatModified(entry) }}
</span>
<ChevronRightIcon
class="size-4 shrink-0 text-secondary group-hover:text-contrast group-focus:text-contrast"
:class="{ invisible: entry.type !== 'directory' }"
aria-hidden="true"
/>
</div>
</div>
<div
v-for="row in fillerRowCount"
:key="`filler:${row}`"
class="flex w-full min-w-0 select-none items-center gap-2 border-0 border-t border-solid border-surface-4 px-3 py-2 text-left"
:class="getRowBackgroundClass(visibleRowCount + row - 1)"
aria-hidden="true"
>
<span class="size-5 shrink-0" />
<span class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate text-sm font-medium opacity-0">.</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] text-left text-sm sm:block" />
<span class="hidden w-[132px] text-left text-sm sm:block" />
<span class="size-4 shrink-0" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ChevronDownIcon,
ChevronRightIcon,
ChevronUpIcon,
FileIcon,
UndoIcon,
} from '@modrinth/assets'
import { type Component, type ComponentPublicInstance, computed, ref, watch } from 'vue'
import { useFormatBytes } from '../../composables/format-bytes'
import { useFormatDateTime } from '../../composables/format-date-time'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getDirectoryIcon, getFileIcon } from '../../utils/auto-icons'
import { truncatedTooltip } from '../../utils/truncate'
import Checkbox from './Checkbox.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
name: {
id: 'files.table-header.name',
defaultMessage: 'Name',
},
size: {
id: 'files.table-header.size',
defaultMessage: 'Size',
},
modified: {
id: 'files.table-header.modified',
defaultMessage: 'Modified',
},
itemCount: {
id: 'files.row.item-count',
defaultMessage: '{count, plural, one {# item} other {# items}}',
},
parentFolder: {
id: 'files.row.parent-folder',
defaultMessage: 'Parent folder',
},
emptyFolderTitle: {
id: 'files.layout.empty-folder-title',
defaultMessage: 'This folder is empty',
},
})
export type FileTreeSelectItem = {
path: string
type?: 'directory' | 'file'
disabled?: boolean
size?: number
modified?: number
count?: number
}
type NormalizedFileTreeSelectItem = FileTreeSelectItem & {
name: string
normalizedPath: string
}
type FileTreeSelectEntry = {
path: string
name: string
type: 'directory' | 'file'
icon: Component
checked: boolean
indeterminate: boolean
disabled: boolean
size?: number
modified?: number
count?: number
item?: NormalizedFileTreeSelectItem
}
type FileTreeSelectSortField = 'name' | 'size' | 'modified'
const formatBytes = useFormatBytes()
const formatDateTime = useFormatDateTime({
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
})
const props = withDefaults(
defineProps<{
items: FileTreeSelectItem[]
modelValue: string[]
}>(),
{
items: () => [],
modelValue: () => [],
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
(e: 'navigate', path: string): void
}>()
const currentPath = ref('')
const sortField = ref<FileTreeSelectSortField>('name')
const sortDesc = ref(false)
const entryNameRefs = ref<Record<string, HTMLElement | null>>({})
const isHomePath = computed(() => currentPath.value === '')
const parentPath = computed(() => currentPath.value.split('/').slice(0, -1).join('/'))
const initialMinimumRowCount = ref(0)
const normalizedItems = computed(() => {
const items = new Map<string, NormalizedFileTreeSelectItem>()
for (const item of props.items) {
const normalizedPath = normalizePath(item.path)
if (!normalizedPath) continue
items.set(normalizedPath, {
...item,
path: item.path,
name: getName(normalizedPath),
normalizedPath,
})
}
return [...items.values()]
})
const selectedPaths = computed(() => new Set(props.modelValue.map((path) => normalizePath(path))))
const folderPaths = computed(() => {
const paths = new Set<string>()
for (const item of normalizedItems.value) {
const segments = item.normalizedPath.split('/')
for (let i = 1; i < segments.length; i++) {
paths.add(segments.slice(0, i).join('/'))
}
if (item.type === 'directory') {
paths.add(item.normalizedPath)
}
}
return paths
})
const entries = computed<FileTreeSelectEntry[]>(() => {
const directories = new Map<string, FileTreeSelectEntry>()
const files: FileTreeSelectEntry[] = []
const currentSegments = currentPath.value ? currentPath.value.split('/') : []
for (const item of normalizedItems.value) {
const segments = item.normalizedPath.split('/')
if (!isInCurrentPath(segments, currentSegments)) continue
const remaining = segments.slice(currentSegments.length)
if (remaining.length > 1) {
const directoryName = remaining[0]
const directoryPath = [...currentSegments, directoryName].join('/')
if (!directories.has(directoryPath)) {
directories.set(directoryPath, buildDirectoryEntry(directoryPath, directoryName))
}
} else if (remaining.length === 1) {
if (item.type === 'directory') {
directories.set(
item.normalizedPath,
buildDirectoryEntry(item.normalizedPath, item.name, item),
)
} else {
files.push(buildFileEntry(item))
}
}
}
return [...directories.values(), ...files].sort(compareEntries)
})
const visibleSelectableEntries = computed(() => entries.value.filter((entry) => !entry.disabled))
const visibleSelectablePaths = computed(() => {
const paths = new Set<string>()
for (const entry of visibleSelectableEntries.value) {
for (const path of getEntrySelectablePaths(entry)) {
paths.add(path)
}
}
return [...paths]
})
const allVisibleSelected = computed(
() =>
visibleSelectablePaths.value.length > 0 &&
visibleSelectablePaths.value.every((path) => selectedPaths.value.has(path)),
)
const someVisibleSelected = computed(() =>
visibleSelectablePaths.value.some((path) => selectedPaths.value.has(path)),
)
const visibleRowCount = computed(
() => entries.value.length + (isHomePath.value ? 0 : 1) + (entries.value.length === 0 ? 1 : 0),
)
const fillerRowCount = computed(() =>
Math.max(0, initialMinimumRowCount.value - visibleRowCount.value),
)
watch(
normalizedItems,
() => {
if (currentPath.value && !folderPaths.value.has(currentPath.value)) {
currentPath.value = ''
}
},
{ immediate: true },
)
watch(
visibleRowCount,
(rowCount) => {
if (isHomePath.value && rowCount > 1 && initialMinimumRowCount.value === 0) {
initialMinimumRowCount.value = rowCount
}
},
{ immediate: true },
)
watch(
isHomePath,
() => {
if (isHomePath.value && visibleRowCount.value > 1 && initialMinimumRowCount.value === 0) {
initialMinimumRowCount.value = visibleRowCount.value
}
},
{ flush: 'post' },
)
function normalizePath(path: string) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
}
function getName(path: string) {
return path.split('/').pop() ?? path
}
function isInCurrentPath(segments: string[], currentSegments: string[]) {
if (segments.length <= currentSegments.length) return false
return currentSegments.every((segment, index) => segments[index] === segment)
}
function handleSort(field: FileTreeSelectSortField) {
if (sortField.value === field) {
sortDesc.value = !sortDesc.value
} else {
sortField.value = field
sortDesc.value = false
}
}
function getRowBackgroundClass(index: number) {
return index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
}
function compareEntries(a: FileTreeSelectEntry, b: FileTreeSelectEntry) {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
switch (sortField.value) {
case 'modified':
return sortDesc.value
? getModifiedSortValue(a) - getModifiedSortValue(b)
: getModifiedSortValue(b) - getModifiedSortValue(a)
case 'size':
return sortDesc.value
? getSizeSortValue(a) - getSizeSortValue(b)
: getSizeSortValue(b) - getSizeSortValue(a)
default:
return sortDesc.value
? b.name.localeCompare(a.name, undefined, { numeric: true, sensitivity: 'base' })
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
}
}
function getSizeSortValue(entry: FileTreeSelectEntry) {
return entry.type === 'directory' ? (entry.count ?? 0) : (entry.size ?? 0)
}
function getModifiedSortValue(entry: FileTreeSelectEntry) {
return entry.modified ?? 0
}
function getFolderDescendants(path: string) {
return normalizedItems.value.filter((item) => item.normalizedPath.startsWith(`${path}/`))
}
function getFolderChildCount(path: string) {
const children = new Set<string>()
const prefix = `${path}/`
for (const item of normalizedItems.value) {
if (!item.normalizedPath.startsWith(prefix)) continue
const relativePath = item.normalizedPath.slice(prefix.length)
const [childName] = relativePath.split('/')
if (childName) {
children.add(childName)
}
}
return children.size
}
function getLatestModified(items: NormalizedFileTreeSelectItem[]) {
const modified = items
.map((item) => item.modified)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
if (modified.length === 0) return undefined
return Math.max(...modified)
}
function buildDirectoryEntry(
path: string,
name: string,
item?: NormalizedFileTreeSelectItem,
): FileTreeSelectEntry {
const descendants = getFolderDescendants(path).filter((item) => !item.disabled)
const selectedCount = descendants.filter((item) =>
selectedPaths.value.has(item.normalizedPath),
).length
const selected = selectedPaths.value.has(path)
return {
path,
name,
type: 'directory',
icon: getDirectoryIcon(name),
checked: selected || (descendants.length > 0 && selectedCount === descendants.length),
indeterminate: !selected && selectedCount > 0 && selectedCount < descendants.length,
disabled: item?.disabled ?? descendants.length === 0,
modified: item?.modified ?? getLatestModified(descendants),
count: item?.count ?? getFolderChildCount(path),
item,
}
}
function buildFileEntry(item: NormalizedFileTreeSelectItem): FileTreeSelectEntry {
return {
path: item.normalizedPath,
name: item.name,
type: 'file',
icon: getFileIcon(item.name),
checked: selectedPaths.value.has(item.normalizedPath),
indeterminate: false,
disabled: item.disabled ?? false,
size: item.size,
modified: item.modified,
item,
}
}
function navigateTo(path: string) {
currentPath.value = path
emit('navigate', path)
}
function setEntryNameRef(path: string, element: Element | ComponentPublicInstance | null) {
if (element instanceof HTMLElement) {
entryNameRefs.value[path] = element
} else {
entryNameRefs.value[path] = null
}
}
function selectEntry(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') {
navigateTo(entry.path)
} else {
toggleEntry(entry, !entry.checked)
}
}
function toggleEntry(entry: FileTreeSelectEntry, selected: boolean) {
if (entry.disabled) return
const nextSelectedPaths = new Set(selectedPaths.value)
const paths = getEntrySelectablePaths(entry)
for (const path of paths) {
if (selected) {
nextSelectedPaths.add(path)
} else {
nextSelectedPaths.delete(path)
}
}
emit('update:modelValue', [...nextSelectedPaths])
}
function getEntrySelectablePaths(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') {
return entry.item?.type === 'directory'
? [entry.path]
: getFolderDescendants(entry.path)
.filter((item) => !item.disabled)
.map((item) => item.normalizedPath)
}
return [entry.path]
}
function toggleAllVisible(selected: boolean) {
const nextSelectedPaths = new Set(selectedPaths.value)
for (const path of visibleSelectablePaths.value) {
if (selected) {
nextSelectedPaths.add(path)
} else {
nextSelectedPaths.delete(path)
}
}
emit('update:modelValue', [...nextSelectedPaths])
}
function formatSize(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') {
return formatMessage(messages.itemCount, { count: entry.count ?? 0 })
}
if (entry.size === undefined) return ''
return formatBytes(entry.size)
}
function formatModified(entry: FileTreeSelectEntry) {
if (entry.modified === undefined) return ''
return formatDateTime(new Date(entry.modified * 1000))
}
</script>
+2
View File
@@ -32,6 +32,8 @@ export { default as EmptyState } from './EmptyState.vue'
export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileInput } from './FileInput.vue'
export type { FileTreeSelectItem } from './FileTreeSelect.vue'
export { default as FileTreeSelect } from './FileTreeSelect.vue'
export type { FilterBarOption } from './FilterBar.vue'
export { default as FilterBar } from './FilterBar.vue'
export type { FilterPillOption } from './FilterPills.vue'