feat(ui): Combobox scroll-into-view, opt-in TagInput blur-commit, unit tests (V4 IMP-1/3, GAP-4)

@bytelyst/ui 0.2.0 -> 0.2.1

- Combobox: scroll the highlighted option into view during arrow-key nav so
  it never leaves the popover viewport (IMP-1)
- TagInput: new commitOnBlur prop (default false) — committing the buffer on
  blur surprised users clicking away to discard. BEHAVIOR CHANGE: blur no
  longer commits unless commitOnBlur is set (IMP-3)
- Add vitest + happy-dom + @testing-library/react devDeps, test script, and
  packages/ui/vitest.config.ts; 6 unit tests for Combobox + TagInput (GAP-4)
- Drop the stale 'vitest pending' ROADMAP-EXEC-TODO comments

6/6 tests pass; tsc clean.
This commit is contained in:
saravanakumardb1 2026-05-28 15:57:49 -07:00
parent a473a45aae
commit e5061350a5
6 changed files with 342 additions and 14 deletions

View File

@ -1,11 +1,13 @@
{
"name": "@bytelyst/ui",
"version": "0.2.0",
"version": "0.2.1",
"type": "module",
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"build": "tsc"
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"exports": {
".": {
@ -219,11 +221,16 @@
"@storybook/addon-essentials": "^8.5.0",
"@storybook/react": "^8.5.0",
"@storybook/react-vite": "^8.5.0",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"storybook": "^8.5.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.0.18"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import * as React from 'react';
import { Combobox, type ComboboxOption } from '../components/Combobox.js';
import { TagInput } from '../components/TagInput.js';
// happy-dom does not implement scrollIntoView — stub it so the Combobox
// highlight-into-view effect (ROADMAP V4 IMP-1) does not throw under test.
beforeEach(() => {
cleanup();
Element.prototype.scrollIntoView = vi.fn();
});
const OPTIONS: ComboboxOption[] = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
];
describe('Combobox', () => {
it('filters options by query and selects via keyboard', () => {
const onChange = vi.fn();
function Harness() {
const [v, setV] = React.useState<string | null>(null);
return (
<Combobox
options={OPTIONS}
value={v}
onChange={(next) => {
setV(next);
onChange(next);
}}
/>
);
}
render(<Harness />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'ban' } });
expect(screen.getByText('Banana')).toBeTruthy();
expect(screen.queryByText('Apple')).toBeNull();
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('banana');
});
it('scrolls the highlighted option into view on arrow nav (IMP-1)', () => {
render(<Combobox options={OPTIONS} value={null} onChange={() => {}} />);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
});
});
describe('TagInput', () => {
function Harness({ commitOnBlur }: { commitOnBlur?: boolean }) {
const [tags, setTags] = React.useState<string[]>([]);
return <TagInput value={tags} onChange={setTags} commitOnBlur={commitOnBlur} />;
}
it('commits a tag on Enter', () => {
render(<Harness />);
const input = screen.getByLabelText('Add a tag');
fireEvent.change(input, { target: { value: 'urgent' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByText('urgent')).toBeTruthy();
});
it('does NOT commit on blur by default (IMP-3)', () => {
render(<Harness />);
const input = screen.getByLabelText('Add a tag');
fireEvent.change(input, { target: { value: 'draft' } });
fireEvent.blur(input);
expect(screen.queryByText('draft')).toBeNull();
});
it('commits on blur when commitOnBlur is enabled (IMP-3)', () => {
render(<Harness commitOnBlur />);
const input = screen.getByLabelText('Add a tag');
fireEvent.change(input, { target: { value: 'keep' } });
fireEvent.blur(input);
expect(screen.getByText('keep')).toBeTruthy();
});
it('removes the last tag on Backspace with an empty buffer', () => {
render(<Harness />);
const input = screen.getByLabelText('Add a tag');
fireEvent.change(input, { target: { value: 'one' } });
fireEvent.keyDown(input, { key: 'Enter' });
fireEvent.change(input, { target: { value: 'two' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByText('two')).toBeTruthy();
fireEvent.keyDown(input, { key: 'Backspace' });
expect(screen.queryByText('two')).toBeNull();
expect(screen.getByText('one')).toBeTruthy();
});
});

View File

@ -1,5 +1,3 @@
// ROADMAP-EXEC-TODO #2 — vitest setup pending in @bytelyst/ui; add Combobox
// unit tests once happy-dom + @testing-library/react devDeps land.
import * as React from 'react';
import { clsx } from 'clsx';
import { Check, ChevronDown, X } from 'lucide-react';
@ -86,6 +84,14 @@ export function Combobox<T extends string = string>({
setHighlight(0);
}, [query, open]);
// Keep the highlighted option visible during arrow-key navigation so it
// never scrolls out of the popover viewport (ROADMAP V4 IMP-1).
React.useEffect(() => {
if (!open) return;
const el = document.getElementById(`${listId}-opt-${highlight}`);
el?.scrollIntoView({ block: 'nearest' });
}, [highlight, open, listId]);
// Click-outside close.
React.useEffect(() => {
if (!open) return;

View File

@ -1,5 +1,3 @@
// ROADMAP-EXEC-TODO #2 — vitest setup pending in @bytelyst/ui; add TagInput
// unit tests once happy-dom + @testing-library/react devDeps land.
import * as React from 'react';
import { clsx } from 'clsx';
import { X } from 'lucide-react';
@ -22,6 +20,12 @@ export interface TagInputProps {
validate?: (candidate: string, existing: string[]) => boolean;
/** Disable input + chip removal. */
disabled?: boolean;
/**
* Commit the in-progress buffer as a tag when the input loses focus.
* Default `false` committing on blur surprises users who click away
* intending to discard the buffer (ROADMAP V4 IMP-3). Opt in explicitly.
*/
commitOnBlur?: boolean;
/** Accessible label for the editor. */
ariaLabel?: string;
className?: string;
@ -50,6 +54,7 @@ export function TagInput({
normalize = DEFAULT_NORMALIZE,
validate = DEFAULT_VALIDATE,
disabled = false,
commitOnBlur = false,
ariaLabel,
className,
}: TagInputProps) {
@ -132,7 +137,7 @@ export function TagInput({
value={buffer}
onChange={(e) => setBuffer(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => buffer.length > 0 && commit()}
onBlur={() => commitOnBlur && buffer.length > 0 && commit()}
placeholder={value.length === 0 ? placeholder : ''}
disabled={disabled || atCap}
aria-label={ariaLabel ?? 'Add a tag'}

View File

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });

220
pnpm-lock.yaml generated
View File

@ -453,6 +453,33 @@ importers:
specifier: ^5.7.3
version: 5.9.3
packages/charts:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/client-encrypt:
devDependencies:
vitest:
@ -517,6 +544,33 @@ importers:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/customizable-workspace:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/dashboard-components:
devDependencies:
'@testing-library/react':
@ -571,6 +625,33 @@ importers:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/data-viz:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/datastore:
dependencies:
'@azure/cosmos':
@ -729,6 +810,33 @@ importers:
specifier: ^3.24.0
version: 3.25.76
packages/generative-theme:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/gentle-notifications:
devDependencies:
typescript:
@ -773,8 +881,89 @@ importers:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/media-ui:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/monitoring: {}
packages/motion:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/notifications-ui:
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/offline-queue: {}
packages/ollama-client:
@ -992,12 +1181,6 @@ importers:
lucide-react:
specifier: ^0.460.0
version: 0.460.0(react@19.2.4)
react:
specifier: ^18.0.0 || ^19.0.0
version: 19.2.4
react-dom:
specifier: ^18.0.0 || ^19.0.0
version: 19.2.4(react@19.2.4)
devDependencies:
'@storybook/addon-a11y':
specifier: ^8.5.0
@ -1011,12 +1194,24 @@ importers:
'@storybook/react-vite':
specifier: ^8.5.0
version: 8.6.18(@storybook/test@8.6.18(storybook@8.6.18(prettier@3.8.1)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@8.6.18(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: ^19.0.0
version: 19.2.14
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.14)
happy-dom:
specifier: ^18.0.1
version: 18.0.1
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
storybook:
specifier: ^8.5.0
version: 8.6.18(prettier@3.8.1)
@ -1026,6 +1221,9 @@ importers:
vite:
specifier: ^6.0.0
version: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/use-keyboard-shortcuts:
devDependencies:
@ -14175,6 +14373,16 @@ snapshots:
lodash: 4.17.23
redent: 3.0.0
'@testing-library/react@16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@testing-library/dom': 10.4.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6