mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[feat] Enhance MultiSelect component and Storybook stories (#5154)
This commit is contained in:
@@ -4,17 +4,26 @@
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Light theme default */
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: #1a1a1a;
|
||||
/* Light theme default - with explicit color to override media queries */
|
||||
body:not(.dark-theme) {
|
||||
background-color: #fff !important;
|
||||
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 */
|
||||
body.dark-theme,
|
||||
.dark-theme body {
|
||||
background-color: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
background-color: #202020;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Ensure Storybook canvas follows theme */
|
||||
@@ -24,11 +33,33 @@
|
||||
|
||||
.dark-theme .sb-show-main,
|
||||
.dark-theme .docs-story {
|
||||
background-color: #0a0a0a !important;
|
||||
background-color: #202020 !important;
|
||||
}
|
||||
|
||||
/* Fix for Storybook controls panel in dark mode */
|
||||
.dark-theme .docblock-argstable-body {
|
||||
color: #e5e5e5;
|
||||
/* CSS Variables for theme consistency */
|
||||
body:not(.dark-theme) {
|
||||
--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>
|
||||
@@ -24,22 +24,22 @@ export const Basic: Story = {
|
||||
<MoreButton>
|
||||
<template #default="{ close }">
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
type="transparent"
|
||||
label="Settings"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<Download />
|
||||
<Download :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="primary"
|
||||
type="transparent"
|
||||
label="Profile"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<ScrollText />
|
||||
<ScrollText :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import type { MultiSelectProps } from 'primevue/multiselect'
|
||||
import { ref } from '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',
|
||||
component: MultiSelect,
|
||||
tags: ['autodocs'],
|
||||
@@ -13,7 +27,35 @@ const meta: Meta<typeof MultiSelect> = {
|
||||
},
|
||||
options: {
|
||||
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 },
|
||||
setup() {
|
||||
const selected = ref([])
|
||||
const options = [
|
||||
const options = args.options || [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
@@ -38,8 +80,11 @@ export const Default: Story = {
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
label="Select Frameworks"
|
||||
v-bind="args"
|
||||
: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">
|
||||
<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 = {
|
||||
render: () => ({
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const options = [
|
||||
const options = args.options || [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
@@ -61,25 +106,43 @@ export const WithPreselectedValues: Story = {
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
]
|
||||
const selected = ref([options[0], options[1]])
|
||||
return { selected, options }
|
||||
return { selected, options, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
: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">
|
||||
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
|
||||
</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 = {
|
||||
render: () => ({
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const frameworkOptions = ref([
|
||||
@@ -114,7 +177,8 @@ export const MultipleSelectors: Story = {
|
||||
tagOptions,
|
||||
selectedFrameworks,
|
||||
selectedProjects,
|
||||
selectedTags
|
||||
selectedTags,
|
||||
args
|
||||
}
|
||||
},
|
||||
template: `
|
||||
@@ -124,22 +188,34 @@ export const MultipleSelectors: Story = {
|
||||
v-model="selectedFrameworks"
|
||||
:options="frameworkOptions"
|
||||
label="Select Frameworks"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
:options="projectOptions"
|
||||
label="Select Projects"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedTags"
|
||||
:options="tagOptions"
|
||||
label="Select Tags"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<h4 class="font-medium mb-2">Current Selection:</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
<h4 class="font-medium mt-0">Current Selection:</h4>
|
||||
<div class="flex flex-col text-sm">
|
||||
<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>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
@@ -147,5 +223,54 @@ export const MultipleSelectors: Story = {
|
||||
</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...'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +1,104 @@
|
||||
<template>
|
||||
<div class="relative inline-block">
|
||||
<MultiSelect
|
||||
v-model="selectedItems"
|
||||
:options="options"
|
||||
option-label="name"
|
||||
unstyled
|
||||
:placeholder="label"
|
||||
:max-selected-labels="0"
|
||||
:pt="pt"
|
||||
<!--
|
||||
Note: Unlike SingleSelect, we don't need an explicit options prop because:
|
||||
1. Our value template only shows a static label (not dynamic based on selection)
|
||||
2. We display a count badge instead of actual selected labels
|
||||
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
|
||||
|
||||
option-label="name" is required because our option template directly accesses option.name
|
||||
max-selected-labels="0" is required to show count badge instead of selected item labels
|
||||
-->
|
||||
<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
|
||||
v-if="hasSearchBox || showSelectedCount || hasClearButton"
|
||||
#header
|
||||
>
|
||||
<div class="p-2 flex flex-col gap-y-4 pb-0">
|
||||
<SearchBox
|
||||
v-if="hasSearchBox"
|
||||
v-model="searchQuery"
|
||||
:has-border="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
/>
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
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'
|
||||
"
|
||||
<div class="p-2 flex flex-col pb-0">
|
||||
<SearchBox
|
||||
v-if="showSearchBox"
|
||||
v-model="searchQuery"
|
||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||
:show-order="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
/>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
class="mt-2 flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
|
||||
>
|
||||
<i-lucide:check
|
||||
v-if="slotProps.selected"
|
||||
class="text-xs text-bold text-white"
|
||||
/>
|
||||
</div>
|
||||
<span>{{ slotProps.option.name }}</span>
|
||||
{{
|
||||
selectedCount > 0
|
||||
? $t('g.itemsSelected', { selectedCount })
|
||||
: $t('g.itemSelected', { selectedCount })
|
||||
}}
|
||||
</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>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
<div class="mt-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Selected count badge -->
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
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 }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import MultiSelect, {
|
||||
MultiSelectPassThroughMethodOptions
|
||||
} from 'primevue/multiselect'
|
||||
@@ -99,26 +110,29 @@ import TextButton from '../button/TextButton.vue'
|
||||
|
||||
type Option = { name: string; value: string }
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface Props {
|
||||
/** Input label shown on the trigger button */
|
||||
label?: string
|
||||
/** Static options for the multiselect (when not using async search) */
|
||||
options: Option[]
|
||||
/** Show search box in the panel header */
|
||||
hasSearchBox?: boolean
|
||||
showSearchBox?: boolean
|
||||
/** Show selected count text in the panel header */
|
||||
showSelectedCount?: boolean
|
||||
/** Show "Clear all" action in the panel header */
|
||||
hasClearButton?: boolean
|
||||
showClearButton?: boolean
|
||||
/** Placeholder for the search input */
|
||||
searchPlaceholder?: string
|
||||
// Note: options prop is intentionally omitted.
|
||||
// It's passed via $attrs to maximize PrimeVue API compatibility
|
||||
}
|
||||
const {
|
||||
label,
|
||||
options,
|
||||
hasSearchBox = false,
|
||||
showSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
hasClearButton = false,
|
||||
showClearButton = false,
|
||||
searchPlaceholder = 'Search...'
|
||||
} = defineProps<Props>()
|
||||
|
||||
@@ -131,7 +145,7 @@ const selectedCount = computed(() => selectedItems.value.length)
|
||||
const pt = computed(() => ({
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
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',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'border-[2.5px] border-solid',
|
||||
@@ -153,7 +167,7 @@ const pt = computed(() => ({
|
||||
},
|
||||
header: () => ({
|
||||
class:
|
||||
hasSearchBox || showSelectedCount || hasClearButton ? 'block' : 'hidden'
|
||||
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
|
||||
}),
|
||||
// Overlay & list visuals unchanged
|
||||
overlay:
|
||||
@@ -161,9 +175,17 @@ const pt = computed(() => ({
|
||||
list: {
|
||||
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
|
||||
},
|
||||
// Option row hover tone identical
|
||||
option:
|
||||
'flex gap-1 items-center p-2 hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
// Option row hover and focus tone
|
||||
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
|
||||
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)
|
||||
pcHeaderCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
|
||||
@@ -10,7 +10,15 @@ const meta: Meta<typeof SearchBox> = {
|
||||
argTypes: {
|
||||
placeHolder: {
|
||||
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 }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SearchBox v-model:="searchQuery" />
|
||||
<div style="max-width: 320px;">
|
||||
<SearchBox v-bind="args" v-model="searchText" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,19 +15,20 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { placeHolder, hasBorder = false } = defineProps<{
|
||||
const { placeHolder, showBorder = false } = defineProps<{
|
||||
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(() => {
|
||||
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 px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
@@ -4,12 +4,24 @@ import { ref } from 'vue'
|
||||
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
|
||||
// SingleSelect already includes options prop, so no need to extend
|
||||
const meta: Meta<typeof SingleSelect> = {
|
||||
title: 'Components/Input/SingleSelect',
|
||||
component: SingleSelect,
|
||||
tags: ['autodocs'],
|
||||
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 },
|
||||
setup() {
|
||||
const selected = ref<string | null>(null)
|
||||
const options = sampleOptions
|
||||
const options = args.options || sampleOptions
|
||||
return { selected, options, args }
|
||||
},
|
||||
template: `
|
||||
<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">
|
||||
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: { label: 'Sorting Type' }
|
||||
})
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
|
||||
@@ -1,58 +1,73 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<Select
|
||||
v-model="selectedItem"
|
||||
:options="options"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
unstyled
|
||||
:placeholder="label"
|
||||
:pt="pt"
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-zinc-700 dark-theme:text-gray-200"
|
||||
>
|
||||
{{ getLabel(slotProps.value) }}
|
||||
</span>
|
||||
<span v-else class="text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<!--
|
||||
Note: We explicitly pass options here (not just via $attrs) because:
|
||||
1. Our custom value template needs options to look up labels from values
|
||||
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
|
||||
3. We need to maintain the icon slot functionality in the value template
|
||||
|
||||
option-label="name" is required because our option template directly accesses option.name
|
||||
-->
|
||||
<Select
|
||||
v-model="selectedItem"
|
||||
v-bind="$attrs"
|
||||
:options="options"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
unstyled
|
||||
:pt="pt"
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-zinc-700 dark-theme:text-gray-200"
|
||||
>
|
||||
{{ getLabel(slotProps.value) }}
|
||||
</span>
|
||||
<span v-else class="text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger caret -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down
|
||||
class="text-base text-neutral-400 dark-theme:text-gray-300"
|
||||
<!-- Trigger caret -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down
|
||||
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>
|
||||
|
||||
<!-- 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"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { label, options } = defineProps<{
|
||||
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
|
||||
value: string
|
||||
}[]
|
||||
@@ -60,8 +75,14 @@ const { label, options } = defineProps<{
|
||||
|
||||
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) => {
|
||||
if (val == null) return label ?? ''
|
||||
if (!options) return label ?? ''
|
||||
const found = options.find((o) => o.value === val)
|
||||
return found ? found.name : label ?? ''
|
||||
}
|
||||
@@ -77,7 +98,7 @@ const pt = computed(() => ({
|
||||
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
|
||||
class: [
|
||||
// container
|
||||
'relative inline-flex w-full cursor-pointer select-none items-center',
|
||||
'relative inline-flex cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
'rounded-md',
|
||||
'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',
|
||||
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
// 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: {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox v-model:="searchQuery" class="max-w-[384px]" />
|
||||
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -59,12 +59,13 @@
|
||||
<div class="relative px-6 pt-2 pb-4 flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
v-model:search-query="searchText"
|
||||
class="w-[250px]"
|
||||
:has-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:has-clear-button="true"
|
||||
label="Select Frameworks"
|
||||
:options="frameworkOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
@@ -135,7 +136,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { provide, ref } from 'vue'
|
||||
import { provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
@@ -201,9 +202,18 @@ const { onClose } = defineProps<{
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
const searchText = ref<string>('')
|
||||
const selectedFrameworks = ref([])
|
||||
const selectedProjects = ref([])
|
||||
const selectedSort = ref<string>('popular')
|
||||
|
||||
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>
|
||||
@@ -240,6 +240,9 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
v-model="selectedFrameworks"
|
||||
label="Select Frameworks"
|
||||
:options="frameworkOptions"
|
||||
:has-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:has-clear-button="true"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ModelSelector from '@/components/widget/ModelSelector.vue'
|
||||
import SampleModelSelector from '@/components/widget/SampleModelSelector.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -15,7 +15,7 @@ export const useModelSelectorDialog = () => {
|
||||
function show() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: ModelSelector,
|
||||
component: SampleModelSelector,
|
||||
props: {
|
||||
onClose: hide
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user