From c4272ef1da366b18d7873a468ff0ecbbb01daee3 Mon Sep 17 00:00:00 2001 From: Dante Date: Tue, 10 Mar 2026 14:00:58 +0900 Subject: [PATCH] refactor: reorganize Select stories and add size/state variants (#9639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스크린샷 2026-03-09 오후 2 48 10 ## Summary - Reorganize Select-related stories under `Components/Select/` hierarchy (SingleSelect, MultiSelect, Select) - Add `size` prop (`lg`/`md`) to SingleSelect, MultiSelect, SelectTrigger for Figma Large (40px) / Medium (32px) variants - Add `invalid` prop (red border) to SingleSelect and SelectTrigger - Add `loading` prop (spinner) to SingleSelect - Add `hover:bg-secondary-background-hover` to all select triggers - Align disabled opacity to 30% per Figma spec - Add new stories: Disabled, Invalid, Loading, MediumSize, AllStates ## Test plan - [ ] Verify Storybook renders all stories under `Components/Select/` - [ ] Check hover state visually on all select triggers - [ ] Verify Medium size (32px) renders correctly - [ ] Verify Invalid state shows red border - [ ] Verify Loading state shows spinner - [ ] Verify Disabled state has 30% opacity and no hover effect ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9639-refactor-reorganize-Select-stories-and-add-size-state-variants-31e6d73d36508142b835f04ab6bdaefe) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 --- src/components/input/MultiSelect.stories.ts | 487 ++++-------------- src/components/input/MultiSelect.vue | 26 +- .../input/SelectDropdown.stories.ts | 219 ++++++++ src/components/input/SingleSelect.stories.ts | 281 ++++------ src/components/input/SingleSelect.vue | 57 +- src/components/ui/select/Select.stories.ts | 261 ---------- src/components/ui/select/SelectGroup.vue | 17 - src/components/ui/select/SelectLabel.vue | 25 - src/components/ui/select/SelectSeparator.vue | 18 - src/components/ui/select/SelectTrigger.vue | 31 +- 10 files changed, 508 insertions(+), 914 deletions(-) create mode 100644 src/components/input/SelectDropdown.stories.ts delete mode 100644 src/components/ui/select/Select.stories.ts delete mode 100644 src/components/ui/select/SelectGroup.vue delete mode 100644 src/components/ui/select/SelectLabel.vue delete mode 100644 src/components/ui/select/SelectSeparator.vue diff --git a/src/components/input/MultiSelect.stories.ts b/src/components/input/MultiSelect.stories.ts index d66a70653d..bac6fd26b7 100644 --- a/src/components/input/MultiSelect.stories.ts +++ b/src/components/input/MultiSelect.stories.ts @@ -1,73 +1,33 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' -import type { MultiSelectProps } from 'primevue/multiselect' import { ref } from 'vue' import MultiSelect from './MultiSelect.vue' import type { SelectOption } from './types' -// Combine our component props with PrimeVue MultiSelect props -// Since we use v-bind="$attrs", all PrimeVue props are available -interface ExtendedProps extends Partial { - // Our custom props - label?: string - showSearchBox?: boolean - showSelectedCount?: boolean - showClearButton?: boolean - searchPlaceholder?: string - listMaxHeight?: string - popoverMinWidth?: string - popoverMaxWidth?: string - // Override modelValue type to match our Option type - modelValue?: SelectOption[] -} - -const meta: Meta = { - title: 'Components/Input/MultiSelect', +const meta: Meta = { + title: 'Components/Select/MultiSelect', component: MultiSelect, tags: ['autodocs'], + parameters: { layout: 'padded' }, + decorators: [ + () => ({ + template: '
' + }) + ], argTypes: { - label: { - control: 'text' + label: { control: 'text' }, + size: { + control: { type: 'select' }, + options: ['lg', 'md'] }, - 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' - }, - listMaxHeight: { - control: 'text', - description: 'Maximum height of the dropdown list' - }, - popoverMinWidth: { - control: 'text', - description: 'Minimum width of the popover' - }, - popoverMaxWidth: { - control: 'text', - description: 'Maximum width of the popover' - } + showSearchBox: { control: 'boolean' }, + showSelectedCount: { control: 'boolean' }, + showClearButton: { control: 'boolean' }, + searchPlaceholder: { control: 'text' } }, args: { - label: 'Select', - options: [ - { name: 'Vue', value: 'vue' }, - { name: 'React', value: 'react' }, - { name: 'Angular', value: 'angular' }, - { name: 'Svelte', value: 'svelte' } - ], + label: 'Category', + size: 'lg', showSearchBox: false, showSelectedCount: false, showClearButton: false, @@ -78,352 +38,125 @@ const meta: Meta = { export default meta type Story = StoryObj +const sampleOptions: SelectOption[] = [ + { name: 'Vue', value: 'vue' }, + { name: 'React', value: 'react' }, + { name: 'Angular', value: 'angular' }, + { name: 'Svelte', value: 'svelte' } +] + export const Default: Story = { render: (args) => ({ components: { MultiSelect }, setup() { - const selected = ref([]) - const options = args.options || [ - { name: 'Vue', value: 'vue' }, - { name: 'React', value: 'react' }, - { name: 'Angular', value: 'angular' }, - { name: 'Svelte', value: 'svelte' } - ] - return { selected, options, args } + const selected = ref([]) + return { selected, sampleOptions, args } }, - template: ` -
- -
-

Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}

-
-
- ` + template: + '' }) } -export const WithPreselectedValues: Story = { - render: (args) => ({ +export const MediumSize: Story = { + render: () => ({ components: { MultiSelect }, setup() { - const options = args.options || [ - { name: 'JavaScript', value: 'js' }, - { name: 'TypeScript', value: 'ts' }, - { name: 'Python', value: 'python' }, - { name: 'Go', value: 'go' }, - { name: 'Rust', value: 'rust' } - ] - const selected = ref([options[0], options[1]]) - return { selected, options, args } + const selected = ref([sampleOptions[0]]) + return { selected, sampleOptions } }, - template: ` -
- -
-

Selected: {{ selected.map(s => s.name).join(', ') }}

-
-
- ` + template: + '' }), - 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...' - } + parameters: { controls: { disable: true } } } -export const MultipleSelectors: Story = { +export const WithPreselectedValues: Story = { + render: () => ({ + components: { MultiSelect }, + setup() { + const selected = ref([sampleOptions[0], sampleOptions[1]]) + return { selected, sampleOptions } + }, + template: + '' + }), + parameters: { controls: { disable: true } } +} + +export const Disabled: Story = { + render: () => ({ + components: { MultiSelect }, + setup() { + const selected = ref([sampleOptions[0]]) + return { selected, sampleOptions } + }, + template: + '' + }), + parameters: { controls: { disable: true } } +} + +export const WithSearchBox: Story = { + args: { showSearchBox: true }, render: (args) => ({ components: { MultiSelect }, setup() { - const frameworkOptions = ref([ - { name: 'Vue', value: 'vue' }, - { name: 'React', value: 'react' }, - { name: 'Angular', value: 'angular' }, - { name: 'Svelte', value: 'svelte' } - ]) + const selected = ref([]) + return { selected, sampleOptions, args } + }, + template: + '' + }) +} - const projectOptions = ref([ - { name: 'Project A', value: 'proj-a' }, - { name: 'Project B', value: 'proj-b' }, - { name: 'Project C', value: 'proj-c' }, - { name: 'Project D', value: 'proj-d' } - ]) +export const AllHeaderFeatures: Story = { + args: { + showSearchBox: true, + showSelectedCount: true, + showClearButton: true + }, + render: (args) => ({ + components: { MultiSelect }, + setup() { + const selected = ref([]) + return { selected, sampleOptions, args } + }, + template: + '' + }) +} - const tagOptions = ref([ - { name: 'Frontend', value: 'frontend' }, - { name: 'Backend', value: 'backend' }, - { name: 'Database', value: 'database' }, - { name: 'DevOps', value: 'devops' }, - { name: 'Testing', value: 'testing' } - ]) - - const selectedFrameworks = ref([]) - const selectedProjects = ref([]) - const selectedTags = ref([]) - - return { - frameworkOptions, - projectOptions, - tagOptions, - selectedFrameworks, - selectedProjects, - selectedTags, - args - } +export const AllStates: Story = { + render: () => ({ + components: { MultiSelect }, + setup() { + const a = ref([]) + const b = ref([sampleOptions[0]]) + const c = ref([sampleOptions[0]]) + return { sampleOptions, a, b, c } }, template: ` -
-
- - - +
+
+

Large (Interface)

+
+ + + +
- -
-

Current Selection:

-
-

Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}

-

Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}

-

Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}

+
+

Medium (Node)

+
+ + +
` }), - 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...' - } -} - -export const CustomMaxHeight: Story = { - render: () => ({ - components: { MultiSelect }, - setup() { - const selected1 = ref([]) - const selected2 = ref([]) - const selected3 = ref([]) - const manyOptions = Array.from({ length: 20 }, (_, i) => ({ - name: `Option ${i + 1}`, - value: `option${i + 1}` - })) - return { selected1, selected2, selected3, manyOptions } - }, - template: ` -
-
-

Small Height (10rem)

- -
-
-

Default Height (28rem)

- -
-
-

Large Height (32rem)

- -
-
- ` - }), parameters: { - controls: { disable: true }, - actions: { disable: true }, - slot: { disable: true } - } -} - -export const CustomMinWidth: Story = { - render: () => ({ - components: { MultiSelect }, - setup() { - const selected1 = ref([]) - const selected2 = ref([]) - const selected3 = ref([]) - const options = [ - { name: 'A', value: 'a' }, - { name: 'B', value: 'b' }, - { name: 'Very Long Option Name Here', value: 'long' } - ] - return { selected1, selected2, selected3, options } - }, - template: ` -
-
-

Auto Width

- -
-
-

Min Width 18rem

- -
-
-

Min Width 28rem

- -
-
- ` - }), - parameters: { - controls: { disable: true }, - actions: { disable: true }, - slot: { disable: true } - } -} - -export const CustomMaxWidth: Story = { - render: () => ({ - components: { MultiSelect }, - setup() { - const selected1 = ref([]) - const selected2 = ref([]) - const selected3 = ref([]) - const longOptions = [ - { name: 'Short', value: 'short' }, - { - name: 'This is a very long option name that would normally expand the dropdown', - value: 'long1' - }, - { - name: 'Another extremely long option that demonstrates max-width constraint', - value: 'long2' - } - ] - return { selected1, selected2, selected3, longOptions } - }, - template: ` -
-
-

Auto Width

- -
-
-

Max Width 18rem

- -
-
-

Min 12rem Max 22rem

- -
-
- ` - }), - parameters: { - controls: { disable: true }, - actions: { disable: true }, - slot: { disable: true } + controls: { disable: true } } } diff --git a/src/components/input/MultiSelect.vue b/src/components/input/MultiSelect.vue index 4a21c760df..813bb46929 100644 --- a/src/components/input/MultiSelect.vue +++ b/src/components/input/MultiSelect.vue @@ -16,20 +16,23 @@ :pt="{ root: ({ props }: MultiSelectPassThroughMethodOptions) => ({ class: cn( - 'relative inline-flex h-10 cursor-pointer select-none', + 'relative inline-flex cursor-pointer select-none', + size === 'md' ? 'h-8' : 'h-10', 'rounded-lg bg-secondary-background text-base-foreground', 'transition-all duration-200 ease-in-out', + 'hover:bg-secondary-background-hover', 'border-[2.5px] border-solid', - selectedCount > 0 - ? 'border-node-component-border' - : 'border-transparent', - 'focus-within:border-node-component-border', - { 'cursor-default opacity-60': props.disabled } + selectedCount > 0 ? 'border-base-foreground' : 'border-transparent', + 'focus-within:border-base-foreground', + props.disabled && + 'cursor-default opacity-30 hover:bg-secondary-background' ) }), labelContainer: { - class: - 'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 ' + class: cn( + 'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap', + size === 'md' ? 'pl-3' : 'pl-4' + ) }, label: { class: 'p-0' @@ -129,12 +132,12 @@