+
-
+
{{ triggerText }}
@@ -95,6 +95,7 @@
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
:class="[
+ props.dropdownClass,
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
]"
:style="dropdownStyle"
@@ -104,59 +105,73 @@
>
-
-
-
-
-
-
-
-
-
- {{ item.label }}
-
-
- {{ item.subLabel }}
-
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+ {{ item.subLabel }}
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
@@ -171,8 +186,11 @@
+
+
diff --git a/packages/ui/src/components/base/DatePicker.vue b/packages/ui/src/components/base/DatePicker.vue
index 55b4c8292d..515257a6fe 100644
--- a/packages/ui/src/components/base/DatePicker.vue
+++ b/packages/ui/src/components/base/DatePicker.vue
@@ -32,13 +32,22 @@
:aria-hidden="calendarOnly ? 'true' : undefined"
type="text"
/>
+
+
+
@@ -1625,7 +1645,7 @@ defineExpose({
}
.modrinth-date-picker :deep(.flatpickr-time) {
- @apply mt-2 flex h-11 max-h-none items-center gap-2 border-0 border-t border-solid border-surface-5 px-1 pt-2 leading-none;
+ @apply mt-2 flex h-11 max-h-none items-center gap-2 border-0 border-t border-solid border-surface-5 px-1 pt-2 overflow-visible leading-none;
}
.modrinth-date-picker :deep(.flatpickr-time .numInputWrapper) {
diff --git a/packages/ui/src/components/base/DropdownFilterBar.vue b/packages/ui/src/components/base/DropdownFilterBar.vue
index 2cc71a73c1..838842f350 100644
--- a/packages/ui/src/components/base/DropdownFilterBar.vue
+++ b/packages/ui/src/components/base/DropdownFilterBar.vue
@@ -1,5 +1,6 @@
@@ -10,6 +11,7 @@
setPreviewSelectedValues(preview.key, nextValue)"
@open="openPreviewFilterDraft(preview.key)"
@close="commitPreviewFilterDraft(preview.key)"
>
+
+
+
-
+
+
{{ preview.label }}:
{{ preview.summary }}
@@ -48,13 +66,32 @@
+
+
+
+
+
+
-
+
-
+
{{ clearLabel }}
@@ -78,9 +115,9 @@
leave-to-class="opacity-0"
>
{{ category.label }}
@@ -109,37 +147,63 @@
handleMenuMouseMove(event, 'submenu')"
>
+
+
+
+ {{ activeCategory.label }}
+
+
+
+
+
+
{{ activeCategorySelectionLabel }}
-
diff --git a/packages/ui/src/components/base/Table.vue b/packages/ui/src/components/base/Table.vue
index 9636cd8a4b..ceb99dcdd8 100644
--- a/packages/ui/src/components/base/Table.vue
+++ b/packages/ui/src/components/base/Table.vue
@@ -6,114 +6,123 @@
>
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
- {{ column.label ?? '' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- No data available.
-
-
-
-
-
-
-
-
-
-
+ :style="column.width ? { width: column.width } : undefined"
+ />
+
+
+
+
-
-
+
-
- {{ row[column.key] ?? '' }}
+
+
+ {{ column.label ?? '' }}
+
+
+
+
+
-
+
-
-
+
+
+
+
+
+
+ No data available.
+
+
+
-
-
-
+
+
+
+
+
+
+ toggleSelection(row, selectRow, event)"
+ />
+
+
+
+ {{ row[column.key] ?? '' }}
+
+
+
+
+
+
+
+
+
+
@@ -123,7 +132,7 @@
generic="K extends string = string, T extends Record
= Record"
>
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 +149,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,9 +163,16 @@ 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 */
+ /**
+ * Sets a minimum width for the table content, allowing horizontal overflow below that width.
+ */
+ tableMinWidth?: string
}>(),
{
showSelection: false,
@@ -170,6 +187,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 +219,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 +270,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/TimeFramePicker.vue b/packages/ui/src/components/base/TimeFramePicker.vue
new file mode 100644
index 0000000000..e47eff6311
--- /dev/null
+++ b/packages/ui/src/components/base/TimeFramePicker.vue
@@ -0,0 +1,1090 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.startDate) }}
+
+
+
+
+
+ {{ formatMessage(messages.endDate) }}
+
+
+
+
+
+
+
+
+ {{ rangeLabel }}:
+ {{ formattedRange }}
+
+ {{ formatMessage(messages.clearRange) }}
+
+
+
+
+
+ {{ formatMessage(messages.emptyRange) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.cancel) }}
+
+
+
+
+ {{ formatMessage(messages.apply) }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.lastTimeframePrefix) }}
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+ {{ formatMessage(messages.customRange) }}
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts
index 14f5382e30..5afcec8f57 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,12 +81,20 @@ 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'
export { default as TagItem } from './TagItem.vue'
export { default as TagTagItem } from './TagTagItem.vue'
+export type {
+ TimeFrameLastUnit,
+ TimeFrameLastUnitOption,
+ TimeFrameMode,
+ TimeFramePickerSelection,
+ TimeFramePreset,
+} from './TimeFramePicker.vue'
+export { default as TimeFramePicker } from './TimeFramePicker.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.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 5cfa12ad66..68eac1f3c9 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 }
@@ -169,5 +179,7 @@ export function useVirtualScroll(items: Ref, options: VirtualScrollOptio
visibleRange,
visibleTop,
visibleItems,
+ resetScrollState,
+ syncScrollState,
}
}
diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json
index 6da9a65ec4..80704b4ab7 100644
--- a/packages/ui/src/locales/en-US/index.json
+++ b/packages/ui/src/locales/en-US/index.json
@@ -4313,6 +4313,93 @@
"tag.loader.waterfall": {
"defaultMessage": "Waterfall"
},
+ "time-frame-picker.apply": {
+ "defaultMessage": "Apply"
+ },
+ "time-frame-picker.cancel": {
+ "defaultMessage": "Cancel"
+ },
+ "time-frame-picker.clear-range": {
+ "defaultMessage": "Clear"
+ },
+ "time-frame-picker.custom-range": {
+ "defaultMessage": "Custom fixed date range..."
+ },
+ "time-frame-picker.decrease-amount": {
+ "defaultMessage": "Decrease timeframe amount"
+ },
+ "time-frame-picker.empty-range": {
+ "defaultMessage": "No date range selected."
+ },
+ "time-frame-picker.end-date": {
+ "defaultMessage": "End date"
+ },
+ "time-frame-picker.increase-amount": {
+ "defaultMessage": "Increase timeframe amount"
+ },
+ "time-frame-picker.last-timeframe": {
+ "defaultMessage": "In the last {amount} {unit, select, hours {{amount, plural, one {hour} other {hours}}} days {{amount, plural, one {day} other {days}}} weeks {{amount, plural, one {week} other {weeks}}} months {{amount, plural, one {month} other {months}}} other {days}}"
+ },
+ "time-frame-picker.last-timeframe-prefix": {
+ "defaultMessage": "In the last"
+ },
+ "time-frame-picker.option.all-time": {
+ "defaultMessage": "All time"
+ },
+ "time-frame-picker.option.last-14-days": {
+ "defaultMessage": "Last 14 days"
+ },
+ "time-frame-picker.option.last-180-days": {
+ "defaultMessage": "Last 180 days"
+ },
+ "time-frame-picker.option.last-30-days": {
+ "defaultMessage": "Last 30 days"
+ },
+ "time-frame-picker.option.last-7-days": {
+ "defaultMessage": "Last 7 days"
+ },
+ "time-frame-picker.option.last-90-days": {
+ "defaultMessage": "Last 90 days"
+ },
+ "time-frame-picker.option.today": {
+ "defaultMessage": "Today"
+ },
+ "time-frame-picker.option.year-to-date": {
+ "defaultMessage": "Year to date"
+ },
+ "time-frame-picker.option.yesterday": {
+ "defaultMessage": "Yesterday"
+ },
+ "time-frame-picker.select-timeframe": {
+ "defaultMessage": "Select timeframe"
+ },
+ "time-frame-picker.selected-range": {
+ "defaultMessage": "Selected"
+ },
+ "time-frame-picker.selecting-range": {
+ "defaultMessage": "Selecting"
+ },
+ "time-frame-picker.start-date": {
+ "defaultMessage": "Start date"
+ },
+ "time-frame-picker.timeframe-amount": {
+ "defaultMessage": "Timeframe amount"
+ },
+ "time-frame-picker.timeframe-unit": {
+ "defaultMessage": "Timeframe unit"
+ },
+ "time-frame-picker.unit.days": {
+ "defaultMessage": "days"
+ },
+ "time-frame-picker.unit.hours": {
+ "defaultMessage": "hours"
+ },
+ "time-frame-picker.unit.months": {
+ "defaultMessage": "months"
+ },
+ "time-frame-picker.unit.weeks": {
+ "defaultMessage": "weeks"
+ },
"ui.component.unsaved-changes-popup.body": {
"defaultMessage": "You have unsaved changes."
},
diff --git a/packages/ui/src/stories/base/Combobox.stories.ts b/packages/ui/src/stories/base/Combobox.stories.ts
index 9fa6e693da..07bf9a7f15 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,56 @@ 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.',
+ },
+ },
+ },
+}
+
+export const ScrollRepositioning: StoryObj = {
+ render: () => ({
+ components: { Combobox },
+ data: () => ({
+ selected: undefined,
+ options: Array.from({ length: 16 }, (_, index) => ({
+ value: `loader-${index + 1}`,
+ label: `Loader ${index + 1}`,
+ })),
+ }),
+ template: /*html*/ `
+
+ `,
+ }),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Covers fixed dropdown repositioning while the page scrolls with a searchable input open.',
+ },
+ },
+ },
+}
diff --git a/packages/ui/src/stories/base/DatePicker.stories.ts b/packages/ui/src/stories/base/DatePicker.stories.ts
index 8a8838cb38..991acdb43d 100644
--- a/packages/ui/src/stories/base/DatePicker.stories.ts
+++ b/packages/ui/src/stories/base/DatePicker.stories.ts
@@ -48,6 +48,26 @@ export const WithTime: Story = {
}),
}
+export const Clearable: Story = {
+ render: () => ({
+ components: { DatePicker },
+ setup() {
+ const value = ref('2026-04-27')
+ return { value }
+ },
+ template: /* html */ `
+
+
+
Selected value: {{ value || 'None' }}
+
+ `,
+ }),
+}
+
export const OpenWithTime: Story = {
render: () => ({
components: { DatePicker },
diff --git a/packages/ui/src/stories/base/DropdownFilterBar.stories.ts b/packages/ui/src/stories/base/DropdownFilterBar.stories.ts
index 94971c2eac..460517b7de 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' },
],
},
]
@@ -83,6 +131,41 @@ export const Default: Story = {
}
export const WithAppliedFilters: Story = {
+ render: () => ({
+ components: { DropdownFilterBar },
+ setup() {
+ const selected = ref>({
+ status: ['active'],
+ type: ['mod', 'plugin'],
+ })
+ const clearEvents = ref(0)
+ function handleClear() {
+ clearEvents.value += 1
+ }
+
+ return { categories: defaultCategories, clearEvents, handleClear, selected }
+ },
+ template: /* html */ `
+
+
+ Clear events: {{ clearEvents }}
+
+ `,
+ }),
+ args: {
+ modelValue: {
+ status: ['active'],
+ type: ['mod', 'plugin'],
+ },
+ categories: defaultCategories,
+ },
+}
+
+export const WithRightCheckmarks: Story = {
render: () => ({
components: { DropdownFilterBar },
setup() {
@@ -94,7 +177,11 @@ export const WithAppliedFilters: Story = {
},
template: /* html */ `
-
+
`,
}),
@@ -104,6 +191,46 @@ export const WithAppliedFilters: Story = {
type: ['mod', 'plugin'],
},
categories: defaultCategories,
+ checkboxPosition: 'right',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Renders selected options with the same right-side checkmark placement as MultiSelect.',
+ },
+ },
+ },
+}
+
+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,
},
}
@@ -133,14 +260,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 */ `
-
+
+
+
+
+
+
+
`,
}),
@@ -148,13 +294,175 @@ export const SearchableCategories: Story = {
modelValue: {},
categories: searchableCategories,
},
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'On mobile and narrow viewports, tapping a category replaces the add menu with the category submenu; both surfaces should stay anchored while scrolling or when the visual viewport changes.',
+ },
+ },
+ },
}
-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 +479,7 @@ export const CustomControls: Story = {
],
},
]
- return { categories, releaseOnly, selected }
+ return { categories, minimumDownloads, releaseOnly, selected }
},
template: /* html */ `
@@ -193,12 +501,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..2fea34a786 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,29 @@ 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 DeselectFocusState: Story = {
+ args: {
+ ...Default.args,
+ modelValue: ['en'],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Mouse focus after deselecting an option should not keep the selected brightness state applied.',
+ },
+ },
+ },
}
export const WithSearch: Story = {
@@ -51,6 +74,56 @@ export const WithSearch: Story = {
searchable: true,
searchPlaceholder: 'Search versions',
},
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Searchable dropdowns avoid auto-focusing search on mobile so opening the menu does not summon the soft keyboard.',
+ },
+ },
+ },
+}
+
+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 = {
@@ -62,12 +135,72 @@ export const WithSelectAll: Story = {
},
}
+export const SingleOptionWithSelectAll: Story = {
+ args: {
+ options: [{ value: 'sodium', label: 'Sodium' }],
+ modelValue: [],
+ placeholder: 'Select projects',
+ searchable: true,
+ includeSelectAllOption: true,
+ searchPlaceholder: 'Search projects',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Select all is hidden when there is only one enabled option.',
+ },
+ },
+ },
+}
+
+export const WithRightCheckbox: Story = {
+ args: {
+ ...Default.args,
+ searchable: true,
+ includeSelectAllOption: true,
+ checkboxPosition: 'right',
+ searchPlaceholder: 'Search languages',
+ },
+}
+
export const WithSelectionActions: Story = {
args: {
...Default.args,
+ modelValue: [],
searchable: true,
showSelectionActions: true,
searchPlaceholder: 'Search versions',
+ maxHeight: 180,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Selection actions stay above the scrollable options and compensate the scroll position when they appear.',
+ },
+ },
+ },
+}
+
+export const WithSections: Story = {
+ args: {
+ options: [
+ { value: 'iris', label: 'Iris' },
+ { value: 'sodium', label: 'Sodium' },
+ { type: 'section-header', label: 'Single project group' },
+ { value: 'lithium', label: 'Lithium', searchTerms: ['Single project group'] },
+ { 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',
},
}
@@ -249,6 +382,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,
@@ -265,3 +442,38 @@ export const Empty: Story = {
modelValue: [],
},
}
+
+export const ScrollRepositioning: Story = {
+ args: {
+ options: Array.from({ length: 16 }, (_, index) => ({
+ value: `version-${index + 1}`,
+ label: `Version ${index + 1}`,
+ })),
+ modelValue: [],
+ placeholder: 'Select versions',
+ searchable: true,
+ searchPlaceholder: 'Search versions',
+ },
+ render: (args) => ({
+ components: { MultiSelect },
+ setup() {
+ const selected = ref(args.modelValue)
+ return { args, selected }
+ },
+ template: /*html*/ `
+
+ `,
+ }),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Covers fixed dropdown repositioning while the page scrolls with the menu open.',
+ },
+ },
+ },
+}
diff --git a/packages/ui/src/stories/base/Table.stories.ts b/packages/ui/src/stories/base/Table.stories.ts
index 4d578c077d..73025f3f49 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',
@@ -57,7 +71,7 @@ export const Default: StoryObj = {
}),
}
-export const WithSelection: StoryObj = {
+export const HorizontalOverflow: StoryObj = {
args: {},
render: () => ({
components: { Table },
@@ -69,6 +83,35 @@ export const WithSelection: StoryObj = {
{ key: 'role', label: 'Role' },
]
const data = sampleUsers
+ return { columns, data }
+ },
+ template: /* html */ `
+
+
+
+
+
Members
+
{{ data.length }} rows
+
+
+
+
+ `,
+ }),
+}
+
+export const WithSelection: 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
const selectedIds = ref([])
return { columns, data, selectedIds }
},
@@ -81,6 +124,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