-
+
-
-
- {{ tag.label }}
-
-
-
+
+
- +{{ overflowCount }}
+ {{ tag.label }}
+
-
-
-
- {{ tag.label }}
-
-
-
-
-
-
- {{ placeholder }}
-
-
-
+
+
+ {{ placeholder }}
+
+
+
+
@@ -153,12 +166,38 @@
+
+
+
+
+
+
+ {{ selectionActionsLabel }}
+
+
@@ -168,7 +207,7 @@
:aria-selected="isSelected(item.value)"
:aria-disabled="item.disabled || undefined"
:data-focused="focusedIndex === index"
- class="flex items-center gap-2.5 cursor-pointer p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5 rounded-xl"
+ class="flex items-center gap-2.5 cursor-pointer p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 rounded-xl"
:class="[
item.class,
{
@@ -214,6 +253,10 @@
{{ noResultsMessage }}
+
+
+
+
@@ -233,6 +276,7 @@ import {
onUnmounted,
ref,
shallowRef,
+ useSlots,
watch,
} from 'vue'
@@ -263,12 +307,19 @@ const props = withDefaults(
clearable?: boolean
maxHeight?: number
triggerClass?: string
+ fitContent?: boolean
+ /** Width for the teleported dropdown; defaults to the trigger width */
+ dropdownWidth?: string | number
+ /** Minimum width for the teleported dropdown */
+ dropdownMinWidth?: string | number
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
noResultsMessage?: string
disableSearchFilter?: boolean
includeSelectAllOption?: boolean
selectAllLabel?: string
+ showSelectionActions?: boolean
+ selectionActionsClearLabel?: string
maxTagRows?: number
}>(),
{
@@ -279,10 +330,13 @@ const props = withDefaults(
showChevron: true,
clearable: true,
maxHeight: DEFAULT_MAX_HEIGHT,
+ fitContent: false,
noOptionsMessage: 'No options available',
noResultsMessage: 'No results found',
includeSelectAllOption: false,
selectAllLabel: 'Select all',
+ showSelectionActions: false,
+ selectionActionsClearLabel: 'Deselect all',
maxTagRows: 1,
},
)
@@ -294,6 +348,7 @@ const emit = defineEmits<{
searchInput: [query: string]
}>()
+const slots = useSlots()
const isOpen = ref(false)
const searchQuery = ref('')
const focusedIndex = ref(-1)
@@ -310,9 +365,11 @@ const dropdownStyle = ref({
top: '0px',
left: '0px',
width: '0px',
+ minWidth: '0px',
})
const openDirection = ref<'down' | 'up'>('down')
+const hasCustomInputContent = computed(() => Boolean(slots['input-content']))
const selectedOptions = computed(() => {
return props.options.filter((opt) => props.modelValue.includes(opt.value))
@@ -361,6 +418,12 @@ const filteredOptions = computed(() => {
const isNoOptionsState = computed(() => props.options.length === 0 && !searchQuery.value)
const shouldShowSelectAll = computed(() => props.includeSelectAllOption && props.options.length > 0)
+const shouldShowSelectionActions = computed(
+ () => props.showSelectionActions && props.modelValue.length > 0,
+)
+const selectionActionsLabel = computed(() => {
+ return props.modelValue.length === 1 ? '1 selected' : `${props.modelValue.length} selected`
+})
function isSelected(value: T) {
return props.modelValue.includes(value)
@@ -387,6 +450,14 @@ function clearAll() {
emit('update:modelValue', [])
}
+function toggleDropdown() {
+ if (isOpen.value) {
+ closeDropdown()
+ } else {
+ openDropdown()
+ }
+}
+
function toggleSelectAll() {
if (isAllSelected.value) {
emit('update:modelValue', [])
@@ -460,12 +531,35 @@ function calculateHorizontalPosition(
return left
}
+function resolveDropdownWidth(triggerWidth: number): string {
+ if (props.dropdownWidth === undefined) return `${triggerWidth}px`
+ if (typeof props.dropdownWidth === 'number') return `${props.dropdownWidth}px`
+ return props.dropdownWidth
+}
+
+function resolveCssSize(size: string | number | undefined): string | undefined {
+ if (size === undefined) return undefined
+ if (typeof size === 'number') return `${size}px`
+ return size
+}
+
async function updateDropdownPosition() {
if (!triggerRef.value || !dropdownRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
+ const width = resolveDropdownWidth(triggerRect.width)
+ const minWidth = resolveCssSize(props.dropdownMinWidth) ?? '0px'
+
+ dropdownStyle.value = {
+ ...dropdownStyle.value,
+ width,
+ minWidth,
+ }
+
+ await nextTick()
+
const dropdownRect = dropdownRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
@@ -477,7 +571,8 @@ async function updateDropdownPosition() {
dropdownStyle.value = {
top: `${top}px`,
left: `${left}px`,
- width: `${triggerRect.width}px`,
+ width,
+ minWidth,
}
openDirection.value = direction
@@ -699,6 +794,9 @@ watch(
() => props.modelValue,
() => {
calculateVisibleTags()
+ if (isOpen.value) {
+ updateDropdownPosition()
+ }
},
{ deep: true },
)
diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts
index 78964199b..84eefe229 100644
--- a/packages/ui/src/components/base/index.ts
+++ b/packages/ui/src/components/base/index.ts
@@ -23,6 +23,8 @@ export { default as ContentPageHeader } from './ContentPageHeader.vue'
export { default as CopyCode } from './CopyCode.vue'
export { default as DoubleIcon } from './DoubleIcon.vue'
export { default as DropArea } from './DropArea.vue'
+export type { DropdownFilterBarCategory, DropdownFilterBarOption } from './DropdownFilterBar.vue'
+export { default as DropdownFilterBar } from './DropdownFilterBar.vue'
export { default as DropdownSelect } from './DropdownSelect.vue'
export { default as DropzoneFileInput } from './DropzoneFileInput.vue'
export { default as EmptyState } from './EmptyState.vue'
diff --git a/packages/ui/src/stories/base/DropdownFilterBar.stories.ts b/packages/ui/src/stories/base/DropdownFilterBar.stories.ts
new file mode 100644
index 000000000..94971c2ea
--- /dev/null
+++ b/packages/ui/src/stories/base/DropdownFilterBar.stories.ts
@@ -0,0 +1,240 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+import { ref } from 'vue'
+
+import DropdownFilterBar from '../../components/base/DropdownFilterBar.vue'
+
+const meta = {
+ title: 'Base/DropdownFilterBar',
+ component: DropdownFilterBar,
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const defaultCategories = [
+ {
+ key: 'status',
+ label: 'Status',
+ options: [
+ { value: 'active', label: 'Active' },
+ { value: 'archived', label: 'Archived' },
+ { value: 'draft', label: 'Draft' },
+ ],
+ },
+ {
+ key: 'type',
+ label: 'Type',
+ options: [
+ { value: 'mod', label: 'Mod' },
+ { value: 'plugin', label: 'Plugin' },
+ { value: 'resourcepack', label: 'Resource Pack' },
+ { value: 'modpack', label: 'Modpack' },
+ ],
+ },
+]
+
+const searchableCategories = [
+ {
+ key: 'country',
+ label: 'Country',
+ searchable: true,
+ searchPlaceholder: 'Search countries...',
+ options: [
+ { value: 'US', label: 'United States', searchTerms: ['USA', 'America'] },
+ { value: 'CA', label: 'Canada' },
+ { value: 'DE', label: 'Germany', searchTerms: ['Deutschland'] },
+ { value: 'JP', label: 'Japan' },
+ { value: 'BR', label: 'Brazil' },
+ { value: 'AU', label: 'Australia' },
+ ],
+ },
+ {
+ key: 'version',
+ label: 'Version',
+ searchable: true,
+ 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' },
+ ],
+ },
+]
+
+export const Default: Story = {
+ render: () => ({
+ components: { DropdownFilterBar },
+ setup() {
+ const selected = ref>({})
+ return { categories: defaultCategories, selected }
+ },
+ template: /* html */ `
+
+
+
+ `,
+ }),
+ args: {
+ modelValue: {},
+ categories: defaultCategories,
+ },
+}
+
+export const WithAppliedFilters: Story = {
+ render: () => ({
+ components: { DropdownFilterBar },
+ setup() {
+ const selected = ref>({
+ status: ['active'],
+ type: ['mod', 'plugin'],
+ })
+ return { categories: defaultCategories, selected }
+ },
+ template: /* html */ `
+
+
+
+ `,
+ }),
+ args: {
+ modelValue: {
+ status: ['active'],
+ type: ['mod', 'plugin'],
+ },
+ categories: defaultCategories,
+ },
+}
+
+export const WithFilterIcon: Story = {
+ render: () => ({
+ components: { DropdownFilterBar },
+ setup() {
+ const selected = ref>({
+ status: ['draft'],
+ })
+ return { categories: defaultCategories, selected }
+ },
+ template: /* html */ `
+
+
+
+ `,
+ }),
+ args: {
+ modelValue: {
+ status: ['draft'],
+ },
+ categories: defaultCategories,
+ useFilterIcon: true,
+ },
+}
+
+export const SearchableCategories: Story = {
+ render: () => ({
+ components: { DropdownFilterBar },
+ setup() {
+ const selected = ref>({})
+ return { categories: searchableCategories, selected }
+ },
+ template: /* html */ `
+
+
+
+ `,
+ }),
+ args: {
+ modelValue: {},
+ categories: searchableCategories,
+ },
+}
+
+export const CustomControls: Story = {
+ render: () => ({
+ components: { DropdownFilterBar },
+ setup() {
+ const selected = ref>({})
+ const releaseOnly = ref(true)
+ const categories = [
+ {
+ key: 'version',
+ label: 'Version',
+ searchable: true,
+ 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: '25w15a', label: '25w15a' },
+ ],
+ },
+ ]
+ return { categories, releaseOnly, selected }
+ },
+ template: /* html */ `
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+ args: {
+ modelValue: {},
+ categories: searchableCategories,
+ },
+}
+
+export const EmptyCategory: Story = {
+ render: () => ({
+ components: { DropdownFilterBar },
+ setup() {
+ const selected = ref>({})
+ const categories = [
+ {
+ key: 'empty',
+ label: 'Empty',
+ searchable: true,
+ searchPlaceholder: 'Search empty options...',
+ options: [],
+ },
+ ]
+ return { categories, selected }
+ },
+ template: /* html */ `
+
+
+
+ `,
+ }),
+ args: {
+ modelValue: {},
+ categories: [
+ {
+ key: 'empty',
+ label: 'Empty',
+ searchable: true,
+ searchPlaceholder: 'Search empty options...',
+ options: [],
+ },
+ ],
+ },
+}
diff --git a/packages/ui/src/stories/base/MultiSelect.stories.ts b/packages/ui/src/stories/base/MultiSelect.stories.ts
index 6d7b59f4b..5fc6d00bd 100644
--- a/packages/ui/src/stories/base/MultiSelect.stories.ts
+++ b/packages/ui/src/stories/base/MultiSelect.stories.ts
@@ -1,5 +1,6 @@
+import { CheckIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
import MultiSelect from '../../components/base/MultiSelect.vue'
@@ -61,6 +62,89 @@ export const WithSelectAll: Story = {
},
}
+export const WithSelectionActions: Story = {
+ args: {
+ ...Default.args,
+ searchable: true,
+ showSelectionActions: true,
+ searchPlaceholder: 'Search versions',
+ },
+}
+
+export const WithTopSlot: Story = {
+ args: {
+ ...Default.args,
+ modelValue: [],
+ searchable: true,
+ showSelectionActions: true,
+ searchPlaceholder: 'Search languages',
+ placeholder: 'All languages',
+ },
+ render: (args) => ({
+ components: { CheckIcon, MultiSelect },
+ setup() {
+ const selected = ref(args.modelValue)
+ const isAllLanguagesSelected = computed(() => selected.value.length === 0)
+ const selectAllLanguages = () => {
+ selected.value = []
+ }
+ return { args, isAllLanguagesSelected, selectAllLanguages, selected }
+ },
+ template: /*html*/ `
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+}
+
+export const DropdownMinWidth: Story = {
+ args: {
+ ...Default.args,
+ modelValue: ['en'],
+ dropdownMinWidth: 320,
+ placeholder: 'Languages',
+ },
+ render: (args) => ({
+ components: { MultiSelect },
+ setup() {
+ const selected = ref(args.modelValue)
+ return { args, selected }
+ },
+ template: /*html*/ `
+
+
+
+ `,
+ }),
+}
+
export const ManySelected: Story = {
args: {
...Default.args,
@@ -77,6 +161,94 @@ export const TwoTagRows: Story = {
},
}
+export const CustomInputContent: Story = {
+ args: {
+ ...Default.args,
+ modelValue: ['en', 'es'],
+ fitContent: true,
+ showChevron: false,
+ clearable: false,
+ triggerClass:
+ 'h-10 max-w-[16rem] border border-solid border-surface-5 bg-surface-4 px-3 py-1.5 hover:bg-surface-5 hover:brightness-100 active:brightness-100',
+ },
+ render: (args) => ({
+ components: { MultiSelect },
+ setup() {
+ const selected = ref(args.modelValue)
+ const selectedLabel = () => {
+ if (selected.value.length === 0) return 'All languages'
+ if (selected.value.length === 1) {
+ const option = args.options.find((item) => item.value === selected.value[0])
+ return option?.label ?? '1 selected'
+ }
+ return `${selected.value.length} selected`
+ }
+ return { args, selected, selectedLabel }
+ },
+ template: /*html*/ `
+
+
+
+
+
+ Languages:
+ {{ selectedLabel() }}
+
+
+ ⌄
+
+
+
+
+
+
+ `,
+ }),
+}
+
+export const WithBottomSlot: Story = {
+ args: {
+ ...Default.args,
+ modelValue: ['en', 'es'],
+ searchable: true,
+ includeSelectAllOption: true,
+ },
+ render: (args) => ({
+ components: { MultiSelect },
+ setup() {
+ const selected = ref(args.modelValue)
+ const minimum = ref('')
+ return { args, selected, minimum }
+ },
+ template: /*html*/ `
+
+
+
+
+ Projects above
+
+ downloads
+
+
+
+
+ `,
+ }),
+}
+
export const NoOptions: Story = {
args: {
...Default.args,