@fiscozen/typeahead
v3.0.0
Published
Design System Typeahead component
Keywords
Readme
@fiscozen/typeahead
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 FzTypeahead component is built on top of FzSelect's architecture, sharing the same floating panel, lazy loading, and keyboard navigation patterns. The key difference is the dual-mode input: when closed, it displays the selected option label; when open, it becomes a filterable input field.
Key Components:
FzTypeahead.vue: Main component orchestrating state, filtering, and navigationFzTypeaheadButton.vue: Opener button that switches between button and input modesFzTypeaheadOptionsList.vue: Options list with lazy loading supportFzTypeaheadLabel.vue: Label component with required indicatorFzTypeaheadHelpError.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/FzTypeahead.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 componentsFzTypeaheadButton.vue: Button/input switcherFzTypeaheadOptionsList.vue: Options list rendererFzTypeaheadLabel.vue: Label rendererFzTypeaheadHelpError.vue: Help/error renderertypes.ts: Internal component prop types
Key Concepts
Dual-Mode Input
The component uses a dual-mode approach:
- Closed state: Button displays selected option label (or placeholder)
- Open state: Input field appears for filtering (when
filtrableis true)
This is handled in FzTypeaheadButton.vue using shouldShowTheInput computed property that checks filtrable && 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: Default when
filtrableis 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
filtrableis 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
filtrableis false 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__/FzTypeahead.test.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/FzTypeahead.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
filtrable: Focus moves to input field (FzInputinsideFzTypeaheadButton) - If not
filtrable: 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 typeahead 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:
@fiscozen/select: Type imports for deprecated props- 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
