@smilodon/react
v1.10.0
Published
React adapter for @smilodon/core
Readme
@smilodon/react
Select component for React applications. The shared core has scoped automated Phase 4 browser coverage; complete React integration parity and manual assistive-technology certification remain unverified.
Large-list status: the public
virtualizedprop enables legacy chunked rendering for large flat lists; it does not window rows to the viewport, and all results may eventually be mounted. True viewport virtualization remains guarded by a private test seam and is not a supported production mode. The shared core passed its scoped Phase 4 automated browser matrix in Chromium, Firefox, and WebKit; this does not certify every React integration, minimum browser versions, or WCAG conformance. Manual assistive-technology assessment remains pending. See the current limitation record.
📖 Documentation
Live documentation & interactive examples: navidrezadoost.github.io/Smilodon
Framework integration (Vue 3, Nuxt 4, React, SSR, Vite): docs/FRAMEWORK-INTEGRATION.md
For comprehensive documentation covering all features, styling options, and advanced patterns:
The complete guide includes:
- ✅ Complete styling token coverage for colors, layout, chips, motion, and accessibility
- ✅ React-specific patterns (hooks, controlled components, refs)
- ✅ Complete API reference with TypeScript types
- ✅ React Hook Form integration examples
- ✅ Performance optimization with useMemo/useCallback
- ✅ Custom renderers and theme examples
- ✅ Advanced patterns (dependent selects, async loading)
- ✅ Troubleshooting and accessibility information
Features
- ✅ Simple API - Clean, intuitive props that feel natural in React
- ✅ Fully Typed - Complete TypeScript support with detailed type definitions
- ✅ Controlled & Uncontrolled - Works both ways, your choice
- ✅ Single & Multi-select - One prop to switch modes
- ✅ Searchable - Built-in filtering with customizable behavior
- ✅ Infinite Scroll - Emit host-managed load-more requests; this adapter does not own data transport, deduplication, or server pagination
- ✅ Large-list chunk rendering - Incrementally render large flat lists; not viewport windowing
- ✅ Grouped Options - Organize items into categories
- 🎨 Dark Mode - add
className="dark-mode"ordata-theme="dark"to the<Select>or any ancestor; styles are applied inside the shadow DOM via:host-context. - ✅ Accessibility behaviors - Shared-core scenarios passed in the scoped Chromium/Firefox/WebKit Phase 4 matrix; exhaustive React integration, manual AT, and WCAG conformance are not established
- ✅ Customizable - Custom renderers, styles, and behaviors
- ✅ Bundle guidance - No version-specific bundle measurement is claimed; measure the published package output in your own bundler if bundle size is a release requirement
The Quick Start examples are self-contained. Longer feature snippets below are focused examples rather than one compilable module; names explicitly described as application-provided (for example, API endpoints or fetch functions) must be supplied by the host app. The package's Complete React Guide provides broader patterns and should be read with the same public-API and limitation notes above.
In focused JSX examples, items, groups, bigItems, and similar values are component-local application data unless the snippet declares them; imports and hook state should be added in the containing component as needed.
Infinite Render Loop: Root-Cause Review (No External Dependencies)
If you see Maximum update depth exceeded, follow this checklist before shipping:
- Controlled sync effects
- Review every
useEffectthat syncsvalueinto the custom element. - Only call
setSelectedValueswhen incoming values are actually different from current selected values.
- Uncontrolled default sync
- Apply
defaultValueonce on initialization. - Do not re-apply default selection on every re-render.
- Renderer stability
customRenderer/optionRenderermay be inline.- Adapter logic must avoid re-initializing selection state just because function references changed.
- Parent computed arrays
- For multi-select, memoize computed arrays in parent code when possible:
const selectedIds = useMemo(() => items.map(i => i.id), [items]);
<Select multiple value={selectedIds} onChange={...} />Minimum Regression Test Matrix
Run and keep these scenarios green in packages/react/tests/infinite-render.spec.tsx:
- Controlled single-select + inline
customRenderer+onChangestate update. - Controlled multi-select with a new array reference every render.
- Uncontrolled mode with
defaultValueand repeated parent re-renders. - Inline DOM
optionRendererunder repeated parent renders.
Run focused tests:
npx vitest run packages/react/tests/infinite-render.spec.tsx --config packages/react/vitest.config.tsInstallation
npm install @smilodon/react @smilodon/coreor
yarn add @smilodon/react @smilodon/coreor
pnpm add @smilodon/react @smilodon/coreNext.js Integration
@smilodon/react is built for client-side React rendering and already includes 'use client'; so it can be imported from Next.js App Router client components without extra wrapper files.
App Router checklist
- Put the consuming component behind
'use client';. - Import
Selectdirectly from@smilodon/react. - Keep large
itemsarrays memoized. virtualizedenables incremental chunk rendering for large flat lists; it does not provide bounded DOM or viewport windowing.
'use client';
import { useMemo, useState } from 'react';
import { Select } from '@smilodon/react';
export default function CountrySelect() {
const [value, setValue] = useState<string | number>('');
const items = useMemo(
() => [
{ value: 'de', label: 'Germany' },
{ value: 'jp', label: 'Japan' },
{ value: 'ir', label: 'Iran' },
],
[]
);
return (
<Select
items={items}
value={value}
onChange={(next) => setValue(next as string)}
searchable
clearable
placeholder="Choose a country"
/>
);
}When using server components
- Fetch data in a server component if desired.
- Pass plain serializable arrays into a client component that renders
Select. - Do not render
Selectdirectly in a server component.
Quick Start
import { Select } from '@smilodon/react';
import { useState } from 'react';
function MyApp() {
const [value, setValue] = useState('');
return (
<Select
items={[
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
]}
value={value}
onChange={(newValue) => setValue(newValue as string)}
placeholder="Select a fruit..."
/>
);
}Clear Control (Reset Selected/Search Value)
Use the built-in clear control in the input area and style it freely:
<Select
items={items}
value={value}
onChange={(next) => setValue(next as string)}
searchable
clearable
clearSelectionOnClear
clearSearchOnClear
clearAriaLabel="Clear selected and searched values"
clearIcon="✕"
onClear={(detail) => console.log('cleared', detail)}
style={{
'--select-clear-button-bg': 'rgba(0,0,0,0.06)',
'--select-clear-button-hover-bg': 'rgba(0,0,0,0.12)',
'--select-clear-button-color': '#374151',
'--select-clear-icon-size': '14px',
} as React.CSSProperties}
/>Available parts for advanced styling: ::part(clear-button), ::part(clear-icon).
By default, an empty clearable select does not reserve clear-button space. The button and adjusted input/arrow layout appear after a value is selected or, when search clearing is enabled, after search text is entered. They disappear again when both targets are empty. This describes intended shared-runtime behavior across single/multi-select and LTR/RTL configurations; it is not exhaustive React integration certification.
For a permanently visible disabled-when-empty control, use the full core configuration:
<Select
items={items}
clearable
config={{ clearControl: { hideWhenEmpty: false } }}
/>onClear receives { clearedSelection, clearedSearch }, so controlled state can distinguish which target changed. The core manages action-area spacing automatically; custom styles should target the exposed parts and --select-clear-* tokens instead of adding permanent padding.
Examples
Basic Single Select
import { Select } from '@smilodon/react';
import { useState } from 'react';
function BasicExample() {
const [value, setValue] = useState('');
return (
<Select
items={[
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
]}
value={value}
onChange={(val) => setValue(val as string)}
placeholder="Choose an option"
/>
);
}String Array Input (Auto-converted)
import { Select } from '@smilodon/react';
import { useState } from 'react';
function StringArrayExample() {
const [values, setValues] = useState<Array<string | number>>([]);
return (
<Select
items={['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry']}
value={values}
onChange={(val) => setValues(val as Array<string | number>)}
multiple
placeholder="Select fruits..."
/>
);
}Note: String arrays are automatically converted to
SelectItemformat internally. Each string becomes{ value: string, label: string }.
Number Array Input (Auto-converted)
import { Select } from '@smilodon/react';
import { useState } from 'react';
function NumberArrayExample() {
const [value, setValue] = useState<string | number>('');
return (
<Select
items={[1, 2, 3, 5, 8, 13, 21, 34, 55, 89]}
value={value}
onChange={(val) => setValue(val)}
placeholder="Select a Fibonacci number..."
/>
);
}Note: Number arrays are automatically converted to
SelectItemformat internally. Each number becomes{ value: number, label: string }.
Multi-Select with Object Array
import { Select } from '@smilodon/react';
import { useState } from 'react';
function MultiSelectExample() {
const [values, setValues] = useState<Array<string | number>>([]);
return (
<Select
items={[
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'angular', label: 'Angular' },
{ value: 'svelte', label: 'Svelte' },
]}
value={values}
onChange={(val) => setValues(val as Array<string | number>)}
multiple
placeholder="Select frameworks..."
/>
);
}Searchable Select
import { useState } from 'react';
import { Select } from '@smilodon/react';
function SearchableExample() {
const [value, setValue] = useState('');
const countries = [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
{ value: 'mx', label: 'Mexico' },
];
return (
<Select
items={countries}
value={value}
onChange={(val) => setValue(val as string)}
searchable
placeholder="Search countries..."
/>
);
}Grouped Options
Smilodon supports both native groupedItems and a flat items array where each object includes a group property. The React wrapper will automatically convert the latter for you (added in v1.4.9) so the following two configurations behave identically:
// flat list with `group` property (auto-converted)
<Select
items={[
{ value: 'apple', label: 'Apple', group: 'Fruits' },
{ value: 'banana', label: 'Banana', group: 'Fruits' },
{ value: 'carrot', label: 'Carrot', group: 'Vegetables' },
]}
value={value}
onChange={(val) => setValue(val as string)}
placeholder="Select food..."
/>// explicit groupedItems structure
<Select
groupedItems={[
{
label: 'Fruits',
options: [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
],
},
{
label: 'Vegetables',
options: [
{ value: 'carrot', label: 'Carrot' },
{ value: 'tomato', label: 'Tomato' },
],
},
]}
value={value}
onChange={(val) => setValue(val as string)}
placeholder="Select food..."
/>Customizing the group header
You can supply a groupHeaderRenderer to render arbitrary React content for the header element. The returned element receives .group-header and part="group-header" automatically.
<Select
groupedItems={groups}
groupHeaderRenderer={(grp) => <div className="text-sm uppercase">{grp.label}</div>}
value={value}
onChange={(v) => setValue(v as string)}
/>Infinite Scroll
import { useRef, useState } from 'react';
import { Select } from '@smilodon/react';
function InfiniteScrollExample() {
const pageSize = 20;
// Page 1 is already loaded; the component's first load-more event requests page 2.
const [items, setItems] = useState(
Array.from({ length: pageSize }, (_, index) => ({
value: String(index + 1),
label: `Item ${index + 1}`,
}))
);
const [value, setValue] = useState('');
const [error, setError] = useState('');
const [loadingPages, setLoadingPages] = useState<number[]>([]);
const [hasMore, setHasMore] = useState(true);
const inFlight = useRef(new Set<number>());
const loadedPages = useRef(new Set<number>());
const handleLoadMore = async (page: number) => {
if (!hasMore || inFlight.current.has(page) || loadedPages.current.has(page)) return;
inFlight.current.add(page);
setLoadingPages((previous) => [...previous, page]);
try {
const response = await fetch(`/api/items?page=${page}`);
if (!response.ok) throw new Error(`Page request failed: ${response.status}`);
const newItems = await response.json() as Array<{ value: string; label: string }>;
setItems((previous) => [...previous, ...newItems]);
loadedPages.current.add(page);
if (newItems.length < pageSize) setHasMore(false);
setError('');
} catch (reason) {
setError(String(reason));
} finally {
inFlight.current.delete(page);
setLoadingPages((previous) => previous.filter((entry) => entry !== page));
}
};
return (
<>
<Select
items={items}
value={value}
onChange={(val) => setValue(val as string)}
infiniteScroll
pageSize={pageSize}
onLoadMore={handleLoadMore}
/>
{loadingPages.length > 0 && <p role="status">Loading more options…</p>}
{error && <p role="alert">{error}</p>}
</>
);
}The adapter calls onLoadMore(page) but does not await the returned Promise. The application owns transport, duplicate-page guards, loading/error UI, and server end-of-data detection; keep that logic in the host component even if the callback is declared async.
Large Lists (Performance Baseline)
For high-volume datasets, start with the built-in lightweight option rendering and then add custom renderers only if needed for UX.
<Select
items={bigItems}
virtualized
searchable
/>Notes:
estimatedItemHeightis an exposed prop for the guarded viewport-windowing test path; it is not a production tuning control while viewport-windowing remains private.- Complex
optionRendererDOM can add interaction cost on large lists; profile before shipping.
With Validation
import { Select } from '@smilodon/react';
import { useState } from 'react';
import type { FormEvent } from 'react';
function ValidationExample() {
const [value, setValue] = useState('');
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
const items = [
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
];
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitted(true);
const message = value ? '' : 'This field is required';
setError(message);
if (!message) console.log('Submit value:', value);
};
return (
<form onSubmit={handleSubmit} noValidate>
<Select
items={items}
value={value}
onChange={(next) => {
const selected = Array.isArray(next) ? next[0] : next;
setValue(selected == null ? '' : String(selected));
if (submitted) setError(selected == null ? 'This field is required' : '');
}}
required
error={!!error}
errorMessage={error}
placeholder="Required field"
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Submit</button>
</form>
);
}Using Ref for Imperative Actions
import { Select, type SelectHandle } from '@smilodon/react';
import { useRef, useState } from 'react';
function RefExample() {
const items = [
{ value: 'alpha', label: 'Alpha' },
{ value: 'beta', label: 'Beta' },
];
const selectRef = useRef<SelectHandle>(null);
const [value, setValue] = useState('');
const handleOpen = () => {
selectRef.current?.open();
};
const handleClear = () => {
selectRef.current?.clear();
};
return (
<div>
<Select
ref={selectRef}
items={items}
value={value}
onChange={(val) => setValue(val as string)}
/>
<button onClick={handleOpen}>Open Dropdown</button>
<button onClick={handleClear}>Clear Selection</button>
</div>
);
}🎯 Two Ways to Specify Options
Smilodon React provides two powerful approaches for defining select options, each optimized for different use cases:
Method 1: Data-Driven (Object Arrays) 📊
Use when: You have structured data and want simple, declarative option rendering.
Advantages:
- ✅ Simple and declarative - React-friendly
- ✅ Auto-conversion from strings/numbers
- ✅ Perfect for basic dropdowns
- ✅ Works seamlessly with React state
- ✅ Can render the datasets exercised in your application; no millions-of-items or constant-DOM guarantee is made
- ✅ Built-in search and filtering
- ✅ Full TypeScript type safety
Examples:
import { Select } from '@smilodon/react';
import { useState } from 'react';
// Example 1: Simple object array
function SimpleExample() {
const [value, setValue] = useState('');
const items = [
{ value: '1', label: 'Apple' },
{ value: '2', label: 'Banana' },
{ value: '3', label: 'Cherry' }
];
return (
<Select
items={items}
value={value}
onChange={(val) => setValue(val as string)}
placeholder="Select a fruit..."
/>
);
}
// Example 2: With metadata and disabled options
function MetadataExample() {
const [country, setCountry] = useState('');
const countries = [
{ value: 'us', label: 'United States', disabled: false },
{ value: 'ca', label: 'Canada', disabled: false },
{ value: 'mx', label: 'Mexico', disabled: true }
];
return (
<Select
items={countries}
value={country}
onChange={(val) => setCountry(val as string)}
placeholder="Select a country..."
/>
);
}
// Example 3: With grouping
function GroupedExample() {
const [food, setFood] = useState('');
const foods = [
{ value: 'apple', label: 'Apple', group: 'Fruits' },
{ value: 'banana', label: 'Banana', group: 'Fruits' },
{ value: 'carrot', label: 'Carrot', group: 'Vegetables' },
{ value: 'broccoli', label: 'Broccoli', group: 'Vegetables' }
];
return (
<Select
items={foods}
value={food}
onChange={(val) => setFood(val as string)}
placeholder="Select food..."
/>
);
}
// Example 4: Auto-conversion from strings
function StringArrayExample() {
const [color, setColor] = useState('');
const colors = ['Red', 'Green', 'Blue', 'Yellow'];
return (
<Select
items={colors}
value={color}
onChange={(val) => setColor(val as string)}
placeholder="Select a color..."
/>
);
}
// Example 5: Auto-conversion from numbers
function NumberArrayExample() {
const [size, setSize] = useState<number | string>('');
const sizes = [10, 20, 30, 40, 50];
return (
<Select
items={sizes}
value={size}
onChange={(val) => setSize(val as number)}
placeholder="Select size..."
/>
);
}
// Example 6: Large datasets with useMemo
function LargeDatasetExample() {
const [id, setId] = useState('');
const items = useMemo(
() =>
Array.from({ length: 100_000 }, (_, i) => ({
value: i.toString(),
label: `Item ${i + 1}`
})),
[]
);
return (
<Select
items={items}
value={id}
onChange={(val) => setId(val as string)}
virtualized // Enable incremental chunk rendering; not viewport windowing
placeholder="Select from 100K items..."
/>
);
}Method 2: Component-Driven (Custom Renderers) 🎨
Now also supports the native optionRenderer hook (Option B) that returns an HTMLElement for maximum control (e.g., non-React DOM fragments). Pass optionRenderer={(item, index, helpers) => { const el = document.createElement('div'); el.textContent = item.label; return el; }} to mirror the Web Component API.
Advanced scenarios (React)
- Accessibility labels: use a literal
aria-labelfor the internal combobox; host IDREF attributes are not forwarded across the shadow boundary:<span>Pick a user</span> <Select id="user-picker" aria-label="Pick a user" onChange={(v, items) => { console.log('Selected', items); }}/> - Server-side lookup: cancellation and request sequencing prevent an old response from overwriting newer results; the host also handles HTTP and transport errors:
import { useEffect, useRef, useState } from 'react'; import { Select, type SelectItem } from '@smilodon/react'; function RemoteSearchExample() { const [items, setItems] = useState<SelectItem[]>([]); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const requestId = useRef(0); const controller = useRef<AbortController | null>(null); useEffect(() => () => controller.current?.abort(), []); async function handleSearch(query: string) { const id = ++requestId.current; controller.current?.abort(); if (!query) { setItems([]); setError(''); setLoading(false); return; } const current = new AbortController(); controller.current = current; setLoading(true); setError(''); try { const response = await fetch(`/api/users?q=${encodeURIComponent(query)}`, { signal: current.signal }); if (!response.ok) throw new Error(`Search failed: ${response.status}`); const next = await response.json() as SelectItem[]; if (id === requestId.current) { setItems(next); setError(''); } } catch (reason) { if (!(reason instanceof DOMException && reason.name === 'AbortError') && id === requestId.current) setError(String(reason)); } finally { if (id === requestId.current) setLoading(false); } } return <><Select items={items} searchable onSearch={handleSearch} />{loading && <p role="status">Searching…</p>}{error && <p role="alert">{error}</p>}</>; } - Heavy lists (100k+):
virtualizeddoes not guarantee bounded DOM; true viewport windowing is not a public production mode:const big = useMemo(() => Array.from({ length: 100_000 }, (_, i) => ({ value: i, label: `Row ${i}` })), []); <Select items={big} virtualized />
Use when: You need rich option content composed with React. Each rendered row remains one listbox option; arbitrary nested buttons, links, or other separately interactive controls are not a supported assumption.
Advantages:
- ✅ Full control over option rendering
- ✅ Render React-node content through the adapter's custom renderer
- ✅ Rich content (images, icons, badges, multi-line text)
- ✅ Custom styling with CSS-in-JS or Tailwind
- ✅ Rich non-interactive option content; keep selection and keyboard interaction on the option itself
- ✅ Conditional rendering based on item data
- ✅ Renderer lifecycle and context boundaries are documented below
- ✅ Perfect for complex UIs (user cards, product listings, etc.)
How it works: Provide a customRenderer function that returns React.ReactNode for each option.
The adapter renders returned content in separate React roots and schedules cleanup when renderer content is replaced or the adapter unmounts. The customRenderer callback itself is not a React component and must not call hooks. A component returned by the callback may use its own hooks, but it does not inherit context providers above <Select> because its root is separate.
Examples:
import { Select, type SelectItem } from '@smilodon/react';
import { useState } from 'react';
// Example 1: Simple custom template with icons
interface Language extends SelectItem {
icon: string;
description: string;
}
function LanguageSelect() {
const [lang, setLang] = useState('');
const languages: Language[] = [
{ value: 'js', label: 'JavaScript', icon: '🟨', description: 'Dynamic scripting language' },
{ value: 'py', label: 'Python', icon: '🐍', description: 'General-purpose programming' },
{ value: 'rs', label: 'Rust', icon: '🦀', description: 'Systems programming language' }
];
return (
<Select
items={languages}
value={lang}
onChange={(val) => setLang(val as string)}
customRenderer={(item: Language, index) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 24 }}>{item.icon}</span>
<div>
<div style={{ fontWeight: 600 }}>{item.label}</div>
<div style={{ fontSize: 12, color: '#6b7280' }}>{item.description}</div>
</div>
</div>
)}
placeholder="Select a language..."
/>
);
}
// Example 2: User selection with avatars
interface User extends SelectItem {
email: string;
avatar: string;
role: 'Admin' | 'User' | 'Moderator';
}
function UserSelect() {
const [userId, setUserId] = useState('');
const users: User[] = [
{
value: '1',
label: 'John Doe',
email: '[email protected]',
avatar: 'https://i.pravatar.cc/150?img=1',
role: 'Admin'
},
{
value: '2',
label: 'Jane Smith',
email: '[email protected]',
avatar: 'https://i.pravatar.cc/150?img=2',
role: 'User'
},
{
value: '3',
label: 'Bob Johnson',
email: '[email protected]',
avatar: 'https://i.pravatar.cc/150?img=3',
role: 'Moderator'
}
];
return (
<Select
items={users}
value={userId}
onChange={(val) => setUserId(val as string)}
customRenderer={(item: User) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '4px 0' }}>
<img
src={item.avatar}
alt={item.label}
style={{
width: 40,
height: 40,
borderRadius: '50%',
objectFit: 'cover'
}}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: '#1f2937' }}>{item.label}</div>
<div style={{ fontSize: 13, color: '#6b7280' }}>{item.email}</div>
</div>
<span
style={{
padding: '4px 8px',
background: item.role === 'Admin' ? '#dbeafe' : '#f3f4f6',
color: item.role === 'Admin' ? '#1e40af' : '#374151',
borderRadius: 12,
fontSize: 11,
fontWeight: 600
}}
>
{item.role}
</span>
</div>
)}
placeholder="Select a user..."
/>
);
}
// Example 3: Product selection with images and pricing
interface Product extends SelectItem {
price: number;
stock: number;
image: string;
badge?: string;
}
function ProductSelect() {
const [productId, setProductId] = useState('');
const products: Product[] = [
{
value: 'p1',
label: 'Premium Laptop',
price: 1299.99,
stock: 15,
image: 'https://via.placeholder.com/60',
badge: 'Best Seller'
},
{
value: 'p2',
label: 'Wireless Mouse',
price: 29.99,
stock: 150,
image: 'https://via.placeholder.com/60'
},
{
value: 'p3',
label: 'Mechanical Keyboard',
price: 89.99,
stock: 0,
image: 'https://via.placeholder.com/60',
badge: 'Out of Stock'
}
];
return (
<Select
items={products}
value={productId}
onChange={(val) => setProductId(val as string)}
customRenderer={(item: Product) => (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
opacity: item.stock === 0 ? 0.5 : 1
}}
>
<img
src={item.image}
alt={item.label}
style={{
width: 60,
height: 60,
borderRadius: 8,
objectFit: 'cover',
border: '1px solid #e5e7eb'
}}
/>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600, color: '#1f2937' }}>{item.label}</span>
{item.badge && (
<span
style={{
padding: '2px 6px',
background: item.badge === 'Best Seller' ? '#dcfce7' : '#fee2e2',
color: item.badge === 'Best Seller' ? '#166534' : '#991b1b',
borderRadius: 4,
fontSize: 10,
fontWeight: 600
}}
>
{item.badge}
</span>
)}
</div>
<div
style={{
marginTop: 4,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<span style={{ fontSize: 16, fontWeight: 700, color: '#059669' }}>
${item.price.toFixed(2)}
</span>
<span style={{ fontSize: 12, color: '#6b7280' }}>
{item.stock > 0 ? `${item.stock} in stock` : 'Out of stock'}
</span>
</div>
</div>
</div>
)}
placeholder="Select a product..."
/>
);
}
// Example 4: Status indicators with conditional styling
interface Task extends SelectItem {
status: 'completed' | 'in-progress' | 'pending';
priority: 'high' | 'medium' | 'low';
assignee: string;
}
function TaskSelect() {
const [taskId, setTaskId] = useState('');
const tasks: Task[] = [
{ value: 't1', label: 'Design Homepage', status: 'completed', priority: 'high', assignee: 'John' },
{ value: 't2', label: 'API Integration', status: 'in-progress', priority: 'high', assignee: 'Jane' },
{ value: 't3', label: 'Write Documentation', status: 'pending', priority: 'medium', assignee: 'Bob' },
{ value: 't4', label: 'Bug Fixes', status: 'in-progress', priority: 'low', assignee: 'Alice' }
];
const statusConfig = {
'completed': { bg: '#dcfce7', color: '#166534', icon: '✓' },
'in-progress': { bg: '#dbeafe', color: '#1e40af', icon: '⟳' },
'pending': { bg: '#fef3c7', color: '#92400e', icon: '○' }
};
const priorityColors = {
'high': '#ef4444',
'medium': '#f59e0b',
'low': '#10b981'
};
return (
<Select
items={tasks}
value={taskId}
onChange={(val) => setTaskId(val as string)}
customRenderer={(item: Task) => {
const status = statusConfig[item.status];
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0' }}>
<div
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: status.bg,
color: status.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold'
}}
>
{status.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: '#1f2937' }}>{item.label}</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
Assigned to {item.assignee}
</div>
</div>
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: priorityColors[item.priority]
}}
title={`${item.priority} priority`}
/>
</div>
);
}}
placeholder="Select a task..."
/>
);
}
// Example 5: Using Tailwind CSS (if available)
// Note: Tailwind/CSS classes in customRenderer are supported inside Select options.
// Map dynamic values to literal class names so Tailwind's static scanner can include them.
// Core mirrors document stylesheets into shadow DOM for custom option rendering.
interface Tag extends SelectItem {
color: string;
count: number;
}
function TailwindExample() {
const [tag, setTag] = useState('');
const colorClasses: Record<string, string> = {
blue: 'bg-blue-500',
green: 'bg-green-500',
red: 'bg-red-500',
} as const;
const tags: Tag[] = [
{ value: 'react', label: 'React', color: 'blue', count: 1250 },
{ value: 'vue', label: 'Vue', color: 'green', count: 850 },
{ value: 'angular', label: 'Angular', color: 'red', count: 420 }
];
return (
<Select
items={tags}
value={tag}
onChange={(val) => setTag(val as string)}
customRenderer={(item: Tag) => (
<div className="flex items-center justify-between p-2">
<div className="flex items-center gap-2">
<span className={`w-3 h-3 rounded-full ${colorClasses[item.color]}`} />
<span className="font-semibold text-gray-900">{item.label}</span>
</div>
<span className="text-sm text-gray-500">{item.count} posts</span>
</div>
)}
placeholder="Select a tag..."
/>
);
}
// Example 6: With React components
import { Badge } from './Badge'; // Your custom component
function ComponentExample() {
const [value, setValue] = useState('');
const items = [
{ value: '1', label: 'Feature', type: 'success', count: 12 },
{ value: '2', label: 'Bug', type: 'error', count: 5 },
{ value: '3', label: 'Enhancement', type: 'info', count: 8 }
];
return (
<Select
items={items}
value={value}
onChange={(val) => setValue(val as string)}
customRenderer={(item: any) => (
<div className="flex items-center justify-between">
<span>{item.label}</span>
<Badge type={item.type}>{item.count}</Badge>
</div>
)}
/>
);
}Comparison: When to Use Each Method
| Feature | Method 1: Object Arrays | Method 2: Custom Renderers | |---------|------------------------|---------------------------| | Setup | Pass an array of option data | Provide a renderer callback | | Rendering behavior | Shared runtime renders option labels | Adapter renders React-node option content | | Customization | Data-driven labels and state | Custom non-interactive content; nested controls are not promised | | React integration | Controlled/uncontrolled values and callbacks | Same adapter API with custom-rendered content | | Component reuse | Reuse data definitions | Return components; separate-root context limits apply | | TypeScript | Typed item data and props | Typed renderer callback and props | | Very large datasets | No 1M-item certification or performance comparison is claimed | No 1M-item certification or performance comparison is claimed | | Choosing a method | Direct for simple options | Use when custom content is required |
Best Practices:
✅ Use Method 1 (Object Arrays) when:
- You need simple text-based options
- You have a large dataset and will profile the workload and use host-managed search or pagination where appropriate; no millions-of-items guarantee is made
- You want minimal code
- Built-in search/filter is sufficient
- Working with external APIs returning plain data
✅ Use Method 2 (Custom Renderers) when:
- You need images, icons, or badges
- Options require multiple lines of text
- Custom styling/layout is important
- Reusing existing React components
- Conditional rendering based on data
- Rich user experience is priority
- Using CSS-in-JS or Tailwind CSS
Combining Both Methods
You can start with Method 1 and add Method 2 later as your UI evolves:
import { Select } from '@smilodon/react';
import { useState } from 'react';
function EvolvingComponent() {
const [value, setValue] = useState('');
// Start simple
const items = ['Option 1', 'Option 2', 'Option 3'];
return (
<Select
items={items}
value={value}
onChange={(val) => setValue(val as string)}
// Later, add custom rendering without changing items
customRenderer={(item, index) => (
<div style={{ padding: 8, background: index % 2 ? '#f9fafb' : 'white' }}>
<strong>{item.label || item}</strong>
</div>
)}
/>
);
}Performance Tips
For Method 1:
- Use
useMemoto memoize large item arrays - The
virtualizedprop enables incremental chunk rendering, not viewport-windowed rendering. - Enable
infiniteScrollfor dynamic loading
For Method 2:
- Use
useCallbackto memoize renderer function - Keep renderer pure (no side effects)
- Avoid heavy computations in renderer
- Use React.memo for complex nested components
import { Select } from '@smilodon/react';
import { useState, useMemo, useCallback } from 'react';
function OptimizedExample() {
const [value, setValue] = useState('');
// Memoize items
const items = useMemo(
() =>
Array.from({ length: 10000 }, (_, i) => ({
value: i.toString(),
label: `Item ${i + 1}`,
description: `Description for item ${i + 1}`
})),
[]
);
// Memoize renderer
const renderer = useCallback((item: any, index: number) => (
<div>
<div style={{ fontWeight: 600 }}>{item.label}</div>
<div style={{ fontSize: 12, color: '#666' }}>{item.description}</div>
</div>
), []);
return (
<Select
items={items}
value={value}
onChange={(val) => setValue(val as string)}
customRenderer={renderer}
virtualized // Enable incremental chunk rendering, not viewport windowing
/>
);
}API Reference
Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| items | SelectItem[] | [] | Array of items to display |
| groupedItems | GroupedItem[] | - | Grouped items (alternative to flat array) |
| value | string \| number \| Array<string \| number> | - | Current value (controlled) |
| defaultValue | string \| number \| Array<string \| number> | - | Default value (uncontrolled) |
| multiple | boolean | false | Enable multi-select mode |
| searchable | boolean | false | Enable search/filter |
| placeholder | string | "Select an option..." | Placeholder text |
| disabled | boolean | false | Disable the select |
| required | boolean | false | Mark as required field |
| error | boolean | false | Show error state |
| errorMessage | string | - | Error message to display |
| infiniteScroll | boolean | false | Enable infinite scroll |
| pageSize | number | 20 | Items per page (infinite scroll) |
| virtualized | boolean | false | Enable legacy chunked rendering for large flat lists; not viewport windowing |
| estimatedItemHeight | number | 48 | Exposed estimate used only by the guarded viewport-windowing test path; not a production tuning control |
| maxSelections | number | - | Max selections (multi-select) |
| toggleOnTriggerClick | boolean | true | Allow repeated trigger clicks to toggle the dropdown |
| placement | string | "bottom-start" | Dropdown placement |
| className | string | - | Custom CSS class |
| style | CSSProperties | - | Inline styles |
| selectionConfig, multiSelectDisplay, scrollToSelected, styles, config | core configuration types | - | Shared runtime configuration groups |
| optionRenderer | (item, index, helpers) => HTMLElement | - | DOM renderer; return safe DOM nodes and retain the single-option interaction model |
| customRenderer | (item, index) => React.ReactNode | - | React-node option content; nested interactive controls are not promised |
| groupHeaderRenderer | (group, index) => React.ReactNode | - | React-node grouped-header content |
| clearable, clearSelectionOnClear, clearSearchOnClear, clearAriaLabel, clearIcon | booleans / strings | - | Clear-control configuration |
| onClear | (detail) => void | - | Reports which selection/search state was cleared |
| onDiagnostic | (detail) => void | - | Runtime diagnostic callback |
| trackingEnabled, trackEvents, trackStyling, trackLimitations, emitDiagnostics, trackingMaxEntries, limitationPolicies, autoMitigateRuntimeModeSwitch | documented tracking/limitation types | - | Runtime tracking and limitation-policy configuration |
| onChange | (value, items) => void | - | Selection change callback |
| onSelect | (item, index) => void | - | Item select callback |
| onOpen | () => void | - | Dropdown open callback |
| onClose | () => void | - | Dropdown close callback |
| onSearch | (query, results, count) => void | - | Search callback |
| onLoadMore | (page) => void \| Promise<void> | - | Load more callback |
| creatable | boolean | false | Allow creating new options |
| onCreate | (label) => void | - | Create option callback |
Multi-select chip styling
Selected chips in multi-select mode use softer default pill styles and remain fully customizable. Target them with ::part(chip), ::part(chip-label), ::part(chip-remove), and ::part(chip-remove-icon), or override CSS variables such as --select-badge-bg, --select-badge-border, --select-badge-color, --select-badge-shadow, --select-badge-width, --select-badge-height, --select-badge-remove-size, --select-badge-remove-icon-size, and --select-multi-input-min-width.
Group headers and option states now expose a matching styling surface as well, including --select-group-header-*, --select-option-border-radius, --select-option-active-*, and --select-option-disabled-*.
If you prefer runtime config over raw CSS variables, the shared styles config now supports badge, badgeHover, badgeActive, badgeLabel, badgeRemove, badgeRemoveHover, badgeRemoveActive, groupHeader, and activeOption. Custom chip remove icons are available through selection.removeButtonIcon.
Alignment in closed input and dropdown options
To switch alignment and inspect both the selected value in the closed control and the option labels in the dropdown, use the shared alignment tokens together:
--select-input-text-align--select-option-text-align--select-input-justify-content--select-group-header-text-align
Example:
<Select
items={items}
style={{
'--select-input-text-align': 'center',
'--select-option-text-align': 'center',
} as React.CSSProperties}
/>When testing alignment, inspect the closed selected value first, then open the dropdown and verify the option rows follow the same alignment.
Direction
The shared default direction is ltr, and you can switch a React select to rtl through the direction prop.
<Select
items={items}
direction="rtl"
/>This mirrors the shell and dropdown layout automatically, including arrow placement, clear-button placement, separator side, and selected indicator side.
Disabled / dimmed options
Items with disabled: true are dimmed and non-selectable by default.
If you want a dimmed option to remain hoverable or keyboard-focusable while still looking disabled:
<Select
items={items}
disabledOptionBehavior={{
hoverable: true,
focusable: true,
selectable: false,
}}
/>Selected indicator
To hide the selected side indicator without targeting internal pseudo-elements:
<Select
items={items}
showSelectedIndicator={false}
/>If you want to restyle the indicator instead of hiding it, set the related CSS variables through the component style prop.
Chip remove icon
You can replace the default chip remove icon with the removeButtonIcon prop:
<Select
items={items}
multiple
removeButtonIcon="−"
/>Markup strings are supported too, so inline SVG icons work.
To style the icon separately from the round remove button, use the host style prop with these tokens:
--select-badge-remove-icon-size--select-badge-remove-icon-color--select-badge-remove-icon-font-size--select-badge-remove-icon-line-height--select-badge-remove-icon-transform--select-badge-remove-icon-opacity
You can also target the icon directly with ::part(chip-remove-icon).
Core config parity and global defaults
The React adapter now exposes the same runtime configuration surface as @smilodon/core through both convenience props and the full config prop.
Convenience props for the most common core features
Use these when you want adapter-friendly props without building the full config object yourself:
selectionConfigmultiSelectDisplayscrollToSelectedstylesconfig
Example: horizontal chip scrolling with a custom remove icon style.
import { Select } from '@smilodon/react';
import { useState } from 'react';
function HorizontalChipExample() {
const [values, setValues] = useState<Array<string | number>>(['react', 'vue']);
return (
<Select
multiple
items={[
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'svelte', label: 'Svelte' },
{ value: 'solid', label: 'Solid' },
]}
value={values}
onChange={(next) => setValues(next as Array<string | number>)}
multiSelectDisplay={{
mode: 'horizontal',
maxHeight: '56px',
overflowX: 'auto',
overflowY: 'hidden',
dragScroll: true,
}}
selectionConfig={{
closeOnSelect: false,
toggleOnTriggerClick: true,
}}
styles={{
badgeRemoveIcon: {
color: '#dc2626',
transform: 'scale(1.1)',
},
}}
removeButtonIcon="−"
searchable
clearable
placeholder="Select frameworks"
/>
);
}Full core config passthrough with config
Use config when you want to pass the same shape you would send directly to enhanced-select.updateConfig().
import { Select } from '@smilodon/react';
<Select
items={items}
multiple
config={{
dropdownPlacement: { mode: 'auto' },
multiSelectDisplay: {
mode: 'vertical',
maxHeight: '120px',
overflowY: 'auto',
},
scrollToSelected: {
enabled: true,
multiSelectTarget: 'last',
behavior: 'smooth',
block: 'nearest',
},
selection: {
closeOnSelect: false,
allowDeselect: true,
disabledOptionBehavior: {
hoverable: true,
focusable: true,
},
},
styles: {
badge: {
background: '#eff6ff',
border: '1px solid #bfdbfe',
color: '#1d4ed8',
},
badgeRemoveIcon: {
color: '#1d4ed8',
},
},
}}
/>Global defaults from the adapter package
The React package now re-exports the shared global config helpers, so React apps can set runtime defaults without importing from @smilodon/core directly.
import { configureSelect, resetSelectConfig, Select } from '@smilodon/react';
configureSelect({
searchable: true,
clearControl: {
enabled: true,
clearSelection: true,
clearSearch: true,
},
selection: {
showSelectedIndicator: false,
removeButtonIcon: '×',
},
multiSelectDisplay: {
mode: 'wrap',
},
});
function App() {
return <Select items={items} placeholder="Inherited defaults" />;
}
// Later, if you need to clear the global defaults:
resetSelectConfig();Runtime control from refs
SelectHandle now includes the common core-style imperative helpers in addition to the existing diagnostics APIs.
import { useRef } from 'react';
import { Select, type SelectHandle } from '@smilodon/react';
function RuntimeControlExample() {
const ref = useRef<SelectHandle>(null);
return (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button onClick={() => ref.current?.open()}>Open</button>
<button onClick={() => ref.current?.clearSearch()}>Clear Search</button>
<button onClick={() => ref.current?.updateConfig({ selection: { showSelectedIndicator: false } })}>
Hide Indicator
</button>
<button onClick={() => ref.current?.setError('Selection is required')}>Set Error</button>
<button onClick={() => ref.current?.clearError()}>Clear Error</button>
<button onClick={() => console.log(ref.current?.validate())}>Validate</button>
</div>
<Select ref={ref} items={items} required searchable />
</>
);
}Types
SelectItem
interface SelectItem {
value: string | number;
label: string;
disabled?: boolean;
[key: string]: unknown; // Additional custom properties
}SelectHandle
interface SelectHandle {
focus: () => void;
open: () => void;
close: () => void;
getSelectedItems: () => SelectItem[];
getSelectedValues: () => Array<string | number>;
setItems: (items: SelectItem[]) => void;
setGroupedItems: (groups: GroupedItem[]) => void;
clear: () => void;
clearSearch: () => void;
updateConfig: (config: Partial<GlobalSelectConfig>) => void;
setError: (message: string) => void;
clearError: () => void;
setRequired: (required: boolean) => void;
validate: () => boolean;
getCapabilities: () => SelectCapabilitiesReport | undefined;
getKnownLimitations: () => LimitationState[];
getTrackingSnapshot: () => TrackingSnapshot;
clearTracking: (source?: 'event' | 'style' | 'limitation' | 'all') => void;
setLimitationPolicies: (policies: LimitationPolicyMap) => void;
}Styling
The component uses Shadow DOM for style encapsulation. You can customize it using CSS custom properties:
enhanced-select {
--select-border-color: #e2e8f0;
--select-border-focus-color: #3b82f6;
--select-bg: white;
--select-text-color: #1f2937;
--select-placeholder-color: #9ca3af;
--select-dropdown-bg: white;
--select-dropdown-border: #e2e8f0;
--select-dropdown-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--select-option-hover-bg: #f3f4f6;
--select-option-selected-bg: #eff6ff;
--select-option-selected-color: #3b82f6;
--select-option-selected-border: 1px solid #3b82f6;
--select-option-selected-hover-bg: #dbeafe;
--select-option-selected-hover-border: 1px solid #2563eb;
}Accessibility
Implemented keyboard and ARIA behaviors include:
- ✅ Tested keyboard paths include Arrow keys, Enter, Escape, and Tab; this is not an exhaustive interaction certification
- ✅ ARIA attributes and selected live-region announcements
- ✅ Focus management
- ✅ High contrast mode support
- ✅ Reduced motion support
- Touch-target sizing is themeable; WCAG touch-target conformance has not been certified.
- The shared core passed its scoped Phase 4 automated browser matrix in Chromium, Firefox, and WebKit. This does not establish every React integration pattern, minimum-browser-version compatibility, or WCAG conformance. Manual Windows/NVDA and macOS/VoiceOver assessments remain pending.
Important: Passing inline functions as renderers
If you define optionRenderer or customRenderer inline (like arrow function inside template/JSX), it may cause unnecessary re-creation in some frameworks.
Highly recommended: For best performance and to avoid potential issues, always use memoization:
const myRenderer = useCallback((item, index, helpers) => {
return document.createElement('div');
}, []);Adapters are designed to not loop even without memoization, but memoization still improves performance.
Performance
- Incremental chunk rendering for large flat datasets; viewport windowing is not public/production-activated
- Lazy loading with infinite scroll
- React state and renderer stability guidance; the adapter does not promise an internal
React.memowrapper - Tree-shakeable exports
- Bundle output depends on the consuming app and bundler; no version-specific size claim is made here
Browser Support
The minimum versions listed by repository policy are compatibility targets, not versions proven by the current Phase 4 matrix. The completed automated matrix uses current pinned Chromium, Firefox, and WebKit builds; consult the browser support policy for declared compatibility details.
TypeScript
Full TypeScript support with comprehensive type definitions included.
import { Select, type SelectProps, type SelectHandle, type SelectItem, type GroupedItem, type GlobalSelectConfig, type SelectCapabilitiesReport, type LimitationState, type TrackingSnapshot, type LimitationPolicyMap } from '@smilodon/react';License
MIT © Smilodon
Related Packages
@smilodon/core- Core web component@smilodon/vue- Vue 3 adapter@smilodon/svelte- Svelte adapter
