chore(ts-migration): migrate UI primitives and design system to TypeScript
Phase 6. 57 files: 23 vendored shadcn primitives, 30 design-system components,
4 charts. Plus `src/components/ds/props.ts`, which is types only.
Renaming these alone took typecheck from 37 to 1008, and the reason is worth
recording because it is the shape of every remaining phase.
These components had NO prop contract. No PropTypes, no validation: in the
JavaScript every prop was optional and every extra prop was spread onto the
underlying element. TypeScript infers a destructured parameter WITHOUT a default
as REQUIRED, so the moment the files became `.tsx` it invented a rule the
components never had and rejected several hundred call sites that have always
worked. That is the compiler describing its own inference, not a defect it
found.
Three mechanical fixes, each restoring a contract that already existed:
- 57 JSDoc `@type {React.ForwardRefExoticComponent<any>}` annotations become
real TypeScript annotations. Those comments were the previous authors'
deliberate compatibility types; JSDoc stops applying in a `.tsx` file, so
converting them preserves an intent that was already written down.
- 61 `React.forwardRef(...)` calls gain `<any, any>`. Without generics `ref`
infers `ForwardedRef<unknown>`, which no element's `Ref<T>` accepts - so
every primitive that forwards a ref to a `div` failed on the ref, not the
props.
- 78 component signatures take `DsProps`, a documented alias for
`Record<string, any>`. It exists so the decision is recorded once and is
greppable when someone tightens it, rather than being 78 bare `any`s with
no explanation between them. The prop NAMES are not lost: every component
still destructures them by name, which is where a reader looks.
Four files needed real types rather than compatibility ones. `ds/toast` takes
react-hot-toast's own `ToastOptions`, which narrows `position` to its
`ToastPosition` union instead of widening to `string` - the widening was what
made all six calls unassignable. `ds/Pagination`'s page range is genuinely
`(number | string)[]`, because it interleaves page numbers with '…' markers that
the renderer tests for. `ds/Field` narrows `children.props` at three reads, and
`ds/Avatar` needed the ref generic.
Two of my own automated passes were wrong and were caught rather than shipped. A
props-interface generator dropped alternating props, because non-overlapping
regex matches consume the separating comma - it made things worse (83 file
errors to 146) and was reverted wholesale. A second pass missed every
multi-line signature whose defaults contain a `)`, such as `onClose = () => {}`;
that needed a brace matcher rather than a character class.
56 of 57 files emit byte-identical JavaScript. The one exception is `ds/toast`,
where a JSDoc type CAST - `/** @type {ToastPosition} */ ('bottom-center')` -
became a real annotation, so the emitted output loses a comment and a pair of
now-redundant parentheses. The value is `"bottom-center"` either way; the
minified outputs differ only in esbuild's choice of mangled local names.
Verified: tsc 37 -> 35, set-difference showing zero introduced and two removed;
zero errors remain in any Phase 6 file; npm test 1684/1691 with the same seven
failures; lint 0 errors; build succeeds with the API origin inlined; baseline
artifacts untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
173
src/components/ds/MultiSelect.tsx
Normal file
173
src/components/ds/MultiSelect.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronDown, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SearchInput } from '@/components/ds/SearchInput';
|
||||
|
||||
/**
|
||||
* MultiSelect — pick several values from a list.
|
||||
*
|
||||
* Selections render as removable chips in the trigger. Above `searchThreshold`
|
||||
* options a filter field appears, so short lists stay uncluttered and long ones
|
||||
* stay usable. Options may be strings or `{ value, label, count }`.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function MultiSelect({
|
||||
options = [],
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = 'Select...',
|
||||
/** Chips shown before collapsing into "+N more". */
|
||||
maxChips = 2,
|
||||
searchThreshold = 8,
|
||||
disabled = false,
|
||||
className = '',
|
||||
align = 'start',
|
||||
emptyMessage = 'No options',
|
||||
}: DsProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
|
||||
const normalized = React.useMemo(
|
||||
() => options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o)),
|
||||
[options]
|
||||
);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!query) return normalized;
|
||||
const q = query.toLowerCase();
|
||||
return normalized.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}, [normalized, query]);
|
||||
|
||||
const labelFor = (v) => normalized.find((o) => o.value === v)?.label ?? v;
|
||||
|
||||
const toggle = (optionValue) => {
|
||||
const next = value.includes(optionValue)
|
||||
? value.filter((v) => v !== optionValue)
|
||||
: [...value, optionValue];
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
const remove = (optionValue, event) => {
|
||||
// The chip's X sits inside the trigger — don't open the popover.
|
||||
event.stopPropagation();
|
||||
onChange?.(value.filter((v) => v !== optionValue));
|
||||
};
|
||||
|
||||
const visible = value.slice(0, maxChips);
|
||||
const overflow = value.length - visible.length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex min-h-10 w-full items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-left shadow-xs',
|
||||
'transition-all duration-base ease-out',
|
||||
'outline-none focus-visible:border-krow-blue focus-visible:ring-2 focus-visible:ring-krow-blue/20',
|
||||
'disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:opacity-60',
|
||||
open && 'border-krow-blue ring-2 ring-krow-blue/20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-1 flex-wrap items-center gap-1 min-w-0">
|
||||
{value.length === 0 && <span className="text-body-sm text-ink-4">{placeholder}</span>}
|
||||
{visible.map((v) => (
|
||||
<Badge key={v} variant="soft" size="lg" className="max-w-[160px] pr-1">
|
||||
<span className="truncate">{labelFor(v)}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${labelFor(v)}`}
|
||||
onClick={(e) => remove(v, e)}
|
||||
className="grid place-items-center w-3.5 h-3.5 rounded-full hover:bg-krow-blue/20 shrink-0"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" aria-hidden="true" />
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Badge variant="neutral" size="lg">+{overflow} more</Badge>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-ink-4 shrink-0 transition-transform duration-base',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align={align} className="w-[--radix-popover-trigger-width] min-w-52 p-0">
|
||||
{normalized.length >= searchThreshold && (
|
||||
<div className="p-2 border-b border-border">
|
||||
<SearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Filter options"
|
||||
size="sm"
|
||||
debounce={0}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-64 overflow-y-auto p-1.5">
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-body-sm text-ink-3">{emptyMessage}</p>
|
||||
)}
|
||||
{filtered.map((option) => {
|
||||
const selected = value.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => toggle(option.value)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-body-sm transition-colors duration-fast',
|
||||
'hover:bg-krow-blue-tint focus-ring-inset',
|
||||
selected ? 'text-krow-blue font-medium' : 'text-ink-2'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center w-4 h-4 rounded border shrink-0 transition-colors duration-fast',
|
||||
selected ? 'bg-krow-blue border-krow-blue text-white' : 'border-border'
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3" aria-hidden="true" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
{option.count != null && (
|
||||
<span className="text-caption text-ink-4 tabular-nums">{option.count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{value.length > 0 && (
|
||||
<div className="border-t border-border p-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange?.([])}
|
||||
className="w-full rounded-md px-2 py-1.5 text-body-sm text-ink-3 hover:bg-surface-sunken hover:text-ink-1 transition-colors focus-ring-inset"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user