From d8754094f9cd1a95723a052467c4840c329e1798 Mon Sep 17 00:00:00 2001 From: tdgao Date: Thu, 5 Mar 2026 21:12:11 -0800 Subject: [PATCH 1/9] start multiselect component --- packages/assets/styles/defaults.scss | 4 +- packages/assets/styles/variables.scss | 2 +- .../ui/src/components/base/MultiSelect.vue | 691 ++++++++++++++++++ packages/ui/src/components/base/index.ts | 2 + .../src/stories/base/MultiSelect.stories.ts | 85 +++ 5 files changed, 781 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/components/base/MultiSelect.vue create mode 100644 packages/ui/src/stories/base/MultiSelect.stories.ts diff --git a/packages/assets/styles/defaults.scss b/packages/assets/styles/defaults.scss index d77191e349..6b6a3d59ce 100644 --- a/packages/assets/styles/defaults.scss +++ b/packages/assets/styles/defaults.scss @@ -158,11 +158,11 @@ h3 { } ::-webkit-scrollbar-thumb { - background: var(--color-button-bg); + background: var(--color-scrollbar); } // Firefox scrollbar * { scrollbar-width: thin; - scrollbar-color: var(--color-button-bg) transparent; + scrollbar-color: var(--color-scrollbar) transparent; } diff --git a/packages/assets/styles/variables.scss b/packages/assets/styles/variables.scss index 76be6f6da8..7106722110 100644 --- a/packages/assets/styles/variables.scss +++ b/packages/assets/styles/variables.scss @@ -320,7 +320,7 @@ html { --color-button-bg: var(--surface-4); --color-button-border: rgba(193, 190, 209, 0.12); - --color-scrollbar: var(--color-button-bg); + --color-scrollbar: var(--surface-5); --color-divider: var(--color-button-bg); --color-divider-dark: #646c75; diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue new file mode 100644 index 0000000000..2128c702c6 --- /dev/null +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -0,0 +1,691 @@ + + + + + diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts index 48aeb49577..ec4805053d 100644 --- a/packages/ui/src/components/base/index.ts +++ b/packages/ui/src/components/base/index.ts @@ -39,6 +39,8 @@ export { default as JoinedButtons } from './JoinedButtons.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 { default as MultiSelect } from './MultiSelect.vue' export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue' export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue' export { default as OptionGroup } from './OptionGroup.vue' diff --git a/packages/ui/src/stories/base/MultiSelect.stories.ts b/packages/ui/src/stories/base/MultiSelect.stories.ts new file mode 100644 index 0000000000..5d8951024f --- /dev/null +++ b/packages/ui/src/stories/base/MultiSelect.stories.ts @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import MultiSelect from '../../components/base/MultiSelect.vue' + +const meta = { + title: 'Base/MultiSelect', + // @ts-ignore - error comes from generically typed component + component: MultiSelect, + render: (args) => ({ + components: { MultiSelect }, + setup() { + const selected = ref(args.modelValue) + return { args, selected } + }, + template: /*html*/ ` +
+ +
+ `, + }), +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + options: [ + { value: 'en', label: 'English' }, + { value: 'es', label: 'Spanish' }, + { value: 'fr', label: 'French' }, + { value: 'de', label: 'German' }, + { value: 'zh-CN', label: 'Chinese (Simplified)' }, + { value: 'ko', label: 'Korean' }, + { value: 'ja', label: 'Japanese' }, + { value: 'pt', label: 'Portuguese' }, + { value: 'ru', label: 'Russian' }, + { value: 'it', label: 'Italian' }, + { value: 'ar', label: 'Arabic' }, + ], + modelValue: ['en', 'es', 'fr', 'zh-CN'], + placeholder: 'Select languages', + }, +} + +export const WithSearch: Story = { + args: { + ...Default.args, + searchable: true, + searchPlaceholder: 'Search versions', + }, +} + +export const WithSelectAll: Story = { + args: { + ...Default.args, + searchable: true, + includeSelectAllOption: true, + searchPlaceholder: 'Search versions', + }, +} + +export const ManySelected: Story = { + args: { + ...Default.args, + modelValue: ['en', 'es', 'fr', 'zh-CN', 'ko', 'ja', 'pt', 'ru', 'it', 'ar', 'de'], + searchable: true, + includeSelectAllOption: true, + }, +} + +export const TwoTagRows: Story = { + args: { + ...ManySelected.args, + maxTagRows: 2, + }, +} + +export const Empty: Story = { + args: { + ...Default.args, + modelValue: [], + }, +} From 301e2671ba26a5418ebeba3ba50c0e73d05bd96f Mon Sep 17 00:00:00 2001 From: tdgao Date: Thu, 5 Mar 2026 21:35:08 -0800 Subject: [PATCH 2/9] update styles --- packages/ui/src/components/base/MultiSelect.vue | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue index 2128c702c6..f941dc974d 100644 --- a/packages/ui/src/components/base/MultiSelect.vue +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -103,15 +103,15 @@ @mousedown.prevent.stop @keydown="handleDropdownKeydown" > -
+
@@ -120,12 +120,12 @@
Date: Mon, 9 Mar 2026 15:43:12 -0700 Subject: [PATCH 4/9] fix padding and styles --- .../ui/src/components/base/MultiSelect.vue | 138 +++++++++--------- 1 file changed, 72 insertions(+), 66 deletions(-) diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue index 7712a53781..90e002d4c9 100644 --- a/packages/ui/src/components/base/MultiSelect.vue +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -60,7 +60,7 @@
- + {{ placeholder }}
@@ -103,14 +103,14 @@ @mousedown.prevent.stop @keydown="handleDropdownKeydown" > -
+
- +
- - - {{ selectAllLabel }} - - - - +
+ +
+ +
From 1b8b6227210cad67c8fe6ad5b8438cf93eb863b0 Mon Sep 17 00:00:00 2001 From: tdgao Date: Mon, 9 Mar 2026 15:49:35 -0700 Subject: [PATCH 5/9] add border bottom on sticky items --- .../ui/src/components/base/MultiSelect.vue | 163 +++++++++--------- 1 file changed, 82 insertions(+), 81 deletions(-) diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue index 90e002d4c9..9950a14f8e 100644 --- a/packages/ui/src/components/base/MultiSelect.vue +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -103,104 +103,105 @@ @mousedown.prevent.stop @keydown="handleDropdownKeydown" > -
- -
- -
-
- - - - - {{ selectAllLabel }} - - +
+
+
- +
+
+ +
{{ noOptionsMessage }}
From dd40847a013fc217bf9b63cd8c7dd58505406aa0 Mon Sep 17 00:00:00 2001 From: tdgao Date: Mon, 9 Mar 2026 15:58:20 -0700 Subject: [PATCH 6/9] add border bottom to search as well --- packages/ui/src/components/base/MultiSelect.vue | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue index 9950a14f8e..db6f232720 100644 --- a/packages/ui/src/components/base/MultiSelect.vue +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -100,11 +100,14 @@ :style="dropdownStyle" role="listbox" aria-multiselectable="true" - @mousedown.prevent.stop + @mousedown.stop @keydown="handleDropdownKeydown" > -
-
+
+
@@ -120,9 +122,9 @@
-
+
Date: Mon, 9 Mar 2026 16:00:59 -0700 Subject: [PATCH 7/9] fix select all showing line --- packages/ui/src/components/base/MultiSelect.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue index db6f232720..74e82de52e 100644 --- a/packages/ui/src/components/base/MultiSelect.vue +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -122,7 +122,7 @@
Date: Mon, 9 Mar 2026 16:09:11 -0700 Subject: [PATCH 8/9] use multi-select component for languages field --- .../src/pages/[type]/[id]/settings/server.vue | 14 ++++++-------- packages/ui/src/components/base/MultiSelect.vue | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/pages/[type]/[id]/settings/server.vue b/apps/frontend/src/pages/[type]/[id]/settings/server.vue index af88816222..cac014ed6f 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/server.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/server.vue @@ -26,15 +26,13 @@ >Languages (optional) - @@ -166,10 +164,10 @@ import { injectModrinthClient, injectNotificationManager, injectProjectPageContext, + MultiSelect, StyledInput, UnsavedChangesPopup, } from '@modrinth/ui' -import { Multiselect } from 'vue-multiselect' import CompatibilityCard from '~/components/ui/project-settings/CompatibilityCard.vue' diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue index 74e82de52e..81a1e0ee62 100644 --- a/packages/ui/src/components/base/MultiSelect.vue +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -106,7 +106,7 @@
Date: Mon, 9 Mar 2026 16:24:05 -0700 Subject: [PATCH 9/9] add no options story for empty state --- .../ui/src/components/base/MultiSelect.vue | 25 +++++++++++++------ .../src/stories/base/MultiSelect.stories.ts | 10 ++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue index 81a1e0ee62..f8b76e6ac9 100644 --- a/packages/ui/src/components/base/MultiSelect.vue +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -121,10 +121,10 @@
-
+
-
+
{{ noOptionsMessage }}
+
+ {{ noResultsMessage }} +
@@ -259,6 +265,7 @@ const props = withDefaults( triggerClass?: string forceDirection?: 'up' | 'down' noOptionsMessage?: string + noResultsMessage?: string disableSearchFilter?: boolean includeSelectAllOption?: boolean selectAllLabel?: string @@ -272,7 +279,8 @@ const props = withDefaults( showChevron: true, clearable: true, maxHeight: DEFAULT_MAX_HEIGHT, - noOptionsMessage: 'No results found', + noOptionsMessage: 'No options available', + noResultsMessage: 'No results found', includeSelectAllOption: false, selectAllLabel: 'Select all', maxTagRows: 1, @@ -351,6 +359,9 @@ const filteredOptions = computed(() => { }) }) +const isNoOptionsState = computed(() => props.options.length === 0 && !searchQuery.value) +const shouldShowSelectAll = computed(() => props.includeSelectAllOption && props.options.length > 0) + function isSelected(value: T) { return props.modelValue.includes(value) } @@ -485,7 +496,7 @@ async function openDropdown() { ;(searchInputRef.value as unknown as { focus: () => void }).focus() } - focusedIndex.value = props.includeSelectAllOption ? -2 : 0 + focusedIndex.value = shouldShowSelectAll.value ? -2 : filteredOptions.value.length > 0 ? 0 : -1 startPositionTracking() } @@ -546,7 +557,7 @@ function focusPreviousOption() { const length = filteredOptions.value.length if (length === 0) return - if (focusedIndex.value <= 0 && props.includeSelectAllOption) { + if (focusedIndex.value <= 0 && shouldShowSelectAll.value) { focusedIndex.value = -2 return } @@ -630,7 +641,7 @@ function handleSearchInput() { if (!isOpen.value) { openDropdown() } - focusedIndex.value = props.includeSelectAllOption ? -2 : 0 + focusedIndex.value = shouldShowSelectAll.value ? -2 : filteredOptions.value.length > 0 ? 0 : -1 } function handleWindowResize() { diff --git a/packages/ui/src/stories/base/MultiSelect.stories.ts b/packages/ui/src/stories/base/MultiSelect.stories.ts index 5d8951024f..6d7b59f4b0 100644 --- a/packages/ui/src/stories/base/MultiSelect.stories.ts +++ b/packages/ui/src/stories/base/MultiSelect.stories.ts @@ -77,6 +77,16 @@ export const TwoTagRows: Story = { }, } +export const NoOptions: Story = { + args: { + ...Default.args, + options: [], + modelValue: [], + searchable: true, + noOptionsMessage: 'No options available', + }, +} + export const Empty: Story = { args: { ...Default.args,