From e5061350a5df2109afe7dd424f2c6a064c112f9c Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 28 May 2026 15:57:49 -0700 Subject: [PATCH] feat(ui): Combobox scroll-into-view, opt-in TagInput blur-commit, unit tests (V4 IMP-1/3, GAP-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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. --- packages/ui/package.json | 13 +- .../ui/src/__tests__/ui-primitives.test.tsx | 100 ++++++++ packages/ui/src/components/Combobox.tsx | 10 +- packages/ui/src/components/TagInput.tsx | 11 +- packages/ui/vitest.config.ts | 2 + pnpm-lock.yaml | 220 +++++++++++++++++- 6 files changed, 342 insertions(+), 14 deletions(-) create mode 100644 packages/ui/src/__tests__/ui-primitives.test.tsx create mode 100644 packages/ui/vitest.config.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index d9f379d3..996871aa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/__tests__/ui-primitives.test.tsx b/packages/ui/src/__tests__/ui-primitives.test.tsx new file mode 100644 index 00000000..49ed61de --- /dev/null +++ b/packages/ui/src/__tests__/ui-primitives.test.tsx @@ -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(null); + return ( + { + setV(next); + onChange(next); + }} + /> + ); + } + render(); + 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( {}} />); + 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([]); + return ; + } + + it('commits a tag on Enter', () => { + render(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/packages/ui/src/components/Combobox.tsx b/packages/ui/src/components/Combobox.tsx index 8c57a875..842071ba 100644 --- a/packages/ui/src/components/Combobox.tsx +++ b/packages/ui/src/components/Combobox.tsx @@ -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({ 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; diff --git a/packages/ui/src/components/TagInput.tsx b/packages/ui/src/components/TagInput.tsx index fd89186b..f85458d6 100644 --- a/packages/ui/src/components/TagInput.tsx +++ b/packages/ui/src/components/TagInput.tsx @@ -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'} diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts new file mode 100644 index 00000000..73b69c6f --- /dev/null +++ b/packages/ui/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a2fee91..d2dab5a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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