Merge branch 'main' into sno-fix-playwright-pnpm

This commit is contained in:
snomiao
2025-08-31 20:03:02 +09:00
committed by GitHub
11 changed files with 435 additions and 187 deletions

View File

@@ -4,17 +4,26 @@
transition: background-color 0.3s ease, color 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease;
} }
/* Light theme default */ /* Light theme default - with explicit color to override media queries */
body { body:not(.dark-theme) {
background-color: #ffffff; background-color: #fff !important;
color: #1a1a1a; color: #000 !important;
}
/* Override browser dark mode preference for light theme */
@media (prefers-color-scheme: dark) {
body:not(.dark-theme) {
color: #000 !important;
--fg-color: #000 !important;
--bg-color: #fff !important;
}
} }
/* Dark theme styles */ /* Dark theme styles */
body.dark-theme, body.dark-theme,
.dark-theme body { .dark-theme body {
background-color: #0a0a0a; background-color: #202020;
color: #e5e5e5; color: #fff;
} }
/* Ensure Storybook canvas follows theme */ /* Ensure Storybook canvas follows theme */
@@ -24,11 +33,33 @@
.dark-theme .sb-show-main, .dark-theme .sb-show-main,
.dark-theme .docs-story { .dark-theme .docs-story {
background-color: #0a0a0a !important; background-color: #202020 !important;
} }
/* Fix for Storybook controls panel in dark mode */ /* CSS Variables for theme consistency */
.dark-theme .docblock-argstable-body { body:not(.dark-theme) {
color: #e5e5e5; --fg-color: #000;
--bg-color: #fff;
--content-bg: #e0e0e0;
--content-fg: #000;
--content-hover-bg: #adadad;
--content-hover-fg: #000;
}
body.dark-theme {
--fg-color: #fff;
--bg-color: #202020;
--content-bg: #4e4e4e;
--content-fg: #fff;
--content-hover-bg: #222;
--content-hover-fg: #fff;
}
/* Override Storybook's problematic & selector styles */
/* Reset only the specific properties that Storybook injects */
#storybook-root li+li,
#storybook-docs li+li {
margin: inherit;
padding: inherit;
} }
</style> </style>

View File

