@@ -80,11 +81,14 @@
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
-
+
toggleSelection(row, selectRow, event)"
/>
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
-import { computed, toRef, useSlots } from 'vue'
+import { computed, ref, toRef, useSlots } from 'vue'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import Checkbox from './Checkbox.vue'
@@ -140,6 +144,7 @@ export interface TableColumn {
label?: string
align?: TableColumnAlign
enableSorting?: boolean
+ defaultSortDirection?: SortDirection
/**
* CSS width value for the column.
* Accepts any valid CSS width (e.g., '200px', '20%', '10rem', 'auto', 'fit-content').
@@ -153,6 +158,9 @@ const props = withDefaults(
data: T[] /* Row data for table */
showSelection?: boolean
rowKey?: keyof T /* The key used to uniquely identify each row */
+ selectionKey?: keyof T /* The key used to identify selectable rows */
+ selectionData?: T[] /* The complete selectable data set when data is paginated */
+ selectionIds?: unknown[] /* Complete selectable IDs when callers do not want to retain row objects */
virtualized?: boolean
virtualRowHeight?: number
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
@@ -170,6 +178,7 @@ const selectedIds = defineModel('selectedIds', { default: () => [] })
const sortColumn = defineModel('sortColumn')
const sortDirection = defineModel('sortDirection', { default: 'asc' })
const slots = useSlots()
+const selectionAnchorId = ref()
const hasHeaderSlot = computed(() => Boolean(slots.header))
const columnSpan = computed(() => Math.max(props.columns.length + (props.showSelection ? 1 : 0), 1))
@@ -201,17 +210,39 @@ const emit = defineEmits<{
sort: [column: string, direction: SortDirection]
}>()
+const selectableRows = computed(() => props.selectionData ?? props.data)
+const selectableRowIds = computed(
+ () => props.selectionIds ?? selectableRows.value.map((row) => getSelectionId(row)),
+)
+const selectedIdSet = computed(() => new Set(selectedIds.value))
+const selectedSelectableIdCount = computed(() => {
+ let count = 0
+ for (const id of selectableRowIds.value) {
+ if (selectedIdSet.value.has(id)) {
+ count++
+ }
+ }
+ return count
+})
const allSelected = computed(
- () => props.data.length > 0 && selectedIds.value.length === props.data.length,
+ () =>
+ selectableRowIds.value.length > 0 &&
+ selectedSelectableIdCount.value === selectableRowIds.value.length,
)
const someSelected = computed(
- () => selectedIds.value.length > 0 && selectedIds.value.length < props.data.length,
+ () =>
+ selectedSelectableIdCount.value > 0 &&
+ selectedSelectableIdCount.value < selectableRowIds.value.length,
)
function getRowId(row: T): unknown {
return row[props.rowKey as keyof T]
}
+function getSelectionId(row: T): unknown {
+ return row[(props.selectionKey ?? props.rowKey) as keyof T]
+}
+
function setListContainer(element: unknown) {
listContainer.value = props.virtualized ? (element as HTMLElement | null) : null
}
@@ -230,31 +261,66 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
}
function isSelected(row: T): boolean {
- return selectedIds.value.includes(getRowId(row))
+ return selectedIdSet.value.has(getSelectionId(row))
}
-function toggleSelection(row: T) {
- const id = getRowId(row)
- if (isSelected(row)) {
- selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id)
+function toggleSelection(row: T, selectRow: boolean, event?: MouseEvent) {
+ const id = getSelectionId(row)
+ const rowIndex = selectableRowIds.value.findIndex((selectableId) => selectableId === id)
+ const anchorIndex = selectableRowIds.value.findIndex(
+ (selectableId) => selectableId === selectionAnchorId.value,
+ )
+
+ if (event?.shiftKey && rowIndex !== -1 && anchorIndex !== -1) {
+ const startIndex = Math.min(rowIndex, anchorIndex)
+ const endIndex = Math.max(rowIndex, anchorIndex)
+ const rangeIds = selectableRowIds.value.slice(startIndex, endIndex + 1)
+
+ if (selectRow) {
+ const nextSelectedIds = [...selectedIds.value]
+ const nextSelectedIdSet = new Set(nextSelectedIds)
+ for (const rangeId of rangeIds) {
+ if (!nextSelectedIdSet.has(rangeId)) {
+ nextSelectedIds.push(rangeId)
+ nextSelectedIdSet.add(rangeId)
+ }
+ }
+ selectedIds.value = nextSelectedIds
+ } else {
+ const rangeIdSet = new Set(rangeIds)
+ selectedIds.value = selectedIds.value.filter((selectedId) => !rangeIdSet.has(selectedId))
+ }
} else {
- selectedIds.value = [...selectedIds.value, id]
+ selectedIds.value = selectRow
+ ? [...selectedIds.value, id]
+ : selectedIds.value.filter((selectedId) => selectedId !== id)
}
+
+ selectionAnchorId.value = id
}
function toggleSelectAll(selectAll: boolean) {
+ selectionAnchorId.value = undefined
if (selectAll) {
- selectedIds.value = props.data.map((row) => getRowId(row))
+ selectedIds.value = [...selectableRowIds.value]
} else {
selectedIds.value = []
}
}
function handleSort(columnKey: string) {
+ const column = props.columns.find((column) => column.key === columnKey)
+ const defaultDirection = column?.defaultSortDirection ?? 'asc'
const newDirection: SortDirection =
- sortColumn.value === columnKey && sortDirection.value === 'asc' ? 'desc' : 'asc'
+ sortColumn.value === columnKey && sortDirection.value === defaultDirection
+ ? getOppositeSortDirection(defaultDirection)
+ : defaultDirection
sortColumn.value = columnKey
sortDirection.value = newDirection
emit('sort', columnKey, newDirection)
}
+
+function getOppositeSortDirection(direction: SortDirection): SortDirection {
+ return direction === 'asc' ? 'desc' : 'asc'
+}
diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts
index 14f5382e30..9564a7a8be 100644
--- a/packages/ui/src/components/base/index.ts
+++ b/packages/ui/src/components/base/index.ts
@@ -50,7 +50,11 @@ export { default as LoadingBar } from './LoadingBar.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
export { default as MarkdownEditor } from './MarkdownEditor.vue'
-export type { MultiSelectOption } from './MultiSelect.vue'
+export type {
+ MultiSelectItem,
+ MultiSelectOption,
+ MultiSelectSectionHeader,
+} from './MultiSelect.vue'
export { default as MultiSelect } from './MultiSelect.vue'
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
@@ -77,7 +81,7 @@ export type { StackedAdmonitionItem, StackedAdmonitionType } from './StackedAdmo
export { default as StackedAdmonitions } from './StackedAdmonitions.vue'
export { default as StatItem } from './StatItem.vue'
export { default as StyledInput } from './StyledInput.vue'
-export type { TableColumn } from './Table.vue'
+export type { SortDirection, TableColumn } from './Table.vue'
export { default as Table } from './Table.vue'
export type { TabsTab, TabsValue } from './Tabs.vue'
export { default as Tabs } from './Tabs.vue'
diff --git a/packages/ui/src/composables/format-number.ts b/packages/ui/src/composables/format-number.ts
index 4cd10948cb..54ee6e1f58 100644
--- a/packages/ui/src/composables/format-number.ts
+++ b/packages/ui/src/composables/format-number.ts
@@ -24,7 +24,7 @@ export function useCompactNumber() {
const { locale } = injectI18n()
function formatCompactNumber(value: number | bigint): string {
- if (value < 10_000) {
+ if (value < 1000) {
const standardFormatter = getStandardFormatter(locale.value)
return standardFormatter.format(value)
}
diff --git a/packages/ui/src/composables/virtual-scroll.ts b/packages/ui/src/composables/virtual-scroll.ts
index 6b641efd4b..0c39d3ef21 100644
--- a/packages/ui/src/composables/virtual-scroll.ts
+++ b/packages/ui/src/composables/virtual-scroll.ts
@@ -65,12 +65,22 @@ export function useVirtualScroll(items: Ref, options: VirtualScrollOptio
}
function syncScrollState() {
- if (!scrollContainer.value) return
- scrollTop.value = getScrollTop(scrollContainer.value)
- viewportHeight.value = getViewportHeight(scrollContainer.value)
+ const listEl = listContainer.value
+ if (!listEl) return
+
+ const container = findScrollableAncestor(listEl)
+ scrollContainer.value = container
+ scrollTop.value = getScrollTop(container)
+ viewportHeight.value = getViewportHeight(container)
updateContainerOffset()
}
+ function resetScrollState() {
+ scrollTop.value = 0
+ viewportHeight.value = 0
+ containerOffset.value = 0
+ }
+
const visibleRange = computed(() => {
if (enabled && !enabled.value) {
return { start: 0, end: items.value.length }
@@ -165,5 +175,7 @@ export function useVirtualScroll(items: Ref, options: VirtualScrollOptio
visibleRange,
visibleTop,
visibleItems,
+ resetScrollState,
+ syncScrollState,
}
}
diff --git a/packages/ui/src/stories/base/Combobox.stories.ts b/packages/ui/src/stories/base/Combobox.stories.ts
index 9fa6e693da..6a7d96eabf 100644
--- a/packages/ui/src/stories/base/Combobox.stories.ts
+++ b/packages/ui/src/stories/base/Combobox.stories.ts
@@ -32,6 +32,17 @@ export const Default: Story = {
},
}
+export const WithSelectedOption: Story = {
+ args: {
+ modelValue: '2',
+ options: [
+ { value: '1', label: 'Option 1' },
+ { value: '2', label: 'Option 2' },
+ { value: '3', label: 'Option 3' },
+ ],
+ },
+}
+
export const Searchable: Story = {
args: {
options: [
@@ -64,6 +75,54 @@ export const SearchableEmpty: Story = {
},
}
+export const DropdownMinWidth: StoryObj = {
+ render: () => ({
+ components: { Combobox },
+ data: () => ({
+ selected: undefined,
+ options: [
+ { value: 'fabric', label: 'Fabric', subLabel: 'Lightweight modding toolchain' },
+ { value: 'forge', label: 'Forge', subLabel: 'The original Minecraft modding API' },
+ { value: 'neoforge', label: 'NeoForge', subLabel: 'Community-driven Forge fork' },
+ ],
+ }),
+ template: /*html*/ `
+
+
+
+ `,
+ }),
+}
+
+export const DropdownClass: StoryObj = {
+ render: () => ({
+ components: { Combobox },
+ data: () => ({
+ selected: undefined,
+ options: [
+ { value: 'fabric', label: 'Fabric' },
+ { value: 'forge', label: 'Forge' },
+ { value: 'neoforge', label: 'NeoForge' },
+ ],
+ }),
+ template: /*html*/ `
+
+
+
+ `,
+ }),
+}
+
export const Disabled: Story = {
args: {
options: [{ value: '1', label: 'Option 1' }],
@@ -72,40 +131,40 @@ export const Disabled: Story = {
},
}
-export const WithSubLabels: Story = {
+export const SearchableWithIcons: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
{ value: 'share', label: 'Share', icon: ShareIcon },
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
- { type: 'divider' },
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
{ value: 'profile', label: 'Profile', icon: UserIcon },
- { type: 'divider' },
- { value: 'delete', label: 'Delete', icon: TrashIcon, disabled: true },
+ { value: 'delete', label: 'Delete', icon: TrashIcon },
],
placeholder: 'Select an action',
- listbox: false,
+ searchable: true,
+ searchPlaceholder: 'Search actions...',
},
}
-export const SearchableWithIcons: Story = {
+export const WithDividers: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
{ value: 'share', label: 'Share', icon: ShareIcon },
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
+ { type: 'divider' },
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
{ value: 'profile', label: 'Profile', icon: UserIcon },
- { value: 'delete', label: 'Delete', icon: TrashIcon },
+ { type: 'divider' },
+ { value: 'delete', label: 'Delete', icon: TrashIcon, disabled: true },
],
placeholder: 'Select an action',
- searchable: true,
- searchPlaceholder: 'Search actions...',
+ listbox: false,
},
}
-export const WithSelectedOption: Story = {
+export const WithSubLabel: Story = {
args: {
modelValue: '2',
options: [
@@ -117,32 +176,30 @@ export const WithSelectedOption: Story = {
},
}
-export const SearchableNoFilter: Story = {
+export const MixedSubLabels: Story = {
args: {
options: [
- { value: 'download', label: 'Download', icon: DownloadIcon },
- { value: 'share', label: 'Share', icon: ShareIcon },
- { value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
- { value: 'settings', label: 'Settings', icon: SettingsIcon },
- { value: 'profile', label: 'Profile', icon: UserIcon },
+ { value: '1', label: 'Minecraft', subLabel: 'The base game' },
+ { value: '2', label: 'Fabric' },
+ { value: '3', label: 'Forge', subLabel: 'Supports most mods' },
+ { value: '4', label: 'NeoForge' },
+ { value: '5', label: 'Quilt', subLabel: 'Fabric-compatible' },
],
- searchable: true,
- searchPlaceholder: 'Search actions...',
- disableSearchFilter: true,
},
}
-export const SearchableModpacks: Story = {
+export const SearchableNoFilter: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
{ value: 'share', label: 'Share', icon: ShareIcon },
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
+ { value: 'profile', label: 'Profile', icon: UserIcon },
],
searchable: true,
- searchPlaceholder: 'Search modpacks...',
- noOptionsMessage: 'No modpacks found',
+ searchPlaceholder: 'Search actions...',
+ disableSearchFilter: true,
},
}
@@ -194,15 +251,48 @@ export const WithDropdownFooter: StoryObj = {
}),
}
-export const MixedSubLabels: Story = {
- args: {
- options: [
- { value: '1', label: 'Minecraft', subLabel: 'The base game' },
- { value: '2', label: 'Fabric' },
- { value: '3', label: 'Forge', subLabel: 'Supports most mods' },
- { value: '4', label: 'NeoForge' },
- { value: '5', label: 'Quilt', subLabel: 'Fabric-compatible' },
- ],
+export const DropdownFooterOnly: StoryObj = {
+ render: () => ({
+ components: { Combobox },
+ data: () => ({
+ selected: undefined,
+ options: [],
+ }),
+ template: /*html*/ `
+
+
+
+
+
Dropdown footer content
+
+ This dropdown has no options and stays open because its footer slot is content.
+
+
+
+ Cancel
+
+
+ Apply
+
+
+
+
+
+
+ `,
+ }),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Covers dropdowns whose only rendered content is the footer slot, such as the analytics custom date range picker.',
+ },
+ },
},
}
@@ -258,3 +348,22 @@ export const SearchableWithOptionAndSelectionAffix: StoryObj = {
`,
}),
}
+
+export const ManyOptionsOverflow: Story = {
+ args: {
+ options: Array.from({ length: 40 }, (_, index) => ({
+ value: `${index + 1}`,
+ label: `Option ${index + 1}`,
+ })),
+ placeholder: 'Select an option',
+ maxHeight: 380,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Covers long option lists where the dropdown content should scroll within its max height.',
+ },
+ },
+ },
+}
diff --git a/packages/ui/src/stories/base/DropdownFilterBar.stories.ts b/packages/ui/src/stories/base/DropdownFilterBar.stories.ts
index 94971c2eac..be074b0b36 100644
--- a/packages/ui/src/stories/base/DropdownFilterBar.stories.ts
+++ b/packages/ui/src/stories/base/DropdownFilterBar.stories.ts
@@ -1,3 +1,4 @@
+import { BoxIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
@@ -55,10 +56,57 @@ const searchableCategories = [
searchPlaceholder: 'Search versions...',
submenuClass: 'w-[360px]',
options: [
- { value: '1.21.5', label: '1.21.5' },
- { value: '1.21.4', label: '1.21.4' },
- { value: '1.20.1', label: '1.20.1' },
- { value: '1.19.2', label: '1.19.2' },
+ { value: '1.21.5', label: '1.21.5', searchTerms: ['Sodium'] },
+ { value: '1.21.4', label: '1.21.4', searchTerms: ['Sodium'] },
+ { value: '1.20.1', label: '1.20.1', searchTerms: ['Iris'] },
+ { value: '1.19.2', label: '1.19.2', searchTerms: ['Mod Menu'] },
+ ],
+ },
+]
+
+const largeVersionOptions = Array.from({ length: 250 }, (_, index) => {
+ const version = `1.${Math.floor(index / 10) + 1}.${index % 10}`
+ const project = `Project ${Math.floor(index / 25) + 1}`
+ return {
+ value: `version-${index + 1}`,
+ label: version,
+ searchTerms: [project],
+ }
+})
+
+const mixedWidthCategories = [
+ {
+ key: 'status',
+ label: 'Status',
+ options: [
+ { value: 'active', label: 'Active' },
+ { value: 'archived', label: 'Archived' },
+ { value: 'draft', label: 'Draft' },
+ ],
+ },
+ {
+ key: 'country',
+ label: 'Country',
+ searchable: true,
+ searchPlaceholder: 'Search countries...',
+ submenuClass: 'w-[324px]',
+ options: [
+ { value: 'US', label: 'United States' },
+ { value: 'CA', label: 'Canada' },
+ { value: 'DE', label: 'Germany' },
+ { value: 'JP', label: 'Japan' },
+ ],
+ },
+ {
+ key: 'version',
+ label: 'Project version',
+ searchable: true,
+ searchPlaceholder: 'Search project versions...',
+ submenuClass: 'w-[368px]',
+ options: [
+ { value: 'sodium-1.21.5', label: 'Sodium 1.21.5' },
+ { value: 'iris-1.21.4', label: 'Iris 1.21.4' },
+ { value: 'mod-menu-1.20.1', label: 'Mod Menu 1.20.1' },
],
},
]
@@ -90,11 +138,21 @@ export const WithAppliedFilters: Story = {
status: ['active'],
type: ['mod', 'plugin'],
})
- return { categories: defaultCategories, selected }
+ const clearEvents = ref(0)
+ function handleClear() {
+ clearEvents.value += 1
+ }
+
+ return { categories: defaultCategories, clearEvents, handleClear, selected }
},
template: /* html */ `
-
+
+ Clear events: {{ clearEvents }}
`,
}),
@@ -107,6 +165,37 @@ export const WithAppliedFilters: Story = {
},
}
+export const WithClearOverride: Story = {
+ render: () => ({
+ components: { DropdownFilterBar },
+ setup() {
+ const selected = ref>({})
+ const clearEvents = ref(0)
+ function handleClear() {
+ clearEvents.value += 1
+ }
+
+ return { categories: defaultCategories, clearEvents, handleClear, selected }
+ },
+ template: /* html */ `
+
+
+ Clear events: {{ clearEvents }}
+
+ `,
+ }),
+ args: {
+ modelValue: {},
+ categories: defaultCategories,
+ showClear: true,
+ },
+}
+
export const WithFilterIcon: Story = {
render: () => ({
components: { DropdownFilterBar },
@@ -133,14 +222,33 @@ export const WithFilterIcon: Story = {
export const SearchableCategories: Story = {
render: () => ({
- components: { DropdownFilterBar },
+ components: { BoxIcon, DropdownFilterBar },
setup() {
const selected = ref>({})
- return { categories: searchableCategories, selected }
+ const versionProjects: Record = {
+ '1.21.5': 'Sodium',
+ '1.21.4': 'Sodium',
+ '1.20.1': 'Iris',
+ '1.19.2': 'Mod Menu',
+ }
+ function getVersionProject(categoryKey: string, optionValue: string) {
+ return categoryKey === 'version' ? versionProjects[optionValue] : undefined
+ }
+ return { categories: searchableCategories, getVersionProject, selected }
},
template: /* html */ `
-
+
+
+
+
+
+
+
`,
}),
@@ -150,11 +258,165 @@ export const SearchableCategories: Story = {
},
}
-export const CustomControls: Story = {
+export const MixedSubmenuWidthsNearEdge: Story = {
render: () => ({
components: { DropdownFilterBar },
setup() {
const selected = ref>({})
+ return { categories: mixedWidthCategories, selected }
+ },
+ template: /* html */ `
+
+
+
+ `,
+ }),
+ args: {
+ modelValue: {},
+ categories: mixedWidthCategories,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Covers mixed submenu widths near the viewport edge so all add-menu submenus open on the same side.',
+ },
+ },
+ },
+}
+
+export const VirtualizedPreview: Story = {
+ render: () => ({
+ components: { BoxIcon, DropdownFilterBar },
+ setup() {
+ const selected = ref>({
+ version: ['version-3', 'version-47', 'version-132'],
+ })
+ const categories = [
+ {
+ key: 'version',
+ label: 'Version',
+ searchable: true,
+ searchPlaceholder: 'Search versions...',
+ submenuClass: 'w-[360px]',
+ previewDropdownWidth: '360px',
+ options: largeVersionOptions,
+ },
+ ]
+ function getVersionProject(categoryKey: string, optionValue: string) {
+ if (categoryKey !== 'version') {
+ return undefined
+ }
+ const optionIndex = Number(optionValue.replace('version-', '')) - 1
+ return `Project ${Math.floor(optionIndex / 25) + 1}`
+ }
+ return { categories, getVersionProject, selected }
+ },
+ template: /* html */ `
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+ args: {
+ modelValue: {
+ version: ['version-3', 'version-47', 'version-132'],
+ },
+ categories: [
+ {
+ key: 'version',
+ label: 'Version',
+ searchable: true,
+ searchPlaceholder: 'Search versions...',
+ submenuClass: 'w-[360px]',
+ previewDropdownWidth: '360px',
+ options: largeVersionOptions,
+ },
+ ],
+ },
+}
+
+export const VirtualizedSubmenu: Story = {
+ render: () => ({
+ components: { BoxIcon, DropdownFilterBar },
+ setup() {
+ const selected = ref>({})
+ const categories = [
+ {
+ key: 'version',
+ label: 'Version',
+ searchable: true,
+ searchPlaceholder: 'Search versions...',
+ submenuClass: 'w-[360px]',
+ options: largeVersionOptions,
+ },
+ ]
+ function getVersionProject(categoryKey: string, optionValue: string) {
+ if (categoryKey !== 'version') {
+ return undefined
+ }
+ const optionIndex = Number(optionValue.replace('version-', '')) - 1
+ return `Project ${Math.floor(optionIndex / 25) + 1}`
+ }
+ return { categories, getVersionProject, selected }
+ },
+ template: /* html */ `
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+ args: {
+ modelValue: {},
+ categories: [
+ {
+ key: 'version',
+ label: 'Version',
+ searchable: true,
+ searchPlaceholder: 'Search versions...',
+ submenuClass: 'w-[360px]',
+ options: largeVersionOptions,
+ },
+ ],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Covers the add-menu submenu with flush rows, square hover states, and OverlayScrollbars.',
+ },
+ },
+ },
+}
+
+export const CustomControls: Story = {
+ render: () => ({
+ components: { DropdownFilterBar },
+ setup() {
+ const selected = ref>({
+ version: ['1.21.5'],
+ })
+ const minimumDownloads = ref('1k')
const releaseOnly = ref(true)
const categories = [
{
@@ -171,7 +433,7 @@ export const CustomControls: Story = {
],
},
]
- return { categories, releaseOnly, selected }
+ return { categories, minimumDownloads, releaseOnly, selected }
},
template: /* html */ `
@@ -193,12 +455,33 @@ export const CustomControls: Story = {
+
+
+
+ Versions above
+
+
+ downloads
+
+
`,
}),
args: {
- modelValue: {},
+ modelValue: {
+ version: ['1.21.5'],
+ },
categories: searchableCategories,
},
}
diff --git a/packages/ui/src/stories/base/MultiSelect.stories.ts b/packages/ui/src/stories/base/MultiSelect.stories.ts
index 5fc6d00bd0..8320edf99e 100644
--- a/packages/ui/src/stories/base/MultiSelect.stories.ts
+++ b/packages/ui/src/stories/base/MultiSelect.stories.ts
@@ -1,4 +1,4 @@
-import { CheckIcon } from '@modrinth/assets'
+import { BoxIcon, CheckIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, ref } from 'vue'
@@ -43,6 +43,14 @@ export const Default: Story = {
modelValue: ['en', 'es', 'fr', 'zh-CN'],
placeholder: 'Select languages',
},
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Options render flush to the dropdown edges with full-width hover and selected states.',
+ },
+ },
+ },
}
export const WithSearch: Story = {
@@ -53,6 +61,48 @@ export const WithSearch: Story = {
},
}
+export const WithOptionRightSlot: Story = {
+ args: {
+ options: [
+ { value: 'sodium-1.21.5', label: '1.21.5', searchTerms: ['Sodium'] },
+ { value: 'sodium-1.21.4', label: '1.21.4', searchTerms: ['Sodium'] },
+ { value: 'iris-1.20.1', label: '1.20.1', searchTerms: ['Iris'] },
+ { value: 'modmenu-1.19.2', label: '1.19.2', searchTerms: ['Mod Menu'] },
+ ],
+ modelValue: ['sodium-1.21.5'],
+ placeholder: 'Select versions',
+ searchable: true,
+ searchPlaceholder: 'Search versions',
+ },
+ render: (args) => ({
+ components: { BoxIcon, MultiSelect },
+ setup() {
+ const selected = ref(args.modelValue)
+ const projectNames: Record = {
+ 'sodium-1.21.5': 'Sodium',
+ 'sodium-1.21.4': 'Sodium',
+ 'iris-1.20.1': 'Iris',
+ 'modmenu-1.19.2': 'Mod Menu',
+ }
+ return { args, projectNames, selected }
+ },
+ template: /*html*/ `
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+}
+
export const WithSelectAll: Story = {
args: {
...Default.args,
@@ -62,6 +112,16 @@ export const WithSelectAll: Story = {
},
}
+export const WithRightCheckbox: Story = {
+ args: {
+ ...Default.args,
+ searchable: true,
+ includeSelectAllOption: true,
+ checkboxPosition: 'right',
+ searchPlaceholder: 'Search languages',
+ },
+}
+
export const WithSelectionActions: Story = {
args: {
...Default.args,
@@ -71,6 +131,26 @@ export const WithSelectionActions: Story = {
},
}
+export const WithSections: Story = {
+ args: {
+ options: [
+ { value: 'iris', label: 'Iris' },
+ { value: 'sodium', label: 'Sodium' },
+ { type: 'section-header', label: 'LambdAurora' },
+ { value: 'lambda-better-grass', label: 'LambdaBetterGrass', searchTerms: ['LambdAurora'] },
+ { value: 'auroras-decorations', label: "Aurora's Decorations", searchTerms: ['LambdAurora'] },
+ { type: 'section-header', label: 'Terraformers' },
+ { value: 'modmenu', label: 'Mod Menu', searchTerms: ['Terraformers'] },
+ { value: 'terraform-api', label: 'Terraform API', searchTerms: ['Terraformers'] },
+ ],
+ modelValue: ['iris', 'modmenu'],
+ placeholder: 'Select projects',
+ searchable: true,
+ showSelectionActions: true,
+ searchPlaceholder: 'Search projects',
+ },
+}
+
export const WithTopSlot: Story = {
args: {
...Default.args,
@@ -249,6 +329,50 @@ export const WithBottomSlot: Story = {
}),
}
+export const VirtualizedLargeList: Story = {
+ args: {
+ options: Array.from({ length: 250 }, (_, index) => {
+ const version = `1.${Math.floor(index / 10) + 1}.${index % 10}`
+ return {
+ value: `version-${index + 1}`,
+ label: version,
+ searchTerms: [`Project ${Math.floor(index / 25) + 1}`],
+ }
+ }),
+ modelValue: ['version-3', 'version-47', 'version-132'],
+ placeholder: 'Select versions',
+ searchable: true,
+ searchPlaceholder: 'Search versions',
+ showSelectionActions: true,
+ maxHeight: 320,
+ },
+ render: (args) => ({
+ components: { BoxIcon, MultiSelect },
+ setup() {
+ const selected = ref(args.modelValue)
+ function getProjectName(value: string) {
+ const optionIndex = Number(value.replace('version-', '')) - 1
+ return `Project ${Math.floor(optionIndex / 25) + 1}`
+ }
+ return { args, getProjectName, selected }
+ },
+ template: /*html*/ `
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+}
+
export const NoOptions: Story = {
args: {
...Default.args,
diff --git a/packages/ui/src/stories/base/Table.stories.ts b/packages/ui/src/stories/base/Table.stories.ts
index 4d578c077d..65c0dc38a0 100644
--- a/packages/ui/src/stories/base/Table.stories.ts
+++ b/packages/ui/src/stories/base/Table.stories.ts
@@ -28,6 +28,20 @@ const sampleUsers: User[] = [
role: 'Admin',
},
]
+const rangeSelectionUsers: User[] = Array.from({ length: 10 }, (_, index): User => {
+ const id = String(index + 1)
+ const paddedId = id.padStart(2, '0')
+ const statuses: User['status'][] = ['active', 'inactive', 'pending']
+ const roles = ['Admin', 'Editor', 'Maintainer', 'Reviewer', 'User']
+
+ return {
+ id,
+ name: `Member ${paddedId}`,
+ email: `member-${paddedId}@example.com`,
+ status: statuses[index % statuses.length],
+ role: roles[index % roles.length],
+ }
+})
const meta = {
title: 'Base/Table',
@@ -68,7 +82,7 @@ export const WithSelection: StoryObj = {
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role' },
]
- const data = sampleUsers
+ const data = rangeSelectionUsers
const selectedIds = ref([])
return { columns, data, selectedIds }
},
@@ -81,6 +95,73 @@ export const WithSelection: StoryObj = {
row-key="id"
v-model:selected-ids="selectedIds"
/>
+ Click a checkbox, then Shift-click another checkbox to select or clear the range.
+ Selected IDs: {{ selectedIds.join(', ') || 'None' }}
+
+ `,
+ }),
+}
+
+export const WithSelectionData: StoryObj = {
+ args: {},
+ render: () => ({
+ components: { Table },
+ setup() {
+ const columns = [
+ { key: 'name', label: 'Name' },
+ { key: 'email', label: 'Email' },
+ { key: 'status', label: 'Status' },
+ { key: 'role', label: 'Role' },
+ ]
+ const selectionData = rangeSelectionUsers
+ const data = selectionData.filter((_, index) => index === 1 || index === 5)
+ const selectedIds = ref([])
+ return { columns, data, selectionData, selectedIds }
+ },
+ template: /* html */ `
+
+
+
Only rows 2 and 6 are visible; Shift-clicking between them selects IDs 2 through 6 from selectionData.
+
Selected IDs: {{ selectedIds.join(', ') || 'None' }}
+
+ `,
+ }),
+}
+
+export const WithSelectionIds: StoryObj = {
+ args: {},
+ render: () => ({
+ components: { Table },
+ setup() {
+ const columns = [
+ { key: 'name', label: 'Name' },
+ { key: 'email', label: 'Email' },
+ { key: 'status', label: 'Status' },
+ { key: 'role', label: 'Role' },
+ ]
+ const data = rangeSelectionUsers.filter((_, index) => index === 1 || index === 5)
+ const selectionIds = rangeSelectionUsers.map((user) => user.id)
+ const selectedIds = ref([])
+ return { columns, data, selectionIds, selectedIds }
+ },
+ template: /* html */ `
+
+
+
Only rows 2 and 6 are visible; Shift-clicking between them selects IDs 2 through 6 from selectionIds.
Selected IDs: {{ selectedIds.join(', ') || 'None' }}
`,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 73d2dde664..765745886b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -302,6 +302,9 @@ importers:
ansi-to-html:
specifier: ^0.7.2
version: 0.7.2
+ chart.js:
+ specifier: ^4.5.1
+ version: 4.5.1
dayjs:
specifier: ^1.11.7
version: 1.11.19
@@ -712,6 +715,9 @@ importers:
motion-v:
specifier: ^2.2.1
version: 2.2.1(@vueuse/core@11.3.0(vue@3.5.27(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.27(typescript@5.9.3))
+ overlayscrollbars:
+ specifier: ^2.15.1
+ version: 2.15.1
postprocessing:
specifier: ^6.37.6
version: 6.38.2(three@0.172.0)
@@ -2492,6 +2498,9 @@ packages:
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
+ '@kurkle/color@0.3.4':
+ resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
+
'@kwsites/file-exists@1.1.1':
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
@@ -5399,6 +5408,10 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+ chart.js@4.5.1:
+ resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
+ engines: {pnpm: '>=8'}
+
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -11539,6 +11552,8 @@ snapshots:
'@jsdevtools/ono@7.1.3': {}
+ '@kurkle/color@0.3.4': {}
+
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.3
@@ -13302,7 +13317,7 @@ snapshots:
eslint-visitor-keys: 4.2.1
espree: 10.4.0
estraverse: 5.3.0
- picomatch: 4.0.3
+ picomatch: 4.0.4
transitivePeerDependencies:
- supports-color
- typescript
@@ -14869,6 +14884,10 @@ snapshots:
character-reference-invalid@2.0.1: {}
+ chart.js@4.5.1:
+ dependencies:
+ '@kurkle/color': 0.3.4
+
check-error@2.1.3: {}
chevrotain@7.1.1:
@@ -15551,7 +15570,7 @@ snapshots:
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3
minimatch: 10.1.2
- semver: 7.7.3
+ semver: 7.7.4
stable-hash-x: 0.2.0
unrs-resolver: 1.11.1
optionalDependencies:
@@ -15586,7 +15605,7 @@ snapshots:
espree: 10.4.0
esquery: 1.7.0
parse-imports-exports: 0.2.4
- semver: 7.7.3
+ semver: 7.7.4
spdx-expression-parse: 4.0.0
transitivePeerDependencies:
- supports-color
@@ -15684,7 +15703,7 @@ snapshots:
read-pkg-up: 7.0.1
regexp-tree: 0.1.27
regjsparser: 0.10.0
- semver: 7.7.3
+ semver: 7.7.4
strip-indent: 3.0.0
eslint-plugin-vue@10.7.0(@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))):
@@ -15723,7 +15742,7 @@ snapshots:
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 6.1.2
- semver: 7.7.3
+ semver: 7.7.4
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@2.6.1))
xml-name-validator: 4.0.0
transitivePeerDependencies:
@@ -20350,7 +20369,7 @@ snapshots:
espree: 9.6.1
esquery: 1.7.0
lodash: 4.17.23
- semver: 7.7.3
+ semver: 7.7.4
transitivePeerDependencies:
- supports-color