@fiscozen/select
v1.0.0
Published
Design System Select component
Keywords
Readme
@fiscozen/select
For usage documentation, see Storybook Documentation
Development
Setup
- Clone the repository
- Install dependencies:
pnpm install - Run dev server:
pnpm dev(from workspace root)
Architecture
The FzSelect component is a unified dropdown select component that supports both standard select behavior (default, when filterable is false) and typeahead/filterable behavior (when filterable is true). It shares the same floating panel, lazy loading, and keyboard navigation patterns. By default, it behaves as a standard select dropdown. When filterable is set to true, the component shows a filterable input field when the dropdown is open; otherwise, it displays the selected option label as a button.
Key Components:
FzSelect.vue: Main component orchestrating state, filtering, and navigationFzSelectButton.vue: Opener button that switches between button and input modesFzSelectOptionsList.vue: Options list with lazy loading supportFzSelectLabel.vue: Label component with required indicatorFzSelectHelpError.vue: Help/error message component
State Management:
model: Selected value (string | undefined)searchValue: Current input text for filteringdebouncedSearchValue: Debounced version of searchValueinternalFilteredOptions: Filtered options after applying filterFn or Fuse.jsvisibleOptions: Lazy-loaded subset of filtered optionsfocusedIndex: Index of currently focused option inselectableOptionsisOpen: Dropdown open/closed state
Filtering Flow:
- User types →
searchValueupdates - After
delayTimems →debouncedSearchValueupdates updateFilteredOptions()called → appliesfilterFnor Fuse.jsinternalFilteredOptionsupdated → triggersvisibleOptionsreset- Scroll triggers lazy loading →
visibleOptionsexpands
Code Organization
src/FzSelect.vue: Main component with all business logicsrc/types.ts: Type definitions for props and optionssrc/common.ts: Shared utilities (width calculation, constants)src/utils.ts: Utility functions (debounce)src/components/: Internal presentational componentsFzSelectButton.vue: Button/input switcherFzSelectOptionsList.vue: Options list rendererFzSelectLabel.vue: Label rendererFzSelectHelpError.vue: Help/error renderertypes.ts: Internal component prop types
Key Concepts
Dual-Mode Input
The component uses a dual-mode approach when filterable is true:
- Closed state: Button displays selected option label (or placeholder)
- Open state: Input field appears for filtering
When filterable is false (default), the component behaves as a standard select dropdown with button-only interaction.
This is handled in FzSelectButton.vue using shouldShowTheInput computed property that checks filterable && isOpen.
Filtering Strategy
The component supports three filtering modes:
Custom
filterFn(async or sync): Takes precedence over default filtering- Called with
debouncedSearchValue - Can return Promise for async operations
- No race condition protection - shows last result received
- Called with
Fuse.js fuzzy search: When
filterableis true,fuzzySearchis true, and nofilterFn- Searches in
labelfield - Handles typos and partial matches
- Only active when input has value
- Can be disabled by setting
fuzzySearch: falseto use simpleincludes()search instead
- Searches in
Simple includes search: When
filterableis true,fuzzySearchis false, and nofilterFn- Uses case-insensitive
includes()for substring matching - Does not handle typos (exact substring match only)
- Only active when input has value
- Uses case-insensitive
No filtering: When
filterableis false (default) or input is empty- Shows all available options
Lazy Loading
Options are rendered in batches for performance:
- Initial load: First
optionsToShow(default 25) options - Scroll trigger: Loads next batch when user scrolls near bottom
- Buffer:
OPTIONS_BUFFER * OPTIONS_HEIGHTpixels from bottom triggers load - Selected option: Automatically loaded if beyond visible range
Keyboard Navigation with Disabled Options
The component navigates through all options (including disabled/readonly) for WCAG compliance:
selectableOptions: All selectable options (excluding labels, including disabled/readonly)focusedIndex: Points to index inselectableOptions- Navigation traverses all options but disabled/readonly options receive focus without visual indication
- Tab navigation allows traversing disabled options for accessibility
Scroll Reset on Filter Change
When internalFilteredOptions changes (due to filtering or options update):
visibleOptionsresets to first batch- Scroll position resets to top via
resetScrollPosition() - Prevents showing wrong options after filter change
Testing
Running Tests
pnpm test:unit
pnpm coverageTest Structure
- Unit tests in
src/__tests__/FzSelect.spec.ts - Test naming:
describeblocks for feature groups,itblocks for specific cases - Coverage requirement: >90% line coverage
- Use
mountfrom@vue/test-utilsfor component testing
Test Coverage
Tests cover:
- Rendering and props
- v-model binding
- Options filtering (Fuse.js and custom filterFn)
- Lazy loading
- Keyboard navigation (including disabled options)
- Focus management
- Error and help states
- Accessibility attributes
- Edge cases (empty options, undefined options, etc.)
Adding Features
Step 1: Update Types
Add new props to src/types.ts with JSDoc:
/**
* Description of the prop
* @default 'defaultValue' if applicable
*/
newProp?: stringStep 2: Implement Logic
Add computed properties, handlers, etc. in src/FzSelect.vue:
- Follow Representation-First pattern for conditional styling
- Use Information Expert pattern (pass full objects to helpers)
- Extract repeated logic to helper functions
Step 3: Update Template
Add new UI elements in template section:
- Maintain section order:
<script>→<template>→<style> - Use semantic HTML and ARIA attributes
Step 4: Add Tests
Write unit tests for new functionality:
- Test positive and negative cases
- Test edge cases (null, undefined, empty values)
- Test accessibility features
Step 5: Update Documentation
- Update MDX with usage examples
- Update README if architecture/logic changed
Build & Release
- Build:
pnpm build - Version: Follow semver
- Publish:
pnpm publish(from workspace root)
Internal Logic
Width Calculation Algorithm
The calculateContainerWidth() function in src/common.ts:
- Measures opener element's bounding rect
- Sets minWidth to max(opener width, MIN_WIDTH)
- Calculates available space on left and right
- Sets maxWidth to opener width + max(spaceLeft, spaceRight)
This ensures dropdown matches opener width while respecting viewport boundaries.
Focus Management
Focus flow when opening dropdown:
- If
filterable: Focus moves to input field (FzInputinsideFzSelectButton) - If not
filterable: Focus moves to first enabled option - If option selected: Focus moves to selected option (if enabled)
Focus flow when closing:
- Focus returns to opener button
focusedIndexresets to -1
Filtering Race Conditions
When filterFn is async and called multiple times rapidly:
- No cancellation mechanism - all calls proceed
- Last result received is shown (may overwrite earlier results)
- Developer responsibility to handle race conditions (e.g., request cancellation)
This design choice prioritizes simplicity and developer control over automatic race condition handling.
Design Decisions
Why Dual-Mode Input Instead of Always-Input?
The dual-mode approach (button when closed, input when open) provides:
- Better UX: Selected value clearly visible when not filtering
- Space efficiency: Input only appears when needed
- Familiar pattern: Matches common select implementations
Why No Race Condition Protection for filterFn?
Race condition handling is left to the developer because:
- Different use cases need different strategies (cancellation, debouncing, queuing)
- Automatic cancellation could hide bugs in developer's code
- Simpler API: Just show the last result received
Why Navigate Through All Options (Including Disabled)?
Navigating through all options (including disabled/readonly) for WCAG compliance:
- Screen readers can announce all options, including disabled ones
- Keyboard users can traverse the full list for context
- Disabled options receive focus but without visual indication
- Better accessibility: users understand the full option set
Why Representation-First Pattern for Styling?
Using switch(true) with helper functions for conditional styling:
- Explicit mapping: "when does component look like X?"
- Maintainable: Adding new visual state = adding new case
- Self-documenting: Structure answers "when does it look like X?"
- Testable: Helper functions can be tested in isolation
Dependencies
Peer Dependencies:
vue: ^3.4.13tailwindcss: ^3.4.1
Dependencies:
@fiscozen/composables: Floating panel positioning@fiscozen/action: Options list rendering@fiscozen/input: Input field component@fiscozen/icons: Icon components@fiscozen/progress: Loading indicatorfuse.js: Fuzzy search library
Dev Dependencies:
- Standard testing and build tools (vitest, vite, typescript, etc.)
Performance Considerations
- Lazy Loading: Automatic for large lists (100+ items)
- Debouncing: Configurable via
delayTime(default 500ms) - Fuse.js: Only instantiated when needed (computed property)
- Scroll Listener: Attached once, cleaned up on unmount
- Watch Optimizations: Uses
immediate: trueonly when needed
Known Limitations
- No Multi-select: Single selection only
- No Virtual Scrolling: Lazy loading loads batches, but all visible options are in DOM
- No filterFn Cancellation: Async filterFn calls are not cancelled automatically