@@ -24,22 +24,22 @@ export const Basic: Story = {
<MoreButton> <MoreButton>
<template #default="{ close }"> <template #default="{ close }">
<IconTextButton <IconTextButton
type="secondary" type="transparent"
label="Settings" label="Settings"
@click="() => { close() }" @click="() => { close() }"
> >
<template #icon> <template #icon>
<Download /> <Download :size="16" />
</template> </template>
</IconTextButton> </IconTextButton>
<IconTextButton <IconTextButton
type="primary" type="transparent"
label="Profile" label="Profile"
@click="() => { close() }" @click="() => { close() }"
> >
<template #icon> <template #icon>
<ScrollText /> <ScrollText :size="16" />
</template> </template>
</IconTextButton> </IconTextButton>
</template> </template>

View File

@@ -1,9 +1,23 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { MultiSelectProps } from 'primevue/multiselect'
import { ref } from 'vue' import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue' import MultiSelect from './MultiSelect.vue'
const meta: Meta<typeof MultiSelect> = { // Combine our component props with PrimeVue MultiSelect props
// Since we use v-bind="$attrs", all PrimeVue props are available
interface ExtendedProps extends Partial<MultiSelectProps> {
// Our custom props
label?: string
showSearchBox?: boolean
showSelectedCount?: boolean
showClearButton?: boolean
searchPlaceholder?: string
// Override modelValue type to match our Option type
modelValue?: Array<{ name: string; value: string }>
}
const meta: Meta<ExtendedProps> = {
title: 'Components/Input/MultiSelect', title: 'Components/Input/MultiSelect',
component: MultiSelect, component: MultiSelect,
tags: ['autodocs'], tags: ['autodocs'],
@@ -13,7 +27,35 @@ const meta: Meta<typeof MultiSelect> = {
}, },
options: { options: {
control: 'object' control: 'object'
},
showSearchBox: {
control: 'boolean',
description: 'Toggle searchBar visibility'
},
showSelectedCount: {
control: 'boolean',
description: 'Toggle selected count visibility'
},
showClearButton: {
control: 'boolean',
description: 'Toggle clear button visibility'
},
searchPlaceholder: {
control: 'text'
} }
},
args: {
label: 'Select',
options: [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
],
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
} }
} }
@@ -25,7 +67,7 @@ export const Default: Story = {
components: { MultiSelect }, components: { MultiSelect },
setup() { setup() {
const selected = ref([]) const selected = ref([])
const options = [ const options = args.options || [
{ name: 'Vue', value: 'vue' }, { name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' }, { name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' }, { name: 'Angular', value: 'angular' },
@@ -38,8 +80,11 @@ export const Default: Story = {
<MultiSelect <MultiSelect
v-model="selected" v-model="selected"
:options="options" :options="options"
label="Select Frameworks" :label="args.label"
v-bind="args" :showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/> />
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded"> <div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p> <p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
@@ -50,10 +95,10 @@ export const Default: Story = {
} }
export const WithPreselectedValues: Story = { export const WithPreselectedValues: Story = {
render: () => ({ render: (args) => ({
components: { MultiSelect }, components: { MultiSelect },
setup() { setup() {
const options = [ const options = args.options || [
{ name: 'JavaScript', value: 'js' }, { name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' }, { name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' }, { name: 'Python', value: 'python' },
@@ -61,25 +106,43 @@ export const WithPreselectedValues: Story = {
{ name: 'Rust', value: 'rust' } { name: 'Rust', value: 'rust' }
] ]
const selected = ref([options[0], options[1]]) const selected = ref([options[0], options[1]])
return { selected, options } return { selected, options, args }
}, },
template: ` template: `
<div> <div>
<MultiSelect <MultiSelect
v-model="selected" v-model="selected"
:options="options" :options="options"
label="Select Languages" :label="args.label"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/> />
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded"> <div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p> <p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
</div> </div>
</div> </div>
` `
}) }),
args: {
label: 'Select Languages',
options: [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
{ name: 'Go', value: 'go' },
{ name: 'Rust', value: 'rust' }
],
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
} }
export const MultipleSelectors: Story = { export const MultipleSelectors: Story = {
render: () => ({ render: (args) => ({
components: { MultiSelect }, components: { MultiSelect },
setup() { setup() {
const frameworkOptions = ref([ const frameworkOptions = ref([
@@ -114,7 +177,8 @@ export const MultipleSelectors: Story = {
tagOptions, tagOptions,
selectedFrameworks, selectedFrameworks,
selectedProjects, selectedProjects,
selectedTags selectedTags,
args
} }
}, },
template: ` template: `
@@ -124,22 +188,34 @@ export const MultipleSelectors: Story = {
v-model="selectedFrameworks" v-model="selectedFrameworks"
:options="frameworkOptions" :options="frameworkOptions"
label="Select Frameworks" label="Select Frameworks"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/> />
<MultiSelect <MultiSelect
v-model="selectedProjects" v-model="selectedProjects"
:options="projectOptions" :options="projectOptions"
label="Select Projects" label="Select Projects"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/> />
<MultiSelect <MultiSelect
v-model="selectedTags" v-model="selectedTags"
:options="tagOptions" :options="tagOptions"
label="Select Tags" label="Select Tags"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/> />
</div> </div>
<div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded"> <div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<h4 class="font-medium mb-2">Current Selection:</h4> <h4 class="font-medium mt-0">Current Selection:</h4>
<div class="space-y-1 text-sm"> <div class="flex flex-col text-sm">
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p> <p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p> <p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p> <p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
@@ -147,5 +223,54 @@ export const MultipleSelectors: Story = {
</div> </div>
</div> </div>
` `
}) }),
args: {
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
}
export const WithSearchBox: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true
}
}
export const WithSelectedCount: Story = {
...Default,
args: {
...Default.args,
showSelectedCount: true
}
}
export const WithClearButton: Story = {
...Default,
args: {
...Default.args,
showClearButton: true
}
}
export const AllHeaderFeatures: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true,
showSelectedCount: true,
showClearButton: true
}
}
export const CustomSearchPlaceholder: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true,
searchPlaceholder: 'Filter packages...'
}
} }

View File

@@ -1,93 +1,104 @@
<template> <template>
<div class="relative inline-block"> <!--
<MultiSelect Note: Unlike SingleSelect, we don't need an explicit options prop because:
v-model="selectedItems" 1. Our value template only shows a static label (not dynamic based on selection)
:options="options" 2. We display a count badge instead of actual selected labels
option-label="name" 3. All PrimeVue props (including options) are passed via v-bind="$attrs"
unstyled
:placeholder="label" option-label="name" is required because our option template directly accesses option.name
:max-selected-labels="0" max-selected-labels="0" is required to show count badge instead of selected item labels
:pt="pt" -->
<MultiSelect
v-model="selectedItems"
v-bind="$attrs"
option-label="name"
unstyled
:max-selected-labels="0"
:pt="pt"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
> >
<template <div class="p-2 flex flex-col pb-0">
v-if="hasSearchBox || showSelectedCount || hasClearButton" <SearchBox
#header v-if="showSearchBox"
> v-model="searchQuery"
<div class="p-2 flex flex-col gap-y-4 pb-0"> :class="showSelectedCount || showClearButton ? 'mb-2' : ''"
<SearchBox :show-order="true"
v-if="hasSearchBox" :place-holder="searchPlaceholder"
v-model="searchQuery" />
:has-border="true" <div
:place-holder="searchPlaceholder" v-if="showSelectedCount || showClearButton"
/> class="mt-2 flex items-center justify-between"
<div class="flex items-center justify-between"> >
<span <span
v-if="showSelectedCount" v-if="showSelectedCount"
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1" class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
>
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<TextButton
v-if="hasClearButton"
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm !text-blue-500 !dark-theme:text-blue-600"
@click.stop="selectedItems = []"
/>
</div>
<div class="h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
</div>
</template>
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
{{ label }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i-lucide:chevron-down class="text-lg text-neutral-400" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div class="flex items-center gap-2">
<div
class="flex h-4 w-4 p-0.5 flex-shrink-0 items-center justify-center rounded border-[3px] transition-all duration-200"
:class="
slotProps.selected
? 'border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
: 'border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700'
"
> >
<i-lucide:check {{
v-if="slotProps.selected" selectedCount > 0
class="text-xs text-bold text-white" ? $t('g.itemsSelected', { selectedCount })
/> : $t('g.itemSelected', { selectedCount })
</div> }}
<span>{{ slotProps.option.name }}</span> </span>
<TextButton
v-if="showClearButton"
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm !text-blue-500 !dark-theme:text-blue-600"
@click.stop="selectedItems = []"
/>
</div> </div>
</template> <div class="mt-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
</MultiSelect> </div>
</template>
<!-- Selected count badge --> <!-- Trigger value (keep text scale identical) -->
<div <template #value>
v-if="selectedCount > 0" <span class="text-sm text-zinc-700 dark-theme:text-gray-200">
class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white" {{ label }}
> </span>
{{ selectedCount }} <span
</div> v-if="selectedCount > 0"
</div> class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white"
>
{{ selectedCount }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i-lucide:chevron-down class="text-lg text-neutral-400" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div class="flex items-center gap-2">
<div
class="flex h-4 w-4 p-0.5 flex-shrink-0 items-center justify-center rounded transition-all duration-200"
:class="
slotProps.selected
? 'border-[3px] border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
: 'border-[1px] border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700'
"
>
<i-lucide:check
v-if="slotProps.selected"
class="text-xs text-bold text-white"
/>
</div>
<Button class="border-none outline-none bg-transparent" unstyled>{{
slotProps.option.name
}}</Button>
</div>
</template>
</MultiSelect>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Button from 'primevue/button'
import MultiSelect, { import MultiSelect, {
MultiSelectPassThroughMethodOptions MultiSelectPassThroughMethodOptions
} from 'primevue/multiselect' } from 'primevue/multiselect'
@@ -99,26 +110,29 @@ import TextButton from '../button/TextButton.vue'
type Option = { name: string; value: string } type Option = { name: string; value: string }
defineOptions({
inheritAttrs: false
})
interface Props { interface Props {
/** Input label shown on the trigger button */ /** Input label shown on the trigger button */
label?: string label?: string
/** Static options for the multiselect (when not using async search) */
options: Option[]
/** Show search box in the panel header */ /** Show search box in the panel header */
hasSearchBox?: boolean showSearchBox?: boolean
/** Show selected count text in the panel header */ /** Show selected count text in the panel header */
showSelectedCount?: boolean showSelectedCount?: boolean
/** Show "Clear all" action in the panel header */ /** Show "Clear all" action in the panel header */
hasClearButton?: boolean showClearButton?: boolean
/** Placeholder for the search input */ /** Placeholder for the search input */
searchPlaceholder?: string searchPlaceholder?: string
// Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility
} }
const { const {
label, label,
options, showSearchBox = false,
hasSearchBox = false,
showSelectedCount = false, showSelectedCount = false,
hasClearButton = false, showClearButton = false,
searchPlaceholder = 'Search...' searchPlaceholder = 'Search...'
} = defineProps<Props>() } = defineProps<Props>()
@@ -131,7 +145,7 @@ const selectedCount = computed(() => selectedItems.value.length)
const pt = computed(() => ({ const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({ root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: [ class: [
'relative inline-flex cursor-pointer select-none w-full', 'relative inline-flex cursor-pointer select-none',
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white', 'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
'transition-all duration-200 ease-in-out', 'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid', 'border-[2.5px] border-solid',
@@ -153,7 +167,7 @@ const pt = computed(() => ({
}, },
header: () => ({ header: () => ({
class: class:
hasSearchBox || showSelectedCount || hasClearButton ? 'block' : 'hidden' showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
}), }),
// Overlay & list visuals unchanged // Overlay & list visuals unchanged
overlay: overlay:
@@ -161,9 +175,17 @@ const pt = computed(() => ({
list: { list: {
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs' class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
}, },
// Option row hover tone identical // Option row hover and focus tone
option: option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
'flex gap-1 items-center p-2 hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50', class: [
'flex gap-1 items-center p-2',
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Add focus/highlight state for keyboard navigation
{
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
}
]
}),
// Hide built-in checkboxes entirely via PT (no :deep) // Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: { pcHeaderCheckbox: {
root: { class: 'hidden' }, root: { class: 'hidden' },

View File

@@ -10,7 +10,15 @@ const meta: Meta<typeof SearchBox> = {
argTypes: { argTypes: {
placeHolder: { placeHolder: {
control: 'text' control: 'text'
},
showBorder: {
control: 'boolean',
description: 'Toggle border prop'
} }
},
args: {
placeHolder: 'Search...',
showBorder: false
} }
} }
@@ -25,9 +33,23 @@ export const Default: Story = {
return { searchText, args } return { searchText, args }
}, },
template: ` template: `
<div> <div style="max-width: 320px;">
<SearchBox v-model:="searchQuery" /> <SearchBox v-bind="args" v-model="searchText" />
</div> </div>
` `
}) })
} }
export const WithBorder: Story = {
...Default,
args: {
showBorder: true
}
}
export const NoBorder: Story = {
...Default,
args: {
showBorder: false
}
}

View File

@@ -15,19 +15,20 @@
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import { computed } from 'vue' import { computed } from 'vue'
const { placeHolder, hasBorder = false } = defineProps<{ const { placeHolder, showBorder = false } = defineProps<{
placeHolder?: string placeHolder?: string
hasBorder?: boolean showBorder?: boolean
}>() }>()
const searchQuery = defineModel<string>('') // defineModel without arguments uses 'modelValue' as the prop name
const searchQuery = defineModel<string>()
const wrapperStyle = computed(() => { const wrapperStyle = computed(() => {
return hasBorder return showBorder
? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700' ? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700'
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800' : 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
}) })
const iconColorStyle = computed(() => { const iconColorStyle = computed(() => {
return !hasBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700' return !showBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
}) })
</script> </script>

View File

@@ -4,12 +4,24 @@ import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue' import SingleSelect from './SingleSelect.vue'
// SingleSelect already includes options prop, so no need to extend
const meta: Meta<typeof SingleSelect> = { const meta: Meta<typeof SingleSelect> = {
title: 'Components/Input/SingleSelect', title: 'Components/Input/SingleSelect',
component: SingleSelect, component: SingleSelect,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
label: { control: 'text' } label: { control: 'text' },
options: { control: 'object' }
},
args: {
label: 'Sorting Type',
options: [
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
{ name: 'Oldest', value: 'oldest' },
{ name: 'A → Z', value: 'az' },
{ name: 'Z → A', value: 'za' }
]
} }
} }
@@ -29,19 +41,18 @@ export const Default: Story = {
components: { SingleSelect }, components: { SingleSelect },
setup() { setup() {
const selected = ref<string | null>(null) const selected = ref<string | null>(null)
const options = sampleOptions const options = args.options || sampleOptions
return { selected, options, args } return { selected, options, args }
}, },
template: ` template: `
<div> <div>
<SingleSelect v-model="selected" :options="options" :label="args.label || 'Sorting Type'" /> <SingleSelect v-model="selected" :options="options" :label="args.label" />
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded"> <div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p> <p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
</div> </div>
</div> </div>
` `
}), })
args: { label: 'Sorting Type' }
} }
export const WithIcon: Story = { export const WithIcon: Story = {

View File

@@ -1,58 +1,73 @@
<template> <template>
<div class="relative inline-flex items-center"> <!--
<Select Note: We explicitly pass options here (not just via $attrs) because:
v-model="selectedItem" 1. Our custom value template needs options to look up labels from values
:options="options" 2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
option-label="name" 3. We need to maintain the icon slot functionality in the value template
option-value="value"
unstyled option-label="name" is required because our option template directly accesses option.name
:placeholder="label" -->
:pt="pt" <Select
> v-model="selectedItem"
<!-- Trigger value --> v-bind="$attrs"
<template #value="slotProps"> :options="options"
<div class="flex items-center gap-2 text-sm"> option-label="name"
<slot name="icon" /> option-value="value"
<span unstyled
v-if="slotProps.value !== null && slotProps.value !== undefined" :pt="pt"
class="text-zinc-700 dark-theme:text-gray-200" >
> <!-- Trigger value -->
{{ getLabel(slotProps.value) }} <template #value="slotProps">
</span> <div class="flex items-center gap-2 text-sm">
<span v-else class="text-zinc-700 dark-theme:text-gray-200"> <slot name="icon" />
{{ label }} <span
</span> v-if="slotProps.value !== null && slotProps.value !== undefined"
</div> class="text-zinc-700 dark-theme:text-gray-200"
</template> >
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-zinc-700 dark-theme:text-gray-200">
{{ label }}
</span>
</div>
</template>
<!-- Trigger caret --> <!-- Trigger caret -->
<template #dropdownicon> <template #dropdownicon>
<i-lucide:chevron-down <i-lucide:chevron-down
class="text-base text-neutral-400 dark-theme:text-gray-300" class="text-base text-neutral-400 dark-theme:text-gray-300"
/>
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div class="flex items-center justify-between gap-3 w-full">
<span class="truncate">{{ option.name }}</span>
<i-lucide:check
v-if="selected"
class="text-neutral-900 dark-theme:text-white"
/> />
</template> </div>
</template>
<!-- Option row --> </Select>
<template #option="{ option, selected }">
<div class="flex items-center justify-between gap-3 w-full">
<span class="truncate">{{ option.name }}</span>
<i-lucide:check
v-if="selected"
class="text-neutral-900 dark-theme:text-white"
/>
</div>
</template>
</Select>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Select, { SelectPassThroughMethodOptions } from 'primevue/select' import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
import { computed } from 'vue' import { computed } from 'vue'
defineOptions({
inheritAttrs: false
})
const { label, options } = defineProps<{ const { label, options } = defineProps<{
label?: string label?: string
options: { /**
* Required for displaying the selected item's label.
* Cannot rely on $attrs alone because we need to access options
* in getLabel() to map values to their display names.
*/
options?: {
name: string name: string
value: string value: string
}[] }[]
@@ -60,8 +75,14 @@ const { label, options } = defineProps<{
const selectedItem = defineModel<string | null>({ required: true }) const selectedItem = defineModel<string | null>({ required: true })
/**
* Maps a value to its display label.
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
* only the raw value. We need this to show the correct text when an item is selected.
*/
const getLabel = (val: string | null | undefined) => { const getLabel = (val: string | null | undefined) => {
if (val == null) return label ?? '' if (val == null) return label ?? ''
if (!options) return label ?? ''
const found = options.find((o) => o.value === val) const found = options.find((o) => o.value === val)
return found ? found.name : label ?? '' return found ? found.name : label ?? ''
} }
@@ -77,7 +98,7 @@ const pt = computed(() => ({
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({ }: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
class: [ class: [
// container // container
'relative inline-flex w-full cursor-pointer select-none items-center', 'relative inline-flex cursor-pointer select-none items-center',
// trigger surface // trigger surface
'rounded-md', 'rounded-md',
'bg-transparent text-neutral dark-theme:text-white', 'bg-transparent text-neutral dark-theme:text-white',
@@ -115,7 +136,9 @@ const pt = computed(() => ({
'flex items-center justify-between gap-3 px-3 py-2', 'flex items-center justify-between gap-3 px-3 py-2',
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50', 'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Selected state + check icon // Selected state + check icon
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected } { 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
// Add focus state for keyboard navigation
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.focused }
] ]
}), }),
optionLabel: { optionLabel: {

View File

@@ -12,7 +12,7 @@
</template> </template>
<template #header> <template #header>
<SearchBox v-model:="searchQuery" class="max-w-[384px]" /> <SearchBox v-model="searchQuery" class="max-w-[384px]" />
</template> </template>
<template #header-right-area> <template #header-right-area>
@@ -59,12 +59,13 @@
<div class="relative px-6 pt-2 pb-4 flex gap-2"> <div class="relative px-6 pt-2 pb-4 flex gap-2">
<MultiSelect <MultiSelect
v-model="selectedFrameworks" v-model="selectedFrameworks"
v-model:search-query="searchText"
class="w-[250px]" class="w-[250px]"
:has-search-box="true"
:show-selected-count="true"
:has-clear-button="true"
label="Select Frameworks" label="Select Frameworks"
:options="frameworkOptions" :options="frameworkOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
/> />
<MultiSelect <MultiSelect
v-model="selectedProjects" v-model="selectedProjects"
@@ -135,7 +136,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { provide, ref } from 'vue' import { provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue' import IconButton from '@/components/button/IconButton.vue'
@@ -201,9 +202,18 @@ const { onClose } = defineProps<{
provide(OnCloseKey, onClose) provide(OnCloseKey, onClose)
const searchQuery = ref<string>('') const searchQuery = ref<string>('')
const searchText = ref<string>('')
const selectedFrameworks = ref([]) const selectedFrameworks = ref([])
const selectedProjects = ref([]) const selectedProjects = ref([])
const selectedSort = ref<string>('popular') const selectedSort = ref<string>('popular')
const selectedNavItem = ref<string | null>('installed') const selectedNavItem = ref<string | null>('installed')
watch(searchText, (newQuery) => {
console.log('searchText:', searchText.value, newQuery)
})
watch(searchQuery, (newQuery) => {
console.log('searchQuery:', searchQuery.value, newQuery)
})
</script> </script>

View File

@@ -240,6 +240,9 @@ const createStoryTemplate = (args: StoryArgs) => ({
v-model="selectedFrameworks" v-model="selectedFrameworks"
label="Select Frameworks" label="Select Frameworks"
:options="frameworkOptions" :options="frameworkOptions"
:has-search-box="true"
:show-selected-count="true"
:has-clear-button="true"
/> />
<MultiSelect <MultiSelect
v-model="selectedProjects" v-model="selectedProjects"

View File

@@ -1,4 +1,4 @@
import ModelSelector from '@/components/widget/ModelSelector.vue' import SampleModelSelector from '@/components/widget/SampleModelSelector.vue'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
@@ -15,7 +15,7 @@ export const useModelSelectorDialog = () => {
function show() { function show() {
dialogService.showLayoutDialog({ dialogService.showLayoutDialog({
key: DIALOG_KEY, key: DIALOG_KEY,
component: ModelSelector, component: SampleModelSelector,
props: { props: {
onClose: hide onClose: hide
} }