import type { Meta, StoryObj } from '@storybook/vue3' import { ref } from 'vue' import FdsSearchSelect from './FdsSearchSelectPro.vue' const meta: Meta = { title: 'FDS/Form/FdsSearchSelectPro', component: FdsSearchSelect, tags: ['autodocs'], argTypes: { items: { control: { type: 'object' }, description: 'Array of items to display in the search select dropdown', }, multiple: { control: { type: 'boolean' }, description: 'Enables checkbox multi-select behavior', }, hierarchical: { control: { type: 'boolean' }, description: 'Adds hierarchy padding based on level field', }, selectChildrenRecursively: { control: { type: 'boolean' }, description: 'When true, selecting a parent also selects child items recursively', }, showSelectedToggle: { control: { type: 'boolean' }, description: 'Shows selected-only filter toggle in dropdown', }, autoDividerBy: { control: { type: 'text' }, description: 'Field name used to auto-create divider rows (e.g. "claco_region")', }, dividerSelectable: { control: { type: 'boolean' }, description: 'Allows divider rows to be selectable', }, showLoadMore: { control: { type: 'boolean' }, description: 'Shows load-more action row at the end of list', }, loadingMore: { control: { type: 'boolean' }, description: 'Shows loading state for load-more action row', }, autoLoadOnScroll: { control: { type: 'boolean' }, description: 'Automatically triggers load more when scrolling near the bottom', }, autoLoadThreshold: { control: { type: 'number' }, description: 'Bottom distance in pixels before auto-load is triggered', }, highlightDividerMatch: { control: { type: 'boolean' }, description: 'Highlights search term inside divider labels', }, showOutOfBoundsDivider: { control: { type: 'boolean' }, description: 'Keeps divider visible when only children in that group match search', }, maxItems: { control: { type: 'number' }, description: 'Maximum number of items shown at once before the localized load-more action reveals more', }, page: { control: { type: 'number' }, description: 'Current page number for server-side pagination', }, totalPages: { control: { type: 'number' }, description: 'Total number of pages for server-side pagination', }, totalCount: { control: { type: 'number' }, description: 'Total count of unfiltered items (displayed as "x av y")', }, loading: { control: { type: 'boolean' }, description: 'Shows loading spinner in the dropdown when true', }, searchFields: { control: { type: 'object' }, description: 'Array of field names to search in (e.g., ["fullName", "email"])', }, preserveOrder: { control: { type: 'boolean' }, description: 'If true, preserves the order of searchFields when displaying results', }, debounceMs: { control: { type: 'number' }, description: 'Debounce delay in milliseconds for input/change events', }, initialValue: { control: { type: 'text' }, description: 'Initial value to display in the input field', }, displayValue: { control: { type: 'text' }, description: 'Prefills input text without marking it as a selected value', }, disabled: { control: { type: 'boolean' }, description: 'Disables the search select component', }, dropdownAbsolute: { control: { type: 'boolean' }, description: 'If true, positions the dropdown absolutely', }, labelLeft: { control: { type: 'boolean' }, description: 'If true, positions the label to the left of the input', }, label: { control: { type: 'text' }, description: 'Label text for the input field', }, meta: { control: { type: 'text' }, description: 'Meta text displayed below the label', }, searchContext: { control: { type: 'object' }, description: 'Context object with "context" and "linkWord" for displaying search results count', }, valid: { control: { type: 'select' }, options: [true, false, null], mapping: { true: true, false: false, null: null }, description: 'Validation state: true for valid, false for invalid, null for neutral', }, validIconOnSelected: { control: { type: 'boolean' }, description: 'Shows valid icon when an item is selected', }, invalidOnEmptySelection: { control: { type: 'boolean' }, description: 'When false, does not set invalid state when all selections are removed', }, invalidMessage: { control: { type: 'text' }, description: 'Error message displayed when validation is false', }, noResultPrompt: { control: { type: 'text' }, description: 'Message displayed when no search results are found', }, maxListHeight: { control: { type: 'number' }, description: 'Maximum height of the dropdown list in pixels', }, }, args: { items: [], multiple: false, hierarchical: false, selectChildrenRecursively: false, initialSelectedItems: [], itemKey: undefined, dividerField: 'isDivider', dividerSelectable: false, autoDividerBy: undefined, levelField: 'level', showSelectedToggle: false, selectedToggleLabel: 'Dina val', showLoadMore: false, loadingMore: false, autoLoadOnScroll: false, autoLoadThreshold: 24, highlightDividerMatch: true, showOutOfBoundsDivider: true, maxItems: undefined, searchFields: ['companyName', 'orgNumber'], preserveOrder: false, debounceMs: 500, initialValue: '', displayValue: '', disabled: false, dropdownAbsolute: false, labelLeft: false, label: 'Sök organisation', meta: undefined, searchContext: { context: 'organisationer', linkWord: 'av' }, valid: undefined, validIconOnSelected: false, invalidOnEmptySelection: true, invalidMessage: 'Vänligen välj en organisation', noResultPrompt: 'Inga resultat hittades', maxListHeight: undefined, }, } export default meta type Story = StoryObj // Mock data for items examples const mockItems = [ { companyName: 'Acme Corporation', orgNumber: '1234567890', companyFqn: 'Acme Corporation | 1234567890', }, { companyName: 'Tech Solutions AB', orgNumber: '9876543210', companyFqn: 'Tech Solutions AB | 9876543210', }, { companyName: 'Global Industries', orgNumber: '5555555555', companyFqn: 'Global Industries | 5555555555', }, { companyName: 'Nordic Ventures', orgNumber: '1111111111', companyFqn: 'Nordic Ventures | 1111111111', }, { companyName: 'Scandinavian Group', orgNumber: '2222222222', companyFqn: 'Scandinavian Group | 2222222222', }, ] export const WithItems: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const selected = ref | null>(null) const handleSearchSelected = (value: Record | null) => { selected.value = value } return { args, selected, handleSearchSelected } }, template: ` `, }), args: { items: mockItems, label: 'Sök bland organisationer', meta: 'Börja skriva för att söka', }, } export const WithSelectedEarlier: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const selected = ref | null>(null) const handleSearchSelected = (value: Record | null) => { selected.value = value } return { args, selected, handleSearchSelected } }, template: ` `, }), args: { items: mockItems, initialValue: 'Acme Corporation', label: 'Organisation', }, } export const SingleOrganization: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const selected = ref | null>(null) const handleSearchSelected = (value: Record | null) => { selected.value = value } return { args, selected, handleSearchSelected } }, template: ` `, }), args: { items: [ { companyName: 'Acme Corporation', orgNumber: '1234567890', companyFqn: 'Acme Corporation | 1234567890', }, ], label: 'Organisation', }, } export const WithValidation: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const selected = ref | null>(null) const valid = ref(null) const handleSearchSelected = (value: Record | null) => { selected.value = value valid.value = value ? true : null } return { args, selected, valid, handleSearchSelected } }, template: ` `, }), args: { items: mockItems, label: 'Välj organisation', invalidMessage: 'Vänligen välj en organisation', }, } export const ValidIconOnSelected: Story = { args: { items: mockItems, label: 'Välj organisation', validIconOnSelected: true, invalidMessage: 'Vänligen välj en organisation', }, } export const MultiSelectNoInvalidOnDeselect: Story = { args: { items: mockItems, multiple: true, label: 'Välj organisationer', invalidOnEmptySelection: false, invalidMessage: 'Vänligen välj minst en organisation', }, } export const WithLabelLeft: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const selected = ref | null>(null) const handleSearchSelected = (value: Record | null) => { selected.value = value } return { args, selected, handleSearchSelected } }, template: `
`, }), args: { items: mockItems, label: 'Organisation', labelLeft: true, meta: 'Sök efter organisation', }, } export const WithAbsoluteList: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const selected = ref | null>(null) const handleSearchSelected = (value: Record | null) => { selected.value = value } return { args, selected, handleSearchSelected } }, template: `
`, }), args: { items: mockItems, dropdownAbsolute: true, label: 'Sök organisation', }, } export const Disabled: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => ({ args }), template: '', }), args: { items: mockItems, disabled: true, label: 'Sök organisation', }, } export const EnglishCopy: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const selected = ref | null>(null) const handleSearchSelected = (value: Record | null) => { selected.value = value } return { args, selected, handleSearchSelected } }, template: `
`, }), args: { items: mockItems, label: 'Search organization', meta: 'Start typing to search', searchContext: { context: 'organizations', linkWord: 'of' }, noResultPrompt: 'No results found', invalidMessage: 'Please select an organization', }, } export const ServerPaginationWithLoadMore: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const pageSize = 5 const allItems = Array.from({ length: 18 }, (_, i) => { const index = i + 1 return { companyName: `Company ${index}`, orgNumber: String(1000000000 + index), companyFqn: `Company ${index} | ${1000000000 + index}`, } }) const currentPage = ref(1) const totalPages = Math.ceil(allItems.length / pageSize) const loadingMore = ref(false) const items = ref(allItems.slice(0, pageSize)) const handleLoadMore = async () => { if (loadingMore.value || currentPage.value >= totalPages) return loadingMore.value = true await new Promise((resolve) => setTimeout(resolve, 1000)) currentPage.value += 1 items.value = allItems.slice(0, currentPage.value * pageSize) loadingMore.value = false } return { args, items, loadingMore, currentPage, totalPages, totalCount: allItems.length, handleLoadMore, } }, template: ` `, }), args: { label: 'Sök organisation', searchFields: ['companyName', 'orgNumber'], searchContext: { context: 'organisationer', linkWord: 'av' }, noResultPrompt: 'Inga resultat hittades', }, } export const SimulatedSearchAndLoading: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const allItems = [...mockItems] const loading = ref(false) const items = ref(allItems) let activeRequestId = 0 const handleSearchChange = (value: string) => { const requestId = ++activeRequestId const normalizedValue = value.trim().toLowerCase() loading.value = true window.setTimeout(() => { if (requestId !== activeRequestId) return if (!normalizedValue) { items.value = allItems } else { items.value = allItems.filter((item) => [item.companyName, item.orgNumber] .map((field) => field.toLowerCase()) .some((field) => field.includes(normalizedValue)), ) } loading.value = false }, 900) } return { args, items, loading, handleSearchChange } }, template: ` `, }), args: { label: 'Sök organisation (simulerad API-sökning)', meta: 'Visar laddning medan sökningen hämtar data', searchFields: ['companyName', 'orgNumber'], searchContext: { context: 'organisationer', linkWord: 'av' }, noResultPrompt: 'Inga resultat hittades', }, } const hierarchicalItems = [ { id: 'region-north', label: 'Region Norr', isDivider: true, level: 0 }, { id: 'unit-1', label: 'Enhet #1', level: 1 }, { id: 'team-1', label: 'Team #1', level: 2 }, { id: 'team-2', label: 'Team #2', level: 2 }, { id: 'unit-2', label: 'Enhet #2', level: 1 }, { id: 'region-east', label: 'Region Ost', isDivider: true, level: 0 }, { id: 'unit-3', label: 'Enhet #3', level: 1 }, { id: 'unit-4', label: 'Enhet #4', level: 1 }, { id: 'team-3', label: 'Team #3', level: 2 }, ] const largeHierarchicalItems = [ { id: 'region-north', label: 'Region Norr', isDivider: true, level: 0 }, ...Array.from({ length: 20 }, (_, i) => ({ id: `north-unit-${i + 1}`, label: `Norr Enhet #${i + 1}`, level: 1, })), { id: 'region-east', label: 'Region Ost', isDivider: true, level: 0 }, ...Array.from({ length: 20 }, (_, i) => ({ id: `east-unit-${i + 1}`, label: `Ost Enhet #${i + 1}`, level: 1, })), { id: 'region-south', label: 'Region Syd', isDivider: true, level: 0 }, ...Array.from({ length: 20 }, (_, i) => ({ id: `south-unit-${i + 1}`, label: `Syd Enhet #${i + 1}`, level: 1, })), ] export const HierarchicalWithDividers: Story = { args: { items: hierarchicalItems, multiple: true, hierarchical: true, selectChildrenRecursively: true, dividerSelectable: true, itemKey: 'id', dividerField: 'isDivider', levelField: 'level', searchFields: ['label'], label: 'Region, enhet och team', showSelectedToggle: true, initialSelectedItems: [ { id: 'unit-1', label: 'Enhet #1', level: 1 }, { id: 'unit-3', label: 'Enhet #3', level: 1 }, ], searchContext: { context: 'val', linkWord: 'av' }, meta: 'Testa divider-rader samt indrag for hierarchy (niva 1 och 2)', noResultPrompt: 'Inga resultat hittades', }, } export const SingleSelectWithSelectableDivider: Story = { args: { items: hierarchicalItems, multiple: false, dividerSelectable: true, itemKey: 'id', dividerField: 'isDivider', levelField: 'level', searchFields: ['label'], label: 'Region och enhet (single select)', noResultPrompt: 'Inga resultat hittades', }, } export const MultipleWithLoadMore: Story = { args: { items: largeHierarchicalItems, multiple: true, itemKey: 'id', searchFields: ['label'], label: 'Region och enhet', showLoadMore: true, autoLoadOnScroll: true, maxListHeight: 320, maxItems: 10, }, } const clinicItems = [ { id: '0000000', name: 'ALNA', claco_region: 'Stockholm', j4_region: 'ÖÖ_EJ AKTIVA MOTT/BOLAG' }, { id: '1111111', name: 'AO Org och Ledarskap', claco_region: 'Öst', j4_region: 'ORG & LEDARSKAP (AO)' }, { id: '2222222', name: '2000252', claco_region: '', j4_region: '' }, { id: '3333333', name: 'Kundcenter Malmo', claco_region: 'KUNDCENTER', j4_region: 'KUNDCENTER' }, { id: '4444444', name: 'Feelgood Norrkoping', claco_region: 'Öst', j4_region: 'ÖST' }, { id: '5555555', name: 'Feelgood Skovde', claco_region: 'Väst', j4_region: 'VÄST-SKÖVDE' }, { id: '6666666', name: 'Feelgood Malmo', claco_region: 'Syd', j4_region: 'SYD-MALMÖ' }, { id: '7777777', name: 'Feelgood Linkoping City', claco_region: 'Öst', j4_region: 'ÖST' }, { id: '8888888', name: 'Feelgood Norrkoping', claco_region: 'Öst', j4_region: 'ÖST' }, { id: '9999999', name: 'Feelgood Sundsvall', claco_region: 'Norr', j4_region: 'NORR-MELLAN' }, { id: '1000000', name: 'Feelgood Sundsvall', claco_region: 'Norr', j4_region: 'NORR-MELLAN' }, ] export const AutoDividerByRegion: Story = { args: { items: clinicItems, multiple: true, itemKey: 'id', searchFields: ['name', 'test'], autoDividerBy: 'claco_region', dividerField: 'isDivider', label: 'Sök', showSelectedToggle: true, searchContext: { context: 'enheter', linkWord: 'av' }, noResultPrompt: 'Inga resultat hittades', displayValue: 'Alla', unspecifiedLabel: 'Okategoriserat', }, } export const KeyboardNavigationChecklist: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const selected = ref>>([]) const handleSearchSelected = (value: Record | Array> | null) => { selected.value = Array.isArray(value) ? value : value ? [value] : [] } return { args, selected, handleSearchSelected } }, template: `
Keyboard test checklist
1. Tab to input -> dropdown opens (if items exist)
2. ArrowDown/ArrowUp -> move between list options
3. Space/Enter (multiple) -> toggle current option
4. Tab -> move to next focusable element and close dropdown
5. Shift+Tab back to input -> input regains focus
Valda: {{ selected.length }}
`, }), args: { items: [ { id: 'stockholm', name: 'Stockholm', isDivider: true }, { id: 'unit-1', name: 'Enhet 1' }, { id: 'unit-2', name: 'Enhet 2' }, { id: 'unit-3', name: 'Enhet 3' }, { id: 'west', name: 'Väst', isDivider: true }, { id: 'unit-4', name: 'Enhet 4' }, { id: 'unit-5', name: 'Enhet 5' }, ], itemKey: 'id', dividerField: 'isDivider', searchFields: ['name'], label: 'Keyboard test', meta: 'Testa tangentbordsnavigering i listan', multiple: true, showSelectedToggle: true, searchContext: { context: 'enheter', linkWord: 'av' }, noResultPrompt: 'Inga resultat hittades', maxListHeight: 260, }, } export const KeyboardNavigationChecklistRadio: Story = { render: (args: Story['args']) => ({ components: { FdsSearchSelect }, setup: () => { const selected = ref | null>(null) const handleSearchSelected = (value: Record | Array> | null) => { selected.value = Array.isArray(value) ? (value[0] ?? null) : value } return { args, selected, handleSearchSelected } }, template: `
Keyboard test checklist (radio/single)
1. Tab to input -> dropdown opens (if items exist)
2. ArrowDown/ArrowUp -> move between list options
3. Space -> select focused radio option
4. Tab -> move to next focusable element and close dropdown
5. Shift+Tab back to input -> input regains focus
Valt: {{ selected ? (selected.name ?? selected.label ?? 'Ja') : 'Inget valt' }}
`, }), args: { items: [ { id: 'stockholm', name: 'Stockholm', isDivider: true }, { id: 'unit-1', name: 'Enhet 1' }, { id: 'unit-2', name: 'Enhet 2' }, { id: 'unit-3', name: 'Enhet 3' }, { id: 'west', name: 'Väst', isDivider: true }, { id: 'unit-4', name: 'Enhet 4' }, { id: 'unit-5', name: 'Enhet 5' }, ], itemKey: 'id', dividerField: 'isDivider', searchFields: ['name'], label: 'Keyboard test (radio)', meta: 'Testa tangentbordsnavigering i single-select', multiple: false, searchContext: { context: 'enheter', linkWord: 'av' }, noResultPrompt: 'Inga resultat hittades', maxListHeight: 260, }, }