From f661f8ff3a3aa6f945ff5ff4bcf22d6f71750408 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Thu, 12 Nov 2026 16:25:36 +0700 Subject: [PATCH 1/5] fix(ui): use the resolved theme and fresh hook state CodeEditor and Map read the raw theme setting, so a system theme on a dark OS got the light editor. Four DataTable entry points each copied the store setup and only one synced a changed pageSize, and Autosearch's debounce called the onSearch from an earlier render. Editor and map now follow the provider's resolved theme, one shared hook builds and syncs the DataTable store, and Autosearch reads its callbacks through a ref. --- .changeset/autosearch-latest-callbacks.md | 5 + .changeset/data-table-shared-store-sync.md | 5 + .changeset/resolved-theme-editor-map.md | 5 + .../map/__tests__/map-tile-layer.test.tsx | 119 ++++++++++++++++++ .../datum-ui/src/components/base/map/index.ts | 3 + .../components/base/map/map-tile-layer.tsx | 19 +++ .../autosearch/__tests__/autosearch.test.tsx | 80 +++++++++++- .../features/autosearch/autosearch.tsx | 21 +++- .../__tests__/code-editor.test.tsx | 69 +++++++++- .../features/code-editor/code-editor.tsx | 5 +- .../__tests__/server-provider.test.tsx | 28 ++++- .../__tests__/use-data-table-client.test.ts | 42 +++++++ .../__tests__/use-data-table-server.test.ts | 17 +++ .../data-table/components/inline-content.tsx | 2 +- .../data-table/core/client-provider.tsx | 63 +++------- .../data-table/core/server-provider.tsx | 29 ++--- .../data-table/hooks/use-data-table-client.ts | 44 +++---- .../data-table/hooks/use-data-table-server.ts | 23 ++-- .../hooks/use-data-table-store-instance.ts | 48 +++++++ .../form/adapters/conform/conform-adapter.tsx | 2 +- .../form/adapters/rhf/rhf-adapter.tsx | 2 +- packages/datum-ui/src/hooks/use-latest-ref.ts | 18 +++ packages/shadcn/hooks/use-theme.ts | 51 +++++--- packages/shadcn/ui/map/layers.tsx | 5 +- 24 files changed, 563 insertions(+), 142 deletions(-) create mode 100644 .changeset/autosearch-latest-callbacks.md create mode 100644 .changeset/data-table-shared-store-sync.md create mode 100644 .changeset/resolved-theme-editor-map.md create mode 100644 packages/datum-ui/src/components/base/map/__tests__/map-tile-layer.test.tsx create mode 100644 packages/datum-ui/src/components/base/map/map-tile-layer.tsx create mode 100644 packages/datum-ui/src/components/features/data-table/hooks/use-data-table-store-instance.ts create mode 100644 packages/datum-ui/src/hooks/use-latest-ref.ts diff --git a/.changeset/autosearch-latest-callbacks.md b/.changeset/autosearch-latest-callbacks.md new file mode 100644 index 00000000..4af398ea --- /dev/null +++ b/.changeset/autosearch-latest-callbacks.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui": patch +--- + +Make `Autosearch` call the latest `onSearch` and `onValueChange` props when a pending debounce fires or a single result auto-selects, instead of the handlers from an earlier render. diff --git a/.changeset/data-table-shared-store-sync.md b/.changeset/data-table-shared-store-sync.md new file mode 100644 index 00000000..7f5b2b66 --- /dev/null +++ b/.changeset/data-table-shared-store-sync.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui": patch +--- + +Sync a changed `pageSize` (or server `limit`) prop into the DataTable store from `useDataTableClient`, `useDataTableServer` and the server provider, not only from the client provider. diff --git a/.changeset/resolved-theme-editor-map.md b/.changeset/resolved-theme-editor-map.md new file mode 100644 index 00000000..8ab1d3d4 --- /dev/null +++ b/.changeset/resolved-theme-editor-map.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui": patch +--- + +Make `CodeEditor` and `MapTileLayer` follow the `ThemeProvider` resolved theme, so a `system` theme on a dark OS gets the dark editor and an explicit light or dark choice gets the matching map tiles. `MapTileLayer` also accepts an optional `theme` prop to force light or dark tiles. diff --git a/packages/datum-ui/src/components/base/map/__tests__/map-tile-layer.test.tsx b/packages/datum-ui/src/components/base/map/__tests__/map-tile-layer.test.tsx new file mode 100644 index 00000000..6e665ba4 --- /dev/null +++ b/packages/datum-ui/src/components/base/map/__tests__/map-tile-layer.test.tsx @@ -0,0 +1,119 @@ +import { act, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ThemeProvider } from '../../../themes' +import { MapTileLayer } from '../index' + +// react-leaflet needs a real map; render the tile layer as a plain element that +// exposes the URL the layer resolved for the current theme. +vi.mock('@repo/shadcn/ui/map/lazy', () => ({ + LeafletTileLayer: ({ url }: { url: string }) =>
, + LeafletLayerGroup: () => null, + LeafletFeatureGroup: () => null, +})) +// `shared` is a .ts module the @repo/shadcn exports map does not expose, so it +// is mocked by path. +vi.mock('../../../../../../shadcn/ui/map/shared', () => ({ + _useMap: () => ({}), +})) + +const LIGHT_URL = 'https://tiles.test/light/{z}/{x}/{y}.png' +const DARK_URL = 'https://tiles.test/dark/{z}/{x}/{y}.png' + +/** Make `prefers-color-scheme: dark` report the given OS preference. */ +function stubSystemTheme(systemTheme: 'light' | 'dark') { + vi.spyOn(window, 'matchMedia').mockImplementation(query => ({ + matches: systemTheme === 'dark', + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) +} + +function tileUrl() { + return screen.getByTestId('tile-layer').getAttribute('data-url') +} + +afterEach(() => { + vi.restoreAllMocks() + localStorage.clear() + document.documentElement.className = '' + document.documentElement.removeAttribute('data-theme') + document.documentElement.style.colorScheme = '' +}) + +describe('mapTileLayer', () => { + it('uses the light tiles when the theme is light, even on a dark OS', () => { + stubSystemTheme('dark') + render( + + + , + ) + + expect(tileUrl()).toBe(LIGHT_URL) + }) + + it('uses the dark tiles when the theme is dark, even on a light OS', () => { + stubSystemTheme('light') + render( + + + , + ) + + expect(tileUrl()).toBe(DARK_URL) + }) + + it('follows a dark OS preference when the theme is system', () => { + stubSystemTheme('dark') + render( + + + , + ) + + expect(tileUrl()).toBe(DARK_URL) + }) + + it('follows a light OS preference when the theme is system', () => { + stubSystemTheme('light') + render( + + + , + ) + + expect(tileUrl()).toBe(LIGHT_URL) + }) + + it('lets an explicit theme prop win over the provider', () => { + stubSystemTheme('light') + render( + + + , + ) + + expect(tileUrl()).toBe(DARK_URL) + }) + + it('reads data-theme from when there is no provider', async () => { + stubSystemTheme('light') + document.documentElement.setAttribute('data-theme', 'dark') + render() + + expect(tileUrl()).toBe(DARK_URL) + + // The fallback keeps watching the attribute the provider would write. + await act(async () => { + document.documentElement.setAttribute('data-theme', 'light') + await Promise.resolve() + }) + + expect(tileUrl()).toBe(LIGHT_URL) + }) +}) diff --git a/packages/datum-ui/src/components/base/map/index.ts b/packages/datum-ui/src/components/base/map/index.ts index aea9d3b5..19536c7a 100644 --- a/packages/datum-ui/src/components/base/map/index.ts +++ b/packages/datum-ui/src/components/base/map/index.ts @@ -1 +1,4 @@ +// The explicit export overrides the re-exported tile layer with one wired to +// the datum ThemeProvider. +export { MapTileLayer } from './map-tile-layer' export * from '@repo/shadcn/ui/map' diff --git a/packages/datum-ui/src/components/base/map/map-tile-layer.tsx b/packages/datum-ui/src/components/base/map/map-tile-layer.tsx new file mode 100644 index 00000000..bbf24582 --- /dev/null +++ b/packages/datum-ui/src/components/base/map/map-tile-layer.tsx @@ -0,0 +1,19 @@ +'use client' + +import type { ComponentProps } from 'react' +import { MapTileLayer as BaseMapTileLayer } from '@repo/shadcn/ui/map' +import { useTheme } from '../../themes' + +/** + * Map tile layer that follows the datum `ThemeProvider`. + * + * The light or dark tiles come from the provider's resolved theme, the same + * value `CodeEditor` and `ThemedLogo` read. An explicit `theme` prop wins, and + * without a provider the layer falls back to detecting the theme from ``. + */ +export function MapTileLayer({ theme, ...props }: ComponentProps) { + const { resolvedTheme } = useTheme() + const providerTheme = resolvedTheme === 'dark' || resolvedTheme === 'light' ? resolvedTheme : undefined + + return +} diff --git a/packages/datum-ui/src/components/features/autosearch/__tests__/autosearch.test.tsx b/packages/datum-ui/src/components/features/autosearch/__tests__/autosearch.test.tsx index de74cf4b..887158d2 100644 --- a/packages/datum-ui/src/components/features/autosearch/__tests__/autosearch.test.tsx +++ b/packages/datum-ui/src/components/features/autosearch/__tests__/autosearch.test.tsx @@ -1,5 +1,5 @@ import { resetViewport, setViewport } from '@test/viewport' -import { render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, it, vi } from 'vitest' import { Autosearch } from '../autosearch' @@ -59,3 +59,81 @@ describe('autosearch — responsive', () => { expect(screen.queryByRole('heading', { name: 'Search users' })).not.toBeInTheDocument() }) }) + +describe('autosearch — latest callbacks', () => { + const DEBOUNCE_MS = 300 + const singleResult = [{ value: 'a', label: 'Alpha' }] + + afterEach(() => { + vi.useRealTimers() + }) + + it('calls the latest onSearch when the debounce fires after the prop changed', () => { + vi.useFakeTimers() + const firstOnSearch = vi.fn() + const secondOnSearch = vi.fn() + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'al' } }) + // The parent swaps its handler while the debounce is still pending. + rerender( + , + ) + act(() => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + + expect(secondOnSearch).toHaveBeenCalledWith('al') + expect(firstOnSearch).not.toHaveBeenCalled() + }) + + it('auto-selects a single result through the latest onValueChange', () => { + vi.useFakeTimers() + const firstOnValueChange = vi.fn() + const secondOnValueChange = vi.fn() + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'al' } }) + act(() => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + // The parent swaps its handler (for example after a form reset), then the + // search resolves with one result. + rerender( + , + ) + rerender( + , + ) + + expect(secondOnValueChange).toHaveBeenCalledWith('a') + expect(firstOnValueChange).not.toHaveBeenCalled() + }) + + it('does not auto-select a single disabled result', () => { + vi.useFakeTimers() + const onValueChange = vi.fn() + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'al' } }) + act(() => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + rerender( + , + ) + + expect(onValueChange).not.toHaveBeenCalled() + }) +}) diff --git a/packages/datum-ui/src/components/features/autosearch/autosearch.tsx b/packages/datum-ui/src/components/features/autosearch/autosearch.tsx index f7285117..a8d642e7 100644 --- a/packages/datum-ui/src/components/features/autosearch/autosearch.tsx +++ b/packages/datum-ui/src/components/features/autosearch/autosearch.tsx @@ -2,6 +2,7 @@ import type { AutocompleteOption } from '../autocomplete' import type { AutosearchProps } from './autosearch.types' import { AlertCircle, Loader2, X } from 'lucide-react' import * as React from 'react' +import { useLatestRef } from '../../../hooks/use-latest-ref' import { cn } from '../../../utils/cn' import { Button } from '../../base/button' import { Input } from '../../base/input' @@ -78,6 +79,12 @@ export function Autosearch({ const searchDebounceRef = React.useRef | null>(null) + // Latest consumer callbacks. The debounce timer and the auto-select effect + // run after the render that scheduled them, so they read the callbacks + // through refs: a parent that swaps a handler never gets the old one called. + const onSearchRef = useLatestRef(onSearch) + const onValueChangeRef = useLatestRef(onValueChange) + // ===== Debounced onSearch forwarding ===== // The engine's onSearchChange disables internal filtering (correct — consumer owns results). // We wrap it so the actual onSearch callback fires after the debounce delay. @@ -95,17 +102,17 @@ export function Autosearch({ setSearchExecuted(false) if (!normalized) { - onSearch?.('') + onSearchRef.current?.('') return } searchDebounceRef.current = setTimeout(() => { setSearchExecuted(true) - onSearch?.(normalized) + onSearchRef.current?.(normalized) searchDebounceRef.current = null }, searchDebounceMs) }, - [onSearch, searchDebounceMs], + [onSearchRef, searchDebounceMs], ) // Cleanup timeout on unmount @@ -138,6 +145,8 @@ export function Autosearch({ onOpenChange: setOpen, }) + const setSearchRef = useLatestRef(picker.setSearch) + // ===== Open / close logic ===== // Open only when there's a search query, no value selected, and results > 1 const hasSearch = Boolean(picker.search.trim()) @@ -167,12 +176,12 @@ export function Autosearch({ if (options.length === 1 && !options[0]!.disabled) { const option = options[0]! setPersistedOption(option) - onValueChange?.(option.value) + onValueChangeRef.current?.(option.value) // setSearch('') forwards onSearch('') and resets searchExecuted. - picker.setSearch('') + setSearchRef.current('') setOpen(false) } - }, [options, value, hasSearch, loading, searchExecuted]) // eslint-disable-line react/exhaustive-deps + }, [options, value, hasSearch, loading, searchExecuted, onValueChangeRef, setSearchRef]) // ===== Clear ===== const handleClear = React.useCallback(() => { diff --git a/packages/datum-ui/src/components/features/code-editor/__tests__/code-editor.test.tsx b/packages/datum-ui/src/components/features/code-editor/__tests__/code-editor.test.tsx index 6e5df4cc..ea176b0c 100644 --- a/packages/datum-ui/src/components/features/code-editor/__tests__/code-editor.test.tsx +++ b/packages/datum-ui/src/components/features/code-editor/__tests__/code-editor.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ThemeProvider } from '../../../themes' import { CodeEditor } from '../code-editor' @@ -50,6 +50,27 @@ function renderWithTheme(ui: React.ReactElement) { return render({ui}) } +/** Make `prefers-color-scheme: dark` report the given OS preference. */ +function stubSystemTheme(systemTheme: 'light' | 'dark') { + vi.spyOn(window, 'matchMedia').mockImplementation(query => ({ + matches: systemTheme === 'dark', + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) +} + +afterEach(() => { + vi.restoreAllMocks() + localStorage.clear() + document.documentElement.removeAttribute('data-theme') + document.documentElement.style.colorScheme = '' +}) + describe('codeEditor', () => { it('renders without crashing', () => { renderWithTheme() @@ -124,12 +145,48 @@ describe('codeEditor', () => { expect(editor).toHaveAttribute('data-language', language) }) - it('uses correct theme based on ThemeProvider', () => { - renderWithTheme() + it('uses the light Monaco theme when the theme is light', () => { + stubSystemTheme('dark') + render( + + + , + ) - const editor = screen.getByTestId('monaco-editor') - // Default theme should be 'light' (since ThemeProvider defaults to light) - expect(editor).toHaveAttribute('data-theme', 'light') + expect(screen.getByTestId('monaco-editor')).toHaveAttribute('data-theme', 'light') + }) + + it('uses the dark Monaco theme when the theme is dark', () => { + stubSystemTheme('light') + render( + + + , + ) + + expect(screen.getByTestId('monaco-editor')).toHaveAttribute('data-theme', 'vs-dark') + }) + + it('follows a dark OS preference when the theme is system', () => { + stubSystemTheme('dark') + render( + + + , + ) + + expect(screen.getByTestId('monaco-editor')).toHaveAttribute('data-theme', 'vs-dark') + }) + + it('follows a light OS preference when the theme is system', () => { + stubSystemTheme('light') + render( + + + , + ) + + expect(screen.getByTestId('monaco-editor')).toHaveAttribute('data-theme', 'light') }) it('sets custom id on hidden input', () => { diff --git a/packages/datum-ui/src/components/features/code-editor/code-editor.tsx b/packages/datum-ui/src/components/features/code-editor/code-editor.tsx index 05ad0307..54e372da 100644 --- a/packages/datum-ui/src/components/features/code-editor/code-editor.tsx +++ b/packages/datum-ui/src/components/features/code-editor/code-editor.tsx @@ -77,11 +77,12 @@ export function CodeEditor({ readOnly = false, minHeight = '200px', }: CodeEditorProps) { - const { theme } = useTheme() + const { resolvedTheme } = useTheme() const editorRef = useRef(null) const formatTimerRef = useRef | null>(null) - const monacoTheme = theme === 'dark' ? 'vs-dark' : 'light' + // resolvedTheme, not theme: a `system` theme must follow the OS preference. + const monacoTheme = resolvedTheme === 'dark' ? 'vs-dark' : 'light' const showPlaceholder = !!placeholder && value.length === 0 // Clear any pending auto-format timer on unmount so the callback can't fire diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/server-provider.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/server-provider.test.tsx index 958a3c54..428e2255 100644 --- a/packages/datum-ui/src/components/features/data-table/__tests__/server-provider.test.tsx +++ b/packages/datum-ui/src/components/features/data-table/__tests__/server-provider.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { useDataTableStore } from '../core/data-table-context' import { ServerProvider } from '../core/server-provider' -import { useDataTableLoading } from '../hooks/use-selectors' +import { useDataTableLoading, useDataTablePagination } from '../hooks/use-selectors' interface TestRow { readonly id: string @@ -26,11 +26,13 @@ function mockTransform(response: TestResponse) { function ContextReader() { const store = useDataTableStore() const { isLoading } = useDataTableLoading() + const { pageSize } = useDataTablePagination() const mode = store.getSnapshot().mode return (
{mode} {String(isLoading)} + {pageSize}
) } @@ -78,6 +80,30 @@ describe('serverProvider', () => { expect(screen.getByTestId('child')).toBeInTheDocument() }) + it('syncs limit prop updates into the store and refetches with the new limit', async () => { + const fetchSpy = (vi.fn() as any).mockResolvedValue({ items: testData }) + const { rerender } = render( + + + , + ) + expect(screen.getByTestId('page-size')).toHaveTextContent('10') + await vi.waitFor(() => { + expect(fetchSpy).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 10 })) + }) + + rerender( + + + , + ) + + await vi.waitFor(() => { + expect(screen.getByTestId('page-size')).toHaveTextContent('25') + expect(fetchSpy).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 25 })) + }) + }) + it('starts loading and fetches after hydration', async () => { const fetchSpy = (vi.fn() as any).mockResolvedValue({ items: testData }) diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-client.test.ts b/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-client.test.ts index a086833a..11106a7f 100644 --- a/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-client.test.ts +++ b/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-client.test.ts @@ -20,6 +20,48 @@ const testColumns = [ ] describe('useDataTableClient', () => { + it('syncs a pageSize prop change into the store', () => { + const { result, rerender } = renderHook( + ({ pageSize }) => useDataTableClient({ data: testData, columns: testColumns, pageSize }), + { initialProps: { pageSize: 5 } }, + ) + expect(result.current.store.getSnapshot().pageSize).toBe(5) + + rerender({ pageSize: 10 }) + + expect(result.current.store.getSnapshot().pageSize).toBe(10) + }) + + it('keeps the same store and its state across re-renders', () => { + const { result, rerender } = renderHook( + ({ pageSize }) => useDataTableClient({ data: testData, columns: testColumns, pageSize }), + { initialProps: { pageSize: 5 } }, + ) + const store = result.current.store + act(() => { + store.setSearch('pod') + }) + + rerender({ pageSize: 5 }) + + expect(result.current.store).toBe(store) + expect(result.current.store.getSnapshot().search).toBe('pod') + }) + + it('does not write the seed pageSize over adapter state when StrictMode re-runs effects', () => { + const stateAdapter = { + read: () => ({ pageSize: 50 }), + write: () => {}, + } + + const { result } = renderHook( + () => useDataTableClient({ data: testData, columns: testColumns, pageSize: 5, stateAdapter }), + { reactStrictMode: true }, + ) + + expect(result.current.store.getSnapshot().pageSize).toBe(50) + }) + it('returns initial state with defaults', () => { const { result } = renderHook(() => useDataTableClient({ data: testData, columns: testColumns }), diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-server.test.ts b/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-server.test.ts index fcd6e5c8..8a1be088 100644 --- a/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-server.test.ts +++ b/packages/datum-ui/src/components/features/data-table/__tests__/use-data-table-server.test.ts @@ -84,6 +84,23 @@ describe('useDataTableServer', () => { expect(result.current.store.getSnapshot().filters).toEqual({}) }) + it('syncs a limit prop change into the store page size', () => { + const { result, rerender } = renderHook( + ({ limit }) => useDataTableServer({ + columns: testColumns, + fetchFn: mockFetchFn, + transform: mockTransform, + limit, + }), + { initialProps: { limit: 10 } }, + ) + expect(result.current.store.getSnapshot().pageSize).toBe(10) + + rerender({ limit: 25 }) + + expect(result.current.store.getSnapshot().pageSize).toBe(25) + }) + it('manages page size', () => { const { result } = renderHook(() => useDataTableServer({ diff --git a/packages/datum-ui/src/components/features/data-table/components/inline-content.tsx b/packages/datum-ui/src/components/features/data-table/components/inline-content.tsx index 86951b23..8b304539 100644 --- a/packages/datum-ui/src/components/features/data-table/components/inline-content.tsx +++ b/packages/datum-ui/src/components/features/data-table/components/inline-content.tsx @@ -32,7 +32,7 @@ export function DataTableInlineContent({ return () => { unregisterInlineContent(id) } - // eslint-disable-next-line react/exhaustive-deps -- mount/unmount only + // eslint-disable-next-line react/exhaustive-deps -- mount/unmount only; the effect below re-registers on prop changes }, [id, registerInlineContent, unregisterInlineContent]) // Prop sync: update registration when props change (skip initial render) diff --git a/packages/datum-ui/src/components/features/data-table/core/client-provider.tsx b/packages/datum-ui/src/components/features/data-table/core/client-provider.tsx index 39dcabad..c62ed08b 100644 --- a/packages/datum-ui/src/components/features/data-table/core/client-provider.tsx +++ b/packages/datum-ui/src/components/features/data-table/core/client-provider.tsx @@ -3,12 +3,12 @@ import type { ReactTable, RowData } from '@tanstack/react-table' import type { ReactNode } from 'react' import type { UseClientTableOptions } from '../hooks/use-data-table-client' -import type { CreateStoreOptions, DataTableStore, UseDataTableClientOptions } from '../types' +import type { DataTableStore, UseDataTableClientOptions } from '../types' import type { DataTableFeatures } from './features' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useClientTable } from '../hooks/use-data-table-client' +import { useDataTableStoreInstance } from '../hooks/use-data-table-store-instance' import { DataTableRenderKeyContext, DataTableStoreContext, TableInstanceContext } from './data-table-context' -import { createDataTableStore } from './store' export type DataTableClientProviderProps = UseDataTableClientOptions & { readonly loading?: boolean @@ -59,48 +59,21 @@ export function ClientProvider(props: DataTableClientProv children, } = props - // Store created immediately — works during SSR - const store = useMemo>( - () => createDataTableStore({ - data, - mode: 'client', - isLoading: true, - defaultSort, - defaultFilters, - pageSize, - columnCount: columns.length, - searchableColumns, - searchFn, - filterFns, - getRowId, - } as CreateStoreOptions), - // eslint-disable-next-line react/exhaustive-deps - [], // intentionally empty — store created once - ) - - // Sync data changes into store (skip initial render — store already has it) - const isInitialRender = useRef(true) - useEffect(() => { - if (isInitialRender.current) { - isInitialRender.current = false - return - } - store.setData(data) - }, [data, store]) - - // Sync pageSize prop changes — store was seeded with the initial value via - // useMemo([]), but later prop updates (e.g. Storybook control changes, - // parent-controlled paging) were silently dropped. Skip the initial render - // because the store already has it. - const isPageSizeInitial = useRef(true) - useEffect(() => { - if (isPageSizeInitial.current) { - isPageSizeInitial.current = false - return - } - if (pageSize != null) - store.setPageSize(pageSize) - }, [pageSize, store]) + // Store created immediately — works during SSR. The shared hook also syncs + // later `data` and `pageSize` prop changes into it. + const store = useDataTableStoreInstance({ + data, + mode: 'client', + isLoading: true, + defaultSort, + defaultFilters, + pageSize, + columnCount: columns.length, + searchableColumns, + searchFn, + filterFns, + getRowId, + }) // Table instance — null during SSR, created after hydration const [tableReady, setTableReady] = useState(false) diff --git a/packages/datum-ui/src/components/features/data-table/core/server-provider.tsx b/packages/datum-ui/src/components/features/data-table/core/server-provider.tsx index 56be9a01..750dfada 100644 --- a/packages/datum-ui/src/components/features/data-table/core/server-provider.tsx +++ b/packages/datum-ui/src/components/features/data-table/core/server-provider.tsx @@ -5,10 +5,10 @@ import type { ReactNode } from 'react' import type { UseServerTableOptions } from '../hooks/use-data-table-server' import type { DataTableStore, UseDataTableServerOptions } from '../types' import type { DataTableFeatures } from './features' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { useServerTable } from '../hooks/use-data-table-server' +import { useDataTableStoreInstance } from '../hooks/use-data-table-store-instance' import { DataTableRenderKeyContext, DataTableStoreContext, TableInstanceContext } from './data-table-context' -import { createDataTableStore } from './store' export type DataTableServerProviderProps = UseDataTableServerOptions & { readonly className?: string @@ -55,20 +55,17 @@ export function ServerProvider(props: DataTabl children, } = props - // Store created immediately — works during SSR - const store = useMemo>( - () => createDataTableStore({ - data: [] as TData[], - mode: 'server', - isLoading: true, - defaultSort, - defaultFilters, - pageSize: limit, - columnCount: columns.length, - }), - // eslint-disable-next-line react/exhaustive-deps - [], // intentionally empty — store created once - ) + // Store created immediately — works during SSR. The shared hook also syncs + // later `limit` prop changes into the store's page size. + const store = useDataTableStoreInstance({ + data: [], + mode: 'server', + isLoading: true, + defaultSort, + defaultFilters, + pageSize: limit, + columnCount: columns.length, + }) // Table instance — null during SSR, created after hydration const [tableReady, setTableReady] = useState(false) diff --git a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts index 1d77299e..b53beb59 100644 --- a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts +++ b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-client.ts @@ -3,7 +3,6 @@ import type { ColumnDef, RowData } from '@tanstack/react-table' import type { DataTableFeatures } from '../core/features' import type { - CreateStoreOptions, DataTableStore, SelectionColumnOptions, StateAdapter, @@ -13,7 +12,7 @@ import { useTable } from '@tanstack/react-table' import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import { withSelectionColumn } from '../columns/selection-column' import { dataTableFeatures } from '../core/features' -import { createDataTableStore } from '../core/store' +import { useDataTableStoreInstance } from './use-data-table-store-instance' export type { UseDataTableClientOptions } @@ -138,34 +137,21 @@ export function useDataTableClient(options: UseDataTableC stateAdapter, } = options - // 1. Create store (once) - const store = useMemo>( - () => createDataTableStore({ - data, - mode: 'client', - defaultSort, - defaultFilters, - pageSize, - searchableColumns, - searchFn, - filterFns, - getRowId, - } as CreateStoreOptions), - // eslint-disable-next-line react/exhaustive-deps - [], // intentionally empty — store created once - ) - - // 2. Sync data changes into store (skip initial render — store already has it) - const isInitialRender = useRef(true) - useEffect(() => { - if (isInitialRender.current) { - isInitialRender.current = false - return - } - store.setData(data) - }, [data, store]) + // 1. Create the store once and keep `data` and `pageSize` synced into it + const store = useDataTableStoreInstance({ + data, + mode: 'client', + defaultSort, + defaultFilters, + pageSize, + columnCount: columns.length, + searchableColumns, + searchFn, + filterFns, + getRowId, + }) - // 3. Create table from store + // 2. Create table from store const { table } = useClientTable(store, { columns, getRowId, enableRowSelection, stateAdapter }) return { store, table } diff --git a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts index fc389086..4737381c 100644 --- a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts +++ b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-server.ts @@ -14,7 +14,7 @@ import { useTable } from '@tanstack/react-table' import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import { withSelectionColumn } from '../columns/selection-column' import { dataTableFeatures } from '../core/features' -import { createDataTableStore } from '../core/store' +import { useDataTableStoreInstance } from './use-data-table-store-instance' export type { UseDataTableServerOptions } @@ -217,18 +217,15 @@ export function useDataTableServer( stateAdapter, } = options - // 1. Create store - const store = useMemo>( - () => createDataTableStore({ - data: [] as TData[], - mode: 'server', - defaultSort, - defaultFilters, - pageSize: limit, - }), - // eslint-disable-next-line react/exhaustive-deps - [], - ) + // 1. Create the store once and keep `limit` synced into its page size + const store = useDataTableStoreInstance({ + data: [], + mode: 'server', + defaultSort, + defaultFilters, + pageSize: limit, + columnCount: columns.length, + }) // 2. Create table from store const { table } = useServerTable(store, { diff --git a/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-store-instance.ts b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-store-instance.ts new file mode 100644 index 00000000..44baaff8 --- /dev/null +++ b/packages/datum-ui/src/components/features/data-table/hooks/use-data-table-store-instance.ts @@ -0,0 +1,48 @@ +'use client' + +import type { RowData } from '@tanstack/react-table' +import type { CreateStoreOptions, DataTableStore } from '../types' +import { useEffect, useRef, useState } from 'react' +import { createDataTableStore } from '../core/store' + +/** + * Creates the DataTable store once and keeps it in step with the props that + * seeded it. Every DataTable entry point (both providers and both hooks) goes + * through here, so a prop sync added for one applies to all of them. + * + * - The store lives in `useState`, which React never discards, so table state + * survives for the lifetime of the component. + * - `data` is synced in client mode only. In server mode the fetch owns it. + * - `pageSize` is synced in both modes; the store resets to the first page. + * + * Each sync compares against the last value it applied instead of skipping the + * first effect run, so a StrictMode effect re-run does not write the seed + * values back over state hydrated from a state adapter. + */ +export function useDataTableStoreInstance( + options: CreateStoreOptions, +): DataTableStore { + const { data, mode, pageSize } = options + + // Lazy initializer: the options seed the store once, later renders ignore them. + const [store] = useState(() => createDataTableStore(options)) + + const syncedDataRef = useRef(data) + useEffect(() => { + if (mode !== 'client' || syncedDataRef.current === data) + return + syncedDataRef.current = data + store.setData(data) + }, [data, mode, store]) + + const syncedPageSizeRef = useRef(pageSize) + useEffect(() => { + if (syncedPageSizeRef.current === pageSize) + return + syncedPageSizeRef.current = pageSize + if (pageSize != null) + store.setPageSize(pageSize) + }, [pageSize, store]) + + return store +} diff --git a/packages/datum-ui/src/components/features/form/adapters/conform/conform-adapter.tsx b/packages/datum-ui/src/components/features/form/adapters/conform/conform-adapter.tsx index 906935d6..037c6f02 100644 --- a/packages/datum-ui/src/components/features/form/adapters/conform/conform-adapter.tsx +++ b/packages/datum-ui/src/components/features/form/adapters/conform/conform-adapter.tsx @@ -489,7 +489,7 @@ function useConformWatchAll(names: string[]): Record { } } return result - // eslint-disable-next-line react/exhaustive-deps + // eslint-disable-next-line react/exhaustive-deps -- `names` is a new array each render; `namesKey` tracks its content }, [allFields, namesKey]) } diff --git a/packages/datum-ui/src/components/features/form/adapters/rhf/rhf-adapter.tsx b/packages/datum-ui/src/components/features/form/adapters/rhf/rhf-adapter.tsx index e158b8c0..2bdb64e1 100644 --- a/packages/datum-ui/src/components/features/form/adapters/rhf/rhf-adapter.tsx +++ b/packages/datum-ui/src/components/features/form/adapters/rhf/rhf-adapter.tsx @@ -180,7 +180,7 @@ function useRHFWatchAllHook(names: string[]): Record { result[n] = (values as any[])[index] }) return result - // eslint-disable-next-line react/exhaustive-deps + // eslint-disable-next-line react/exhaustive-deps -- `names` is a new array each render; `namesKey` tracks its content }, [namesKey, values]) } diff --git a/packages/datum-ui/src/hooks/use-latest-ref.ts b/packages/datum-ui/src/hooks/use-latest-ref.ts new file mode 100644 index 00000000..87936154 --- /dev/null +++ b/packages/datum-ui/src/hooks/use-latest-ref.ts @@ -0,0 +1,18 @@ +import type { RefObject } from 'react' +import { useLayoutEffect, useRef } from 'react' + +/** + * Keeps a ref pointing at the latest value of a prop or callback. + * + * Read `ref.current` from effects, timers and event handlers that must call + * the current callback without listing it as a dependency. The ref is updated + * in a layout effect, so it is current before any passive effect or timer runs. + * Do not read it during render. + */ +export function useLatestRef(value: T): RefObject { + const ref = useRef(value) + useLayoutEffect(() => { + ref.current = value + }) + return ref +} diff --git a/packages/shadcn/hooks/use-theme.ts b/packages/shadcn/hooks/use-theme.ts index c42344f2..1fc76a78 100644 --- a/packages/shadcn/hooks/use-theme.ts +++ b/packages/shadcn/hooks/use-theme.ts @@ -1,39 +1,52 @@ import * as React from 'react' +type ResolvedTheme = 'light' | 'dark' + +/** Attributes a theme provider may write on . */ +const THEME_ATTRIBUTES = ['class', 'data-theme'] + +function detectTheme(): ResolvedTheme { + const root = document.documentElement + const attribute = root.getAttribute('data-theme') + if (root.classList.contains('dark') || attribute === 'dark') + return 'dark' + if (root.classList.contains('light') || attribute === 'light') + return 'light' + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + /** * Lightweight theme detection hook for shadcn components. - * Detects dark mode via the `dark` class on (Tailwind convention) - * or falls back to prefers-color-scheme media query. + * + * Pass `theme` when the caller already knows the resolved theme (for example + * from a theme provider): the hook returns it and does not watch the DOM. + * Without it the hook reads an explicit `dark`/`light` class or `data-theme` + * on , then falls back to the prefers-color-scheme media query. */ -export function useTheme(): { resolvedTheme: 'light' | 'dark' } { - const [resolvedTheme, setResolvedTheme] = React.useState<'light' | 'dark'>('light') +export function useTheme(theme?: ResolvedTheme): { resolvedTheme: ResolvedTheme } { + const [detectedTheme, setDetectedTheme] = React.useState('light') React.useEffect(() => { - const detect = (): 'light' | 'dark' => { - if (document.documentElement.classList.contains('dark')) - return 'dark' - if (window.matchMedia('(prefers-color-scheme: dark)').matches) - return 'dark' - return 'light' - } + if (theme) + return - setResolvedTheme(detect()) + const update = () => setDetectedTheme(detectTheme()) + update() - const observer = new MutationObserver(() => setResolvedTheme(detect())) + const observer = new MutationObserver(update) observer.observe(document.documentElement, { attributes: true, - attributeFilter: ['class'], + attributeFilter: THEME_ATTRIBUTES, }) const mql = window.matchMedia('(prefers-color-scheme: dark)') - const onChange = () => setResolvedTheme(detect()) - mql.addEventListener('change', onChange) + mql.addEventListener('change', update) return () => { observer.disconnect() - mql.removeEventListener('change', onChange) + mql.removeEventListener('change', update) } - }, []) + }, [theme]) - return { resolvedTheme } + return { resolvedTheme: theme ?? detectedTheme } } diff --git a/packages/shadcn/ui/map/layers.tsx b/packages/shadcn/ui/map/layers.tsx index 3b4b11a4..448efaff 100644 --- a/packages/shadcn/ui/map/layers.tsx +++ b/packages/shadcn/ui/map/layers.tsx @@ -55,11 +55,14 @@ function MapTileLayer({ attribution, darkUrl, darkAttribution, + theme, ...props }: Partial & { name?: string darkUrl?: string darkAttribution?: string + /** Resolved theme from the app's theme provider. Detected from when omitted. */ + theme?: 'light' | 'dark' ref?: Ref }) { const map = _useMap() @@ -71,7 +74,7 @@ function MapTileLayer({ const DEFAULT_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png' const DEFAULT_DARK_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png' - const { resolvedTheme } = useTheme() + const { resolvedTheme } = useTheme(theme) const resolvedUrl = resolvedTheme === 'dark' ? (darkUrl ?? url ?? DEFAULT_DARK_URL) : (url ?? DEFAULT_URL) const resolvedAttribution From 0f952d51cba6cc1bf3fb2c64ca08d68b18f66ab2 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Thu, 12 Nov 2026 15:39:36 +0700 Subject: [PATCH 2/5] fix(form): read constraints through transform pipes Three helpers unwrapped Zod schemas with different wrapper sets, and required detection looked only at the outer type, so an optional field with .transform() was reported required and constraints behind a pipe were lost. One unwrapSchema helper now walks optional, nullable, default, catch, readonly and pipe wrappers by def.type, and all three use it. Constraint extraction no longer relies on instanceof against the zod peer and also reads string min and max length. --- .../form-constraints-through-transforms.md | 5 + .../__tests__/get-field-constraints.test.ts | 84 ++++++++++++++ .../__tests__/get-schema-defaults.test.ts | 15 +++ .../form/utils/get-field-constraints.ts | 102 ++++++----------- .../form/utils/get-schema-defaults.ts | 39 +++---- .../features/form/utils/zod-helpers.ts | 105 +++++++++++++++--- 6 files changed, 243 insertions(+), 107 deletions(-) create mode 100644 .changeset/form-constraints-through-transforms.md diff --git a/.changeset/form-constraints-through-transforms.md b/.changeset/form-constraints-through-transforms.md new file mode 100644 index 00000000..5c42c2d8 --- /dev/null +++ b/.changeset/form-constraints-through-transforms.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui": patch +--- + +Stop the form adapters marking optional fields that use `.transform()`, `.catch()` or `z.preprocess()` as required, so they no longer show a required asterisk or `aria-required`. Schema defaults now read through the same wrappers, so fields wrapped in `.catch()`, `.readonly()`, `.nonoptional()`, `.prefault()` or `z.preprocess()` start from their type's empty value (or the prefault value) instead of `undefined`. diff --git a/packages/datum-ui/src/components/features/form/__tests__/get-field-constraints.test.ts b/packages/datum-ui/src/components/features/form/__tests__/get-field-constraints.test.ts index 5fa3e873..4393dfd8 100644 --- a/packages/datum-ui/src/components/features/form/__tests__/get-field-constraints.test.ts +++ b/packages/datum-ui/src/components/features/form/__tests__/get-field-constraints.test.ts @@ -132,3 +132,87 @@ describe('getFieldConstraintsSingle - array constraints', () => { expect(constraints.maxItems).toBe(5) }) }) + +describe('getFieldConstraints - wrapped fields', () => { + it('marks .optional().transform() as not required', () => { + const schema = z.object({ name: z.string().optional().transform(value => value?.trim()) }) + + expect(getFieldConstraints(schema).name?.required).toBe(false) + }) + + it('keeps .transform() on a required string required', () => { + const schema = z.object({ name: z.string().min(2).max(10).transform(value => value.trim()) }) + + expect(getFieldConstraints(schema).name?.required).toBe(true) + }) + + it('marks a nested default plus transform as not required', () => { + const schema = z.object({ + role: z.string().default('user').transform(value => value.toUpperCase()), + tags: z.array(z.string()).default([]).optional().transform(value => value ?? []), + }) + const constraints = getFieldConstraints(schema) + + expect(constraints.role?.required).toBe(false) + expect(constraints.tags?.required).toBe(false) + }) + + it('marks .nullish() and .catch() as not required', () => { + const schema = z.object({ + nickname: z.string().nullish(), + theme: z.string().catch('light'), + }) + const constraints = getFieldConstraints(schema) + + expect(constraints.nickname?.required).toBe(false) + expect(constraints.theme?.required).toBe(false) + }) + + it('reads the inner schema of z.preprocess()', () => { + const schema = z.object({ + age: z.preprocess(value => Number(value), z.number()), + score: z.preprocess(value => Number(value), z.number().optional()), + }) + const constraints = getFieldConstraints(schema) + + expect(constraints.age?.required).toBe(true) + expect(constraints.score?.required).toBe(false) + }) + + it('treats .optional().nonoptional() as required again', () => { + const schema = z.object({ name: z.string().optional().nonoptional() }) + + expect(getFieldConstraints(schema).name?.required).toBe(true) + }) +}) + +describe('getFieldConstraintsSingle - wrapped fields', () => { + it('reads string min/max through .transform() on a required string', () => { + const schema = z.string().min(2).max(10).transform(value => value.trim()) + + expect(getFieldConstraintsSingle(schema)).toEqual({ required: true, minLength: 2, maxLength: 10 }) + }) + + it('reads string min/max through .optional().transform()', () => { + const schema = z.string().min(2).max(10).optional().transform(value => value?.trim()) + + expect(getFieldConstraintsSingle(schema)).toEqual({ required: false, minLength: 2, maxLength: 10 }) + }) + + it('reads date min/max through a transform', () => { + const minDate = new Date('2024-01-01') + const maxDate = new Date('2024-12-31') + const schema = z.date().min(minDate).max(maxDate).transform(value => value.toISOString()) + + const constraints = getFieldConstraintsSingle(schema) + + expect(constraints.minDate).toEqual(minDate) + expect(constraints.maxDate).toEqual(maxDate) + }) + + it('reads array min/max through a nested default plus transform', () => { + const schema = z.array(z.string()).min(1).max(5).default(['a']).transform(value => value.join(',')) + + expect(getFieldConstraintsSingle(schema)).toEqual({ required: false, minItems: 1, maxItems: 5 }) + }) +}) diff --git a/packages/datum-ui/src/components/features/form/__tests__/get-schema-defaults.test.ts b/packages/datum-ui/src/components/features/form/__tests__/get-schema-defaults.test.ts index d28a50f9..f1150dad 100644 --- a/packages/datum-ui/src/components/features/form/__tests__/get-schema-defaults.test.ts +++ b/packages/datum-ui/src/components/features/form/__tests__/get-schema-defaults.test.ts @@ -89,4 +89,19 @@ describe('getSchemaDefaults', () => { const merged = schemaA.and(schemaB) expect(getSchemaDefaults(merged)).toEqual({ name: '', age: undefined }) }) + + it('unwraps .transform() to the input type', () => { + const schema = z.object({ + name: z.string().optional().transform(value => value?.trim()), + tags: z.array(z.string()).transform(value => value.join(',')), + }) + expect(getSchemaDefaults(schema)).toEqual({ name: '', tags: [] }) + }) + + it('uses a default nested under a transform', () => { + const schema = z.object({ + role: z.string().default('user').transform(value => value.toUpperCase()), + }) + expect(getSchemaDefaults(schema)).toEqual({ role: 'user' }) + }) }) diff --git a/packages/datum-ui/src/components/features/form/utils/get-field-constraints.ts b/packages/datum-ui/src/components/features/form/utils/get-field-constraints.ts index eb40ae96..8a932945 100644 --- a/packages/datum-ui/src/components/features/form/utils/get-field-constraints.ts +++ b/packages/datum-ui/src/components/features/form/utils/get-field-constraints.ts @@ -1,5 +1,5 @@ -import { z } from 'zod' -import { getObjectShape } from './zod-helpers' +import type { z } from 'zod' +import { getObjectShape, unwrapSchema } from './zod-helpers' export interface FieldConstraint { required: boolean @@ -30,7 +30,7 @@ export interface FieldConstraints { * Used by both Conform and RHF adapters to determine required fields. */ export function getFieldConstraints( - schema: z.ZodType, + schema: z.core.$ZodType, ): Record { const shape = getObjectShape(schema) if (!shape) @@ -40,7 +40,7 @@ export function getFieldConstraints( for (const [key, fieldSchema] of Object.entries(shape)) { constraints[key] = { - required: isRequired(fieldSchema as z.ZodType), + required: !unwrapSchema(fieldSchema).isOptional, } } @@ -48,73 +48,43 @@ export function getFieldConstraints( } /** - * Determine if a Zod field type is required. - * Optional, nullable, and default-wrapped types are not required. - */ -function isRequired(schema: z.ZodType): boolean { - const { type } = schema.def - - if (type === 'optional' || type === 'nullable' || type === 'default') { - return false - } - - return true -} - -/** - * Extract field-level constraints from a single Zod field schema. - * Used to extract constraints like min/max dates, array lengths, string patterns, etc. - * for individual form fields. + * Extract field-level constraints from a single Zod field schema: whether it + * is required, min/max dates, array lengths and string lengths. * - * NOTE: Uses Zod v4.3.6 internal API (_zod.def) for constraint extraction. - * This API may change in future Zod versions. + * Wrappers are peeled first (see `unwrapSchema`), so the constraints of + * `z.date().min(x).transform(...)` or `z.string().max(5).optional()` are found. + * + * NOTE: Reads Zod 4 check defs (`check._zod.def`). `.min()` / `.max()` are + * stored as `greater_than` / `less_than` on dates and as `min_length` / + * `max_length` on arrays and strings. */ -export function getFieldConstraintsSingle(schema: z.ZodTypeAny): FieldConstraints { - const constraints: FieldConstraints = {} - - // Unwrap optional/nullable/default schemas - let unwrapped: z.ZodTypeAny = schema - while ( - unwrapped instanceof z.ZodOptional - || unwrapped instanceof z.ZodNullable - || unwrapped instanceof z.ZodDefault - ) { - unwrapped = (unwrapped._def as any).innerType - } +export function getFieldConstraintsSingle(schema: z.core.$ZodType): FieldConstraints { + const unwrapped = unwrapSchema(schema) + const { def } = unwrapped.schema._zod + const constraints: FieldConstraints = { required: !unwrapped.isOptional } + + for (const check of def.checks ?? []) { + const checkDef = (check as z.core.$ZodChecks)._zod.def + + if (def.type === 'date') { + if (checkDef.check === 'greater_than') + constraints.minDate = new Date(checkDef.value as Date) + if (checkDef.check === 'less_than') + constraints.maxDate = new Date(checkDef.value as Date) + } - // Date constraints - // NOTE: Zod v4.3.6 stores check data in check._zod.def structure - // Checks use 'greater_than' for min() and 'less_than' for max() - if (unwrapped instanceof z.ZodDate) { - const checks = unwrapped._def.checks || [] - for (const check of checks) { - const checkDef = (check as any)._zod?.def - if (checkDef) { - if (checkDef.check === 'greater_than') { - constraints.minDate = new Date(checkDef.value) - } - if (checkDef.check === 'less_than') { - constraints.maxDate = new Date(checkDef.value) - } - } + if (def.type === 'array') { + if (checkDef.check === 'min_length') + constraints.minItems = checkDef.minimum + if (checkDef.check === 'max_length') + constraints.maxItems = checkDef.maximum } - } - // Array constraints - // NOTE: Zod v4.3.6 stores array length constraints in check._zod.def structure - // Checks use 'min_length' and 'max_length' with 'minimum' and 'maximum' properties - if (unwrapped instanceof z.ZodArray) { - const checks = unwrapped._def.checks || [] - for (const check of checks) { - const checkDef = (check as any)._zod?.def - if (checkDef) { - if (checkDef.check === 'min_length') { - constraints.minItems = checkDef.minimum - } - if (checkDef.check === 'max_length') { - constraints.maxItems = checkDef.maximum - } - } + if (def.type === 'string') { + if (checkDef.check === 'min_length') + constraints.minLength = checkDef.minimum + if (checkDef.check === 'max_length') + constraints.maxLength = checkDef.maximum } } diff --git a/packages/datum-ui/src/components/features/form/utils/get-schema-defaults.ts b/packages/datum-ui/src/components/features/form/utils/get-schema-defaults.ts index 8e8f7734..8fe37594 100644 --- a/packages/datum-ui/src/components/features/form/utils/get-schema-defaults.ts +++ b/packages/datum-ui/src/components/features/form/utils/get-schema-defaults.ts @@ -1,5 +1,5 @@ import type { z } from 'zod' -import { getObjectShape } from './zod-helpers' +import { getObjectShape, unwrapSchema } from './zod-helpers' /** * Derive default values from a Zod schema that match what React Hook Form's @@ -19,11 +19,11 @@ import { getObjectShape } from './zod-helpers' * - boolean -> `false` * - array -> `[]` * - object -> `{}` (recursive) - * - optional/nullable wrappers -> unwrap and derive inner default + * - optional/nullable/transform wrappers -> unwrap and derive inner default * - `.default()` -> use the provided default value */ export function getSchemaDefaults( - schema: z.ZodType, + schema: z.core.$ZodType, ): Record { const shape = getObjectShape(schema) if (!shape) @@ -32,7 +32,7 @@ export function getSchemaDefaults( const defaults: Record = {} for (const [key, fieldSchema] of Object.entries(shape)) { - defaults[key] = getFieldDefault(fieldSchema as z.ZodType) + defaults[key] = getFieldDefault(fieldSchema) } return defaults @@ -42,29 +42,16 @@ export function getSchemaDefaults( * Get the default value for a Zod field type that matches what RHF's * `useController` will produce when registering the field. * - * Unwraps optional/nullable/default wrappers to find the inner type, - * then returns the natural "empty" value for that type. + * Unwraps wrappers (see `unwrapSchema`) to find the inner type, then returns + * the natural "empty" value for that type. */ -function getFieldDefault(schema: z.ZodType): unknown { - const { type } = schema.def +function getFieldDefault(schema: z.core.$ZodType): unknown { + // Peel optional/nullable/default/pipe wrappers. `.default(value)` wins. + const unwrapped = unwrapSchema(schema) + if (unwrapped.hasDefault) + return unwrapped.defaultValue - // `.default(value)` — use the provided default - if (type === 'default') { - const defaultDef = schema.def as unknown as { defaultValue: unknown } - return defaultDef.defaultValue - } - - // Wrappers: unwrap to find inner type - if (type === 'optional' || type === 'nullable') { - const innerDef = schema.def as unknown as { innerType: z.ZodType } - return getFieldDefault(innerDef.innerType) - } - - // `.pipe()` (from `.transform()` in Zod v4) - if (type === 'pipe') { - const pipeDef = schema.def as unknown as { in: z.ZodType } - return getFieldDefault(pipeDef.in) - } + const { type } = unwrapped.schema._zod.def // Leaf types — match what useController produces on registration if (type === 'string') @@ -76,7 +63,7 @@ function getFieldDefault(schema: z.ZodType): unknown { if (type === 'array') return [] if (type === 'object') - return getSchemaDefaults(schema) + return getSchemaDefaults(unwrapped.schema) // Everything else (enum, literal, union, etc.) — undefined return undefined diff --git a/packages/datum-ui/src/components/features/form/utils/zod-helpers.ts b/packages/datum-ui/src/components/features/form/utils/zod-helpers.ts index 99305cd9..6f0b437a 100644 --- a/packages/datum-ui/src/components/features/form/utils/zod-helpers.ts +++ b/packages/datum-ui/src/components/features/form/utils/zod-helpers.ts @@ -1,8 +1,90 @@ import type { z } from 'zod' /** - * Extract the shape from a ZodObject, ZodIntersection, or ZodPipe. - * Uses Zod v4's `def.type` discriminant. + * Zod schema introspection for the form utilities. + * + * Everything here reads `schema._zod.def`, the introspection surface Zod 4 + * documents for library authors, and switches on `def.type`. Nothing uses + * `instanceof`, so it keeps working when the consumer's zod (a peer + * dependency, `>=4.6.6 <5`) resolves to a different copy than this package's. + */ + +type AnySchema = z.core.$ZodType + +/** + * Narrow a schema to Zod's discriminated union of schema types, so switching + * on `def.type` gives a typed def. This is the one cast the walkers need. + */ +function toKnownSchema(schema: AnySchema): z.core.$ZodTypes { + return schema as z.core.$ZodTypes +} + +export interface UnwrappedSchema { + /** The innermost schema, with every wrapper peeled off. */ + readonly schema: z.core.$ZodTypes + /** True when a wrapper lets the field be left empty (optional, nullable, default, catch). */ + readonly isOptional: boolean + /** True when a `.default()` or `.prefault()` wrapper supplies a value. */ + readonly hasDefault: boolean + /** The outermost default value. Only meaningful when `hasDefault` is true. */ + readonly defaultValue: unknown +} + +/** + * Peel the wrappers off a Zod schema and report what they said on the way in. + * + * Walks optional, nullable, default, prefault, catch, nonoptional, readonly + * and pipe wrappers. A pipe comes from `.transform()` / `.pipe()` (the input + * side holds the field's schema) or from `z.preprocess()` (the input side is a + * bare transform, so the output side holds it). + * + * The outermost wrapper decides optionality, matching how Zod parses: + * `z.string().optional().nonoptional()` is required again. + */ +export function unwrapSchema(schema: AnySchema): UnwrappedSchema { + let current = toKnownSchema(schema) + let isOptional: boolean | undefined + let hasDefault = false + let defaultValue: unknown + + while (true) { + const { def } = current._zod + + switch (def.type) { + case 'optional': + case 'nullable': + case 'catch': + isOptional ??= true + current = toKnownSchema(def.innerType) + break + case 'default': + case 'prefault': + isOptional ??= true + if (!hasDefault) { + hasDefault = true + defaultValue = def.defaultValue + } + current = toKnownSchema(def.innerType) + break + case 'nonoptional': + isOptional ??= false + current = toKnownSchema(def.innerType) + break + case 'readonly': + current = toKnownSchema(def.innerType) + break + case 'pipe': + current = toKnownSchema(def.in._zod.def.type === 'transform' ? def.out : def.in) + break + default: + return { schema: current, isOptional: isOptional ?? false, hasDefault, defaultValue } + } + } +} + +/** + * Extract the field shape from an object schema, looking through wrappers and + * intersections. * * In Zod v4: * - `.refine()` / `.superRefine()` on ZodObject return the same ZodObject (def.type = 'object') @@ -10,26 +92,19 @@ import type { z } from 'zod' * - `.and()` creates ZodIntersection (def.type = 'intersection', with def.left / def.right) */ export function getObjectShape( - schema: z.ZodType, -): Record | null { - const { def } = schema + schema: AnySchema, +): Record | null { + const { def } = unwrapSchema(schema).schema._zod if (def.type === 'object') { - return (schema as z.ZodObject).shape + return def.shape } if (def.type === 'intersection') { - const intersectionDef = def as unknown as { left: z.ZodType, right: z.ZodType } - const leftShape = getObjectShape(intersectionDef.left) - const rightShape = getObjectShape(intersectionDef.right) + const leftShape = getObjectShape(def.left) + const rightShape = getObjectShape(def.right) return { ...(leftShape ?? {}), ...(rightShape ?? {}) } } - // ZodPipe (from .transform()) — unwrap to the input schema - if (def.type === 'pipe') { - const pipeDef = def as unknown as { in: z.ZodType } - return getObjectShape(pipeDef.in) - } - return null } From 2be5b0e7dc2e44aa0cad9bf5291ccecbb899f73b Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Thu, 12 Nov 2026 15:40:49 +0700 Subject: [PATCH 3/5] fix(rich-text-editor): skip effects after the editor is destroyed The disabled, content and placeholder effects only checked the editor for null, so they called setEditable, getHTML and view.dispatch on a destroyed Tiptap instance and threw when StrictMode re-ran effects. Each effect and the onUpdate handler now return early when editor.isDestroyed is set. --- .../rich-text-editor-destroyed-guard.md | 5 ++ .../__tests__/rich-text-editor.test.tsx | 69 ++++++++++++++++++- .../rich-text-editor/rich-text-editor.tsx | 13 +++- 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 .changeset/rich-text-editor-destroyed-guard.md diff --git a/.changeset/rich-text-editor-destroyed-guard.md b/.changeset/rich-text-editor-destroyed-guard.md new file mode 100644 index 00000000..a2c5230e --- /dev/null +++ b/.changeset/rich-text-editor-destroyed-guard.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui": patch +--- + +Stop `RichTextEditor` throwing when `disabled`, `content` or `placeholder` change after its Tiptap editor was destroyed, which crashed under React StrictMode effect re-runs. diff --git a/packages/datum-ui/src/components/features/rich-text-editor/__tests__/rich-text-editor.test.tsx b/packages/datum-ui/src/components/features/rich-text-editor/__tests__/rich-text-editor.test.tsx index 3f46acde..1b44e5a0 100644 --- a/packages/datum-ui/src/components/features/rich-text-editor/__tests__/rich-text-editor.test.tsx +++ b/packages/datum-ui/src/components/features/rich-text-editor/__tests__/rich-text-editor.test.tsx @@ -1,7 +1,9 @@ -import { render } from '@testing-library/react' +import type { RichTextEditorContextValue } from '../types' +import { act, render } from '@testing-library/react' +import { StrictMode } from 'react' import { describe, expect, it } from 'vitest' import { RichTextContent } from '../rich-text-content' -import { RichTextEditor } from '../rich-text-editor' +import { RichTextEditor, useRichTextEditor } from '../rich-text-editor' describe('richTextEditor', () => { it('renders the editor shell without crashing', () => { @@ -18,6 +20,69 @@ describe('richTextEditor', () => { }) }) +describe('richTextEditor — after the editor is destroyed', () => { + type Editor = NonNullable + + /** Renders the editor and hands back the first Tiptap instance it created. */ + function renderEditor(props: { disabled?: boolean, content?: string, placeholder?: string }) { + let firstEditor: Editor | null = null + function Probe() { + const { editor } = useRichTextEditor() + firstEditor ??= editor + return null + } + const ui = (next: typeof props) => ( + + + + + + + + + ) + const view = render(ui(props)) + if (!firstEditor) + throw new Error('editor was not created') + return { ...view, editor: firstEditor as Editor, rerenderWith: (next: typeof props) => view.rerender(ui(next)) } + } + + it('does not throw when disabled changes', () => { + const { editor, rerenderWith } = renderEditor({ disabled: false }) + act(() => editor.destroy()) + + expect(() => rerenderWith({ disabled: true })).not.toThrow() + }) + + it('does not throw when content changes', () => { + const { editor, rerenderWith } = renderEditor({ content: '

First

' }) + act(() => editor.destroy()) + + expect(() => rerenderWith({ content: '

Second

' })).not.toThrow() + }) + + it('does not throw when placeholder changes', () => { + const { editor, rerenderWith } = renderEditor({ placeholder: 'Write a note' }) + act(() => editor.destroy()) + + expect(() => rerenderWith({ placeholder: 'Schreib eine Notiz' })).not.toThrow() + }) + + it('mounts and updates under StrictMode effect re-runs', () => { + const ui = (content: string, disabled: boolean) => ( + + + + + + ) + const { rerender, container } = render(ui('

First

', true)) + + expect(() => rerender(ui('

Second

', false))).not.toThrow() + expect(container.innerHTML).toContain('Second') + }) +}) + describe('richTextContent', () => { it('renders read-only HTML content', () => { const { container } = render() diff --git a/packages/datum-ui/src/components/features/rich-text-editor/rich-text-editor.tsx b/packages/datum-ui/src/components/features/rich-text-editor/rich-text-editor.tsx index 8467f6c0..53c98fb0 100644 --- a/packages/datum-ui/src/components/features/rich-text-editor/rich-text-editor.tsx +++ b/packages/datum-ui/src/components/features/rich-text-editor/rich-text-editor.tsx @@ -66,6 +66,9 @@ function RichTextEditorRoot({ editable: !disabled, autofocus: autoFocus ? 'end' : false, onUpdate: ({ editor: ed }) => { + // A queued update can land after teardown; a destroyed editor has no document to read. + if (ed.isDestroyed) + return // Normalize empty editor state — Tiptap returns

when empty, // which breaks string validation. Return empty string if no text content. const text = ed.getText().trim() @@ -86,10 +89,14 @@ function RichTextEditorRoot({ }, }) + // The effects below skip a destroyed editor. Tiptap destroys the instance on + // unmount and when StrictMode re-runs effects, and a destroyed editor throws + // from setEditable, getHTML and view.dispatch. + // Honor `disabled` changes after mount — the initial `editable` value would // otherwise be the only one that ever takes effect. useEffect(() => { - if (!editor) + if (!editor || editor.isDestroyed) return if (editor.isEditable === disabled) editor.setEditable(!disabled) @@ -99,7 +106,7 @@ function RichTextEditorRoot({ // controlled parent echoing onChange back into `content` doesn't reset the // selection on every keystroke, and empty-vs-`

` doesn't loop. useEffect(() => { - if (!editor) + if (!editor || editor.isDestroyed) return const next = content ?? '' const isNextEmpty = next === '' || next === '

' @@ -109,7 +116,7 @@ function RichTextEditorRoot({ // Honor `placeholder` changes after mount (e.g. i18n locale switch). useEffect(() => { - if (!editor) + if (!editor || editor.isDestroyed) return const placeholderExtension = editor.extensionManager.extensions.find( ext => ext.name === 'placeholder', From 778598e8cc86d676e1569165c3f30a0748f260e0 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Thu, 12 Nov 2026 16:25:41 +0700 Subject: [PATCH 4/5] chore: remove dead code and use the shared clipboard hook Finished refactors left a legacy DataTable context, internal shims and barrels, a committed v0.2 build artefact under test/, and comments that point at another repo or at tickets nobody can open. Typography and the assistant copy action also wrote to the clipboard by hand, threw on insecure origins and gave no feedback. Delete what nothing imports, deprecate the public DataTableContextValue type, and route both copy actions through useCopyToClipboard. --- .changeset/copy-through-clipboard-hook.md | 5 + .../deprecate-data-table-context-value.md | 5 + .../typography/__tests__/typography.test.tsx | 99 ++++++++++++++++++- .../components/base/typography/typography.tsx | 63 +++++++++--- .../assistant/__tests__/message-copy.test.tsx | 66 +++++++++++++ .../components/message/assistant-message.tsx | 16 +-- .../calendar-date-picker.tsx | 2 +- .../features/code-editor/lib/editor.ts | 2 +- .../__tests__/data-table-context.test.tsx | 18 +--- .../data-table/core/data-table-context.tsx | 29 +----- .../features/data-table/hooks/index.ts | 12 --- .../components/features/data-table/types.ts | 6 ++ .../features/date-time-picker/utils/index.ts | 2 - .../date-time-picker/utils/timezone.ts | 2 +- .../components/features/form/context/index.ts | 17 ---- .../features/grid/components/row.tsx | 3 +- .../features/grid/utils/responsive.ts | 2 +- .../__tests__/use-controllable-state.test.ts | 2 +- .../features/grouped-table/grouped-table.tsx | 2 +- .../lib/use-controllable-state.ts | 9 -- .../features/picker/components/calendar.tsx | 2 +- .../features/picker/components/trigger.tsx | 2 +- .../picker/hooks/use-keyboard-shortcuts.ts | 2 +- .../features/picker/hooks/use-picker-state.ts | 6 +- .../internal/use-deprecation-warning.ts | 4 - .../features/picker/presets/defaults.ts | 2 +- .../features/picker/utils/timezone.ts | 5 - .../features/tag-input/tag-input-field.tsx | 2 +- .../features/tag-input/tag-input.tsx | 2 +- .../features/tag-input/use-tags-input.ts | 4 +- .../components/absolute-range-panel.tsx | 15 ++- .../time-range-picker/components/index.ts | 5 - .../features/time-range-picker/index.ts | 2 - .../features/time-range-picker/presets.ts | 3 +- .../features/time-range-picker/types.ts | 2 - .../time-range-picker/utils/format-display.ts | 1 - .../features/time-range-picker/utils/index.ts | 2 - .../time-range-picker/utils/timezone.ts | 4 +- .../time-range-picker/utils/to-api-format.ts | 1 - .../features/transfer/components/index.ts | 8 -- packages/datum-ui/src/hooks/index.ts | 2 +- .../src/hooks/use-copy-to-clipboard.ts | 5 +- packages/datum-ui/src/hooks/use-theme.ts | 1 - packages/datum-ui/test/utils.d.ts | 9 -- packages/datum-ui/test/utils.d.ts.map | 1 - 45 files changed, 265 insertions(+), 189 deletions(-) create mode 100644 .changeset/copy-through-clipboard-hook.md create mode 100644 .changeset/deprecate-data-table-context-value.md create mode 100644 packages/datum-ui/src/components/features/assistant/__tests__/message-copy.test.tsx delete mode 100644 packages/datum-ui/src/components/features/data-table/hooks/index.ts delete mode 100644 packages/datum-ui/src/components/features/date-time-picker/utils/index.ts delete mode 100644 packages/datum-ui/src/components/features/form/context/index.ts delete mode 100644 packages/datum-ui/src/components/features/grouped-table/lib/use-controllable-state.ts delete mode 100644 packages/datum-ui/src/components/features/picker/internal/use-deprecation-warning.ts delete mode 100644 packages/datum-ui/src/components/features/time-range-picker/components/index.ts delete mode 100644 packages/datum-ui/src/components/features/transfer/components/index.ts delete mode 100644 packages/datum-ui/src/hooks/use-theme.ts delete mode 100644 packages/datum-ui/test/utils.d.ts delete mode 100644 packages/datum-ui/test/utils.d.ts.map diff --git a/.changeset/copy-through-clipboard-hook.md b/.changeset/copy-through-clipboard-hook.md new file mode 100644 index 00000000..153e3b8b --- /dev/null +++ b/.changeset/copy-through-clipboard-hook.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui": patch +--- + +`Text copyable` and the assistant message copy action now show a check for two seconds after a successful copy, no longer throw when the browser has no clipboard (insecure origins), and `Text copyable` also copies the text of nested element children. diff --git a/.changeset/deprecate-data-table-context-value.md b/.changeset/deprecate-data-table-context-value.md new file mode 100644 index 00000000..6c8a70df --- /dev/null +++ b/.changeset/deprecate-data-table-context-value.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui": patch +--- + +Mark the `DataTableContextValue` type as deprecated, because nothing provides that value any more; read table state through the `useDataTable*` selector hooks instead. diff --git a/packages/datum-ui/src/components/base/typography/__tests__/typography.test.tsx b/packages/datum-ui/src/components/base/typography/__tests__/typography.test.tsx index 6123d3f4..5e639739 100644 --- a/packages/datum-ui/src/components/base/typography/__tests__/typography.test.tsx +++ b/packages/datum-ui/src/components/base/typography/__tests__/typography.test.tsx @@ -1,6 +1,7 @@ /// -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { COPY_RESET_DELAY_MS } from '../../../../hooks/use-copy-to-clipboard' import { Paragraph, Text, Title } from '../typography' // The type scale's contract: every `size` renders the `text-*` utility of the @@ -164,3 +165,97 @@ describe('title', () => { expect(el).toHaveClass('text-xl') }) }) + +describe('text copyable', () => { + function stubClipboard(value: unknown) { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value }) + } + + /** Clicks the button and lets the hook's async clipboard write settle. */ + async function clickCopy() { + fireEvent.click(screen.getByRole('button', { name: 'Copy text' })) + await act(async () => {}) + } + + afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(navigator, 'clipboard') + }) + + it('renders no copy button unless copyable is set', () => { + render(plain) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('copies the text and shows the copied state until the reset delay passes', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + stubClipboard({ writeText }) + render(api-key-123) + + await clickCopy() + + expect(writeText).toHaveBeenCalledWith('api-key-123') + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Copied') + + act(() => { + vi.advanceTimersByTime(COPY_RESET_DELAY_MS) + }) + + expect(screen.getByRole('button', { name: 'Copy text' })).toBeInTheDocument() + }) + + it('copies the text of nested element children', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + stubClipboard({ writeText }) + render( + + Project + {' '} + + datum- + {42} + + , + ) + + await clickCopy() + + expect(writeText).toHaveBeenCalledWith('Project datum-42') + }) + + it('renders no copy button when the children have no plain text to copy', () => { + function Label() { + return from a component + } + render( + + , + ) + + expect(screen.getByText('from a component')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('does not throw or show the copied state when the clipboard is unavailable', async () => { + stubClipboard(undefined) + render(secret) + + await clickCopy() + + expect(screen.getByRole('button', { name: 'Copy text' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Copied' })).not.toBeInTheDocument() + }) + + it('keeps the copy state when the clipboard write is rejected', async () => { + stubClipboard({ writeText: vi.fn().mockRejectedValue(new Error('denied')) }) + render(secret) + + await clickCopy() + + expect(screen.getByRole('button', { name: 'Copy text' })).toBeInTheDocument() + }) +}) diff --git a/packages/datum-ui/src/components/base/typography/typography.tsx b/packages/datum-ui/src/components/base/typography/typography.tsx index a958032e..1c31c036 100644 --- a/packages/datum-ui/src/components/base/typography/typography.tsx +++ b/packages/datum-ui/src/components/base/typography/typography.tsx @@ -1,7 +1,8 @@ import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' -import { CopyIcon } from 'lucide-react' +import { CheckIcon, CopyIcon } from 'lucide-react' import * as React from 'react' +import { useCopyToClipboard } from '../../../hooks/use-copy-to-clipboard' import { cn } from '../../../utils/cn' // --------------------------------------------------------------------------- @@ -189,6 +190,48 @@ interface TextProps code?: boolean } +/** Plain text of a React node: strings and numbers, joined through nested elements. */ +function getNodeText(node: React.ReactNode): string { + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + if (Array.isArray(node)) { + return node.map(getNodeText).join('') + } + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + return getNodeText(node.props.children) + } + return '' +} + +/** + * The `copyable` button. It lives in its own component so `Text` only pays + * for the clipboard hook when `copyable` is set. + */ +function CopyTextButton({ text }: { text: string }) { + const [isCopied, copy] = useCopyToClipboard() + const label = isCopied ? 'Copied' : 'Copy text' + const StateIcon = isCopied ? CheckIcon : CopyIcon + + return ( + <> + + {/* Announces the copy to screen readers; the button's own label change is not announced. */} + {isCopied ? 'Copied' : ''} + + ) +} + function Text({ className, size, @@ -221,24 +264,12 @@ function Text({ ? 'mark' : type + // Only plain text can be copied; a component child has no text to read here. + const copyText = copyable ? getNodeText(children) : '' const content = ( <> {children} - {copyable && ( - - )} + {copyText && } ) diff --git a/packages/datum-ui/src/components/features/assistant/__tests__/message-copy.test.tsx b/packages/datum-ui/src/components/features/assistant/__tests__/message-copy.test.tsx new file mode 100644 index 00000000..d27dd94f --- /dev/null +++ b/packages/datum-ui/src/components/features/assistant/__tests__/message-copy.test.tsx @@ -0,0 +1,66 @@ +/// +import type { UIMessage } from 'ai' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { COPY_RESET_DELAY_MS } from '../../../../hooks/use-copy-to-clipboard' +import { AssistantMessage } from '../components/message' + +const message: UIMessage = { + id: 'm1', + role: 'assistant', + parts: [{ type: 'text', text: 'Hello from the assistant' }], +} + +function stubClipboard(value: unknown) { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value }) +} + +function copyIcon(): HTMLElement { + const icon = screen.getByRole('button', { name: 'Copy message' }).querySelector('[data-slot="copy-state-icon"]') + if (!icon) { + throw new Error('copy state icon not rendered') + } + return icon +} + +/** Clicks the copy action and lets the hook's async clipboard write settle. */ +async function clickCopy() { + fireEvent.click(screen.getByRole('button', { name: 'Copy message' })) + await act(async () => {}) +} + +describe('assistant message copy action', () => { + afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(navigator, 'clipboard') + }) + + it('copies the message text and shows the copied icon until the reset delay passes', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + stubClipboard({ writeText }) + render() + + expect(copyIcon()).toHaveAttribute('data-copied', 'false') + + await clickCopy() + + expect(writeText).toHaveBeenCalledWith('Hello from the assistant') + expect(copyIcon()).toHaveAttribute('data-copied', 'true') + + act(() => { + vi.advanceTimersByTime(COPY_RESET_DELAY_MS) + }) + + expect(copyIcon()).toHaveAttribute('data-copied', 'false') + }) + + it('leaves the copy icon unchanged when the clipboard is unavailable', async () => { + stubClipboard(undefined) + render() + + await clickCopy() + + expect(copyIcon()).toHaveAttribute('data-copied', 'false') + }) +}) diff --git a/packages/datum-ui/src/components/features/assistant/components/message/assistant-message.tsx b/packages/datum-ui/src/components/features/assistant/components/message/assistant-message.tsx index 66d69acf..02fb1395 100644 --- a/packages/datum-ui/src/components/features/assistant/components/message/assistant-message.tsx +++ b/packages/datum-ui/src/components/features/assistant/components/message/assistant-message.tsx @@ -17,8 +17,9 @@ import { } from '@datum-cloud/datum-ui-assistant-core' import { code } from '@streamdown/code' import { Brain, Download } from 'lucide-react' -import { Fragment, useState } from 'react' +import { Fragment } from 'react' import { Streamdown } from 'streamdown' +import { useCopyToClipboard } from '../../../../../hooks/use-copy-to-clipboard' import { cn } from '../../../../../utils/cn' import { Tooltip } from '../../../../base/tooltip' import { Icon } from '../../../../icons/icon-wrapper' @@ -240,17 +241,10 @@ function AssistantAvatar() { } function MessageActions({ msg }: { msg: UIMessage }) { - const [copied, setCopied] = useState(false) + const [copied, copy] = useCopyToClipboard() - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(messageText(msg)) - setCopied(true) - setTimeout(setCopied, 1500, false) - } - catch { - // clipboard unavailable - } + const handleCopy = () => { + void copy(messageText(msg)) } const handleDownload = () => { diff --git a/packages/datum-ui/src/components/features/calendar-date-picker/calendar-date-picker.tsx b/packages/datum-ui/src/components/features/calendar-date-picker/calendar-date-picker.tsx index 3b0513d5..b2cbb04f 100644 --- a/packages/datum-ui/src/components/features/calendar-date-picker/calendar-date-picker.tsx +++ b/packages/datum-ui/src/components/features/calendar-date-picker/calendar-date-picker.tsx @@ -32,7 +32,7 @@ export function CalendarDatePicker({ // A from-only value (valid per CalendarDatePickerProps, e.g. single-date // usage with numberOfMonths={1}) is rendered as a same-day range so the - // selected date is shown instead of the empty placeholder (BUG-140). + // selected date is shown instead of the empty placeholder. const value = date?.from ? { from: date.from, to: date.to ?? date.from } : null diff --git a/packages/datum-ui/src/components/features/code-editor/lib/editor.ts b/packages/datum-ui/src/components/features/code-editor/lib/editor.ts index 6aeac50e..181676ac 100644 --- a/packages/datum-ui/src/components/features/code-editor/lib/editor.ts +++ b/packages/datum-ui/src/components/features/code-editor/lib/editor.ts @@ -8,7 +8,7 @@ import { CORE_SCHEMA, dump, FAILSAFE_SCHEMA, floatCoreTag, intCoreTag, load } fr * formatting normalization (`1.0`, `0755`, `1e3`) are detected by comparing the * source token against the number's canonical `String()` form. Genuine numbers * (`8080`, `3.14`, `-5`) still resolve to native JS numbers, preserving the - * original BUG-008 fix where `port: 8080` must stay a number. + * rule that `port: 8080` must stay a number. */ function preserveLosslessNumbers( baseTag: ScalarTagDefinition, diff --git a/packages/datum-ui/src/components/features/data-table/__tests__/data-table-context.test.tsx b/packages/datum-ui/src/components/features/data-table/__tests__/data-table-context.test.tsx index f5f18940..c112b89d 100644 --- a/packages/datum-ui/src/components/features/data-table/__tests__/data-table-context.test.tsx +++ b/packages/datum-ui/src/components/features/data-table/__tests__/data-table-context.test.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { TableInstanceContext, useDataTableContext, useDataTableStore, useTableInstance, useTableInstanceOrNull } from '../core/data-table-context' +import { TableInstanceContext, useDataTableStore, useTableInstanceOrNull } from '../core/data-table-context' describe('useDataTableStore', () => { it('throws when used outside provider', () => { @@ -12,14 +12,6 @@ describe('useDataTableStore', () => { }) }) -describe('useTableInstance', () => { - it('throws when used outside provider', () => { - expect(() => { - renderHook(() => useTableInstance()) - }).toThrow('useTableInstance: table instance not yet available') - }) -}) - describe('useTableInstanceOrNull', () => { it('returns null when TableInstanceContext has no value', () => { const wrapper = ({ children }: { readonly children: ReactNode }) => ( @@ -42,11 +34,3 @@ describe('useTableInstanceOrNull', () => { expect(result.current).toBe(mockTable) }) }) - -describe('useDataTableContext (legacy)', () => { - it('throws when used outside provider', () => { - expect(() => { - renderHook(() => useDataTableContext()) - }).toThrow('useDataTableContext must be used within a DataTable provider') - }) -}) diff --git a/packages/datum-ui/src/components/features/data-table/core/data-table-context.tsx b/packages/datum-ui/src/components/features/data-table/core/data-table-context.tsx index 018db553..a06376c0 100644 --- a/packages/datum-ui/src/components/features/data-table/core/data-table-context.tsx +++ b/packages/datum-ui/src/components/features/data-table/core/data-table-context.tsx @@ -1,7 +1,7 @@ 'use client' import type { ReactTable, RowData } from '@tanstack/react-table' -import type { DataTableContextValue, DataTableStore } from '../types' +import type { DataTableStore } from '../types' import type { DataTableFeatures } from './features' import { createContext, use, useContext } from 'react' @@ -27,18 +27,6 @@ export function useRenderKey(): number { return useContext(DataTableRenderKeyContext) } -// Legacy context — retained for backward compat until providers are migrated (Task 4) -export const DataTableContext = createContext | null>(null) - -/** @deprecated Use selector hooks from hooks/use-selectors instead */ -export function useDataTableContext(): DataTableContextValue { - const context = use(DataTableContext) - if (!context) { - throw new Error('useDataTableContext must be used within a DataTable provider') - } - return context as DataTableContextValue -} - export function useDataTableStore(): DataTableStore { const store = use(DataTableStoreContext) if (!store) { @@ -47,21 +35,6 @@ export function useDataTableStore(): DataTableStore } -/** - * @deprecated Use `useTableInstanceOrNull` instead — this throws during the - * SSR/hydration window when the table instance is not yet mounted. - */ -export function useTableInstance(): ReactTable { - const table = use(TableInstanceContext) - if (!table) { - throw new Error( - 'useTableInstance: table instance not yet available. ' - + 'The table mounts after hydration. Use useTableInstanceOrNull to handle the loading window.', - ) - } - return table as ReactTable -} - /** * Returns the table instance or null if not yet available. * Used by hooks that need to handle the null-table window during SSR. diff --git a/packages/datum-ui/src/components/features/data-table/hooks/index.ts b/packages/datum-ui/src/components/features/data-table/hooks/index.ts deleted file mode 100644 index 205ee5b8..00000000 --- a/packages/datum-ui/src/components/features/data-table/hooks/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { useDataTableClient } from './use-data-table-client' -export { useDataTableServer } from './use-data-table-server' -export { - useDataTableFilters, - useDataTableInlineContents, - useDataTableLoading, - useDataTablePagination, - useDataTableRows, - useDataTableSearch, - useDataTableSelection, - useDataTableSorting, -} from './use-selectors' diff --git a/packages/datum-ui/src/components/features/data-table/types.ts b/packages/datum-ui/src/components/features/data-table/types.ts index be4aa7c8..0ce8cd30 100644 --- a/packages/datum-ui/src/components/features/data-table/types.ts +++ b/packages/datum-ui/src/components/features/data-table/types.ts @@ -154,6 +154,12 @@ export interface SelectionColumnOptions { readonly table: ReactTable readonly mode: 'client' | 'server' diff --git a/packages/datum-ui/src/components/features/date-time-picker/utils/index.ts b/packages/datum-ui/src/components/features/date-time-picker/utils/index.ts deleted file mode 100644 index fc98f0cb..00000000 --- a/packages/datum-ui/src/components/features/date-time-picker/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './format' -export * from './timezone' diff --git a/packages/datum-ui/src/components/features/date-time-picker/utils/timezone.ts b/packages/datum-ui/src/components/features/date-time-picker/utils/timezone.ts index 867c96b9..d27e188a 100644 --- a/packages/datum-ui/src/components/features/date-time-picker/utils/timezone.ts +++ b/packages/datum-ui/src/components/features/date-time-picker/utils/timezone.ts @@ -1,6 +1,6 @@ import { formatInTimeZone, fromZonedTime, toZonedTime } from 'date-fns-tz' -// Consolidated onto the canonical picker timezone module (STRUCT-011). +// Consolidated onto the canonical picker timezone module. // `getBrowserTimezone` is re-exported for the deprecated barrel; the // date+time conversion helpers below are unique to this shim. export { getBrowserTimezone } from '../../picker/utils/timezone' diff --git a/packages/datum-ui/src/components/features/form/context/index.ts b/packages/datum-ui/src/components/features/form/context/index.ts deleted file mode 100644 index a77bcc11..00000000 --- a/packages/datum-ui/src/components/features/form/context/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - FieldContext, - FieldProvider, - useFieldContext, - useOptionalFieldContext, -} from './field-context' - -export { - FormContext, - FormProvider, - useFormContext, - useFormField, - useFormSubmitting, -} from './form-context' - -// Note: Stepper context is now internal to form-stepper.tsx -// Use the useStepper hook from hooks/use-stepper.ts instead diff --git a/packages/datum-ui/src/components/features/grid/components/row.tsx b/packages/datum-ui/src/components/features/grid/components/row.tsx index a48afdb2..29902e4b 100644 --- a/packages/datum-ui/src/components/features/grid/components/row.tsx +++ b/packages/datum-ui/src/components/features/grid/components/row.tsx @@ -31,8 +31,7 @@ const Row: React.FC = ({ // Register viewport tracking whenever the gutter contains a responsive // breakpoint object — for the object form AND the array form (e.g. // `[{ md: 16 }, 8]`). Previously the array form bailed here, so its screens - // never updated and getGutter always resolved to the largest breakpoint - // (BUG-084). + // never updated and getGutter always resolved to the largest breakpoint. if (!isResponsiveGutter(gutter)) { return } diff --git a/packages/datum-ui/src/components/features/grid/utils/responsive.ts b/packages/datum-ui/src/components/features/grid/utils/responsive.ts index 5c8362d0..fd8f0a15 100644 --- a/packages/datum-ui/src/components/features/grid/utils/responsive.ts +++ b/packages/datum-ui/src/components/features/grid/utils/responsive.ts @@ -42,7 +42,7 @@ export function registerMediaQuery(query: string, callback: MediaQueryCallback): * form (`[{ md: 16 }, 8]`). Row uses this to decide whether to register media * queries; without it, array-form responsive gutters never track the viewport * and `getGutter` resolves them against the all-true initial `screens`, i.e. the - * largest breakpoint (BUG-084). + * largest breakpoint. */ export function isResponsiveGutter(gutter: Gutter | [Gutter, Gutter]): boolean { const parts = Array.isArray(gutter) ? gutter : [gutter] diff --git a/packages/datum-ui/src/components/features/grouped-table/__tests__/use-controllable-state.test.ts b/packages/datum-ui/src/components/features/grouped-table/__tests__/use-controllable-state.test.ts index d8e39085..f98bf2db 100644 --- a/packages/datum-ui/src/components/features/grouped-table/__tests__/use-controllable-state.test.ts +++ b/packages/datum-ui/src/components/features/grouped-table/__tests__/use-controllable-state.test.ts @@ -1,6 +1,6 @@ import { act, renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { useControllableState } from '../lib/use-controllable-state' +import { useControllableState } from '../../../base/hooks' describe('useControllableState', () => { it('uncontrolled: owns state and notifies', () => { diff --git a/packages/datum-ui/src/components/features/grouped-table/grouped-table.tsx b/packages/datum-ui/src/components/features/grouped-table/grouped-table.tsx index 6500fe8c..9fba9ac9 100644 --- a/packages/datum-ui/src/components/features/grouped-table/grouped-table.tsx +++ b/packages/datum-ui/src/components/features/grouped-table/grouped-table.tsx @@ -8,6 +8,7 @@ import { useReducedMotion } from 'motion/react' import { useMemo } from 'react' import { cn } from '../../../utils/cn' import { Collapsible, CollapsibleTrigger } from '../../base/collapsible' +import { useControllableState } from '../../base/hooks' import { TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../base/table' import { Icon } from '../../icons/icon-wrapper' import { rowMatchesSearch } from '../data-table' @@ -18,7 +19,6 @@ import { GroupedToolbar } from './components/grouped-toolbar' import { bucketRows } from './lib/bucket-rows' import { composeColumns } from './lib/compose-columns' import { sortRows } from './lib/sort-rows' -import { useControllableState } from './lib/use-controllable-state' import { useGroupedExpansion } from './use-grouped-expansion' /** Floor width for unsized (flex) columns so they keep their share instead of collapsing on narrow viewports. */ diff --git a/packages/datum-ui/src/components/features/grouped-table/lib/use-controllable-state.ts b/packages/datum-ui/src/components/features/grouped-table/lib/use-controllable-state.ts deleted file mode 100644 index 428016e7..00000000 --- a/packages/datum-ui/src/components/features/grouped-table/lib/use-controllable-state.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Re-export of the shared controllable-state hook. - * - * The implementation now lives in `base/hooks` so every value-bearing - * component can consume the same `(value?, defaultValue, onValueChange)` - * contract. This module is kept for backward compatibility. - */ -export type { Updater } from '../../../base/hooks' -export { useControllableState } from '../../../base/hooks' diff --git a/packages/datum-ui/src/components/features/picker/components/calendar.tsx b/packages/datum-ui/src/components/features/picker/components/calendar.tsx index db9b1f9d..572f8c36 100644 --- a/packages/datum-ui/src/components/features/picker/components/calendar.tsx +++ b/packages/datum-ui/src/components/features/picker/components/calendar.tsx @@ -16,7 +16,7 @@ const CALENDAR_RESPONSIVE_CLASSNAMES = { root: 'w-full md:w-fit' } * react-day-picker hands back the clicked day cell at midnight, discarding any * time-of-day the user already chose via the time input. Re-apply the previous * wall-clock time (read from the pending ISO in the display timezone) onto the - * newly clicked day so selecting a different date keeps the time (BUG-033). + * newly clicked day so selecting a different date keeps the time. */ function mergeTimeFromIso(day: Date, prevIso: unknown, timezone: string): Date { const merged = new Date(day) diff --git a/packages/datum-ui/src/components/features/picker/components/trigger.tsx b/packages/datum-ui/src/components/features/picker/components/trigger.tsx index b117a46c..e27f4f7d 100644 --- a/packages/datum-ui/src/components/features/picker/components/trigger.tsx +++ b/packages/datum-ui/src/components/features/picker/components/trigger.tsx @@ -51,7 +51,7 @@ export function PickerTrigger({ // Only label with the placeholder when empty. When a value is selected, // omit aria-label so the accessible name falls through to the rendered // value text — otherwise the placeholder masks the selection for screen - // readers (BUG-091). + // readers. aria-label={hasValue ? undefined : placeholder} aria-expanded={state.open} disabled={disabled} diff --git a/packages/datum-ui/src/components/features/picker/hooks/use-keyboard-shortcuts.ts b/packages/datum-ui/src/components/features/picker/hooks/use-keyboard-shortcuts.ts index b334b457..2852f365 100644 --- a/packages/datum-ui/src/components/features/picker/hooks/use-keyboard-shortcuts.ts +++ b/packages/datum-ui/src/components/features/picker/hooks/use-keyboard-shortcuts.ts @@ -36,7 +36,7 @@ function isEditableTarget(target: EventTarget | null): boolean { * The listener is attached to `document` rather than the picker root, because * the popover content is portaled to `document.body` and is therefore outside * the root's DOM subtree — a root-scoped listener never sees those keydowns - * once focus moves into the popover (BUG-078). + * once focus moves into the popover. * * To keep the legacy footgun fixed (presets firing while the user types in an * input), keydowns originating from editable controls are ignored. Modifier diff --git a/packages/datum-ui/src/components/features/picker/hooks/use-picker-state.ts b/packages/datum-ui/src/components/features/picker/hooks/use-picker-state.ts index b5fbf279..8e99e3cb 100644 --- a/packages/datum-ui/src/components/features/picker/hooks/use-picker-state.ts +++ b/packages/datum-ui/src/components/features/picker/hooks/use-picker-state.ts @@ -209,11 +209,11 @@ export function usePickerState({ // Sync prop → pending when the value changes externally, but: // - ignore a new object identity carrying identical content (inline // `{ from, to }` props re-created every render) — content compare, not - // identity (BUG-081); + // identity; // - while the popover is open, don't clobber in-progress *uncommitted* edits // (pending diverged from the last synced value) — that would wipe the // user's half-picked range / preset highlight on any unrelated parent - // re-render (BUG-072). A genuine external value change with no pending + // re-render. A genuine external value change with no pending // edits still syncs (e.g. controlled value swapped programmatically). const lastSyncedValueRef = useRef(value) useEffect(() => { @@ -267,7 +267,7 @@ export function usePickerState({ dispatch({ type: 'SET_RANGE', payload: r }) // react-day-picker reports the first calendar click as a same-day // range ({ from: d, to: d }). In immediate mode, committing+closing on - // that first click makes multi-day selection impossible (BUG-143), so + // that first click makes multi-day selection impossible, so // wait until the two endpoints differ before auto-committing. if ( effectiveCommitRef.current === 'immediate' diff --git a/packages/datum-ui/src/components/features/picker/internal/use-deprecation-warning.ts b/packages/datum-ui/src/components/features/picker/internal/use-deprecation-warning.ts deleted file mode 100644 index 93d61de7..00000000 --- a/packages/datum-ui/src/components/features/picker/internal/use-deprecation-warning.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Promoted to the neutral shared module so sibling deprecation-shim features -// consume it without reaching into picker internals. Re-exported here to keep -// any remaining picker-internal references working. -export { useDeprecationWarning } from '../../_shared/use-deprecation-warning' diff --git a/packages/datum-ui/src/components/features/picker/presets/defaults.ts b/packages/datum-ui/src/components/features/picker/presets/defaults.ts index cfc0a5ee..61769c03 100644 --- a/packages/datum-ui/src/components/features/picker/presets/defaults.ts +++ b/packages/datum-ui/src/components/features/picker/presets/defaults.ts @@ -11,7 +11,7 @@ import { subMinutes, } from 'date-fns' import { fromZonedTime, toZonedTime } from 'date-fns-tz' -import { endOfDayInTz, startOfDayInTz } from '../utils/timezone' +import { endOfDayInTz, startOfDayInTz } from '../../_shared/timezone' /** * Compute a `{ from, to }` window in `tz` wall-clock space using date-fns diff --git a/packages/datum-ui/src/components/features/picker/utils/timezone.ts b/packages/datum-ui/src/components/features/picker/utils/timezone.ts index cb116814..483930f5 100644 --- a/packages/datum-ui/src/components/features/picker/utils/timezone.ts +++ b/packages/datum-ui/src/components/features/picker/utils/timezone.ts @@ -1,10 +1,5 @@ import { formatInTimeZone, fromZonedTime, toZonedTime } from 'date-fns-tz' -// Canonical tz-aware day-boundary helpers live in the neutral shared module so -// sibling features (legacy time-range-picker) can consume them without reaching -// into picker internals. Re-exported here for picker-internal consumers. -export { endOfDayInTz, startOfDayInTz } from '../../_shared/timezone' - /** Browser-detected IANA timezone, falling back to UTC if unavailable. */ export function getBrowserTimezone(): string { try { diff --git a/packages/datum-ui/src/components/features/tag-input/tag-input-field.tsx b/packages/datum-ui/src/components/features/tag-input/tag-input-field.tsx index fd2e6b55..6cde4efb 100644 --- a/packages/datum-ui/src/components/features/tag-input/tag-input-field.tsx +++ b/packages/datum-ui/src/components/features/tag-input/tag-input-field.tsx @@ -75,7 +75,7 @@ export function TagInputField({ value, placeholder, input }: TagInputFieldProps) aria-label="input tag" disabled={disableInput} // A chip is active: keep the field controlled but non-editable so React - // never warns about a `value` without `onChange` (BUG-159). + // never warns about a `value` without `onChange`. readOnly={activeIndex !== -1} onKeyDown={handleKeyDown} onPaste={handlePaste} diff --git a/packages/datum-ui/src/components/features/tag-input/tag-input.tsx b/packages/datum-ui/src/components/features/tag-input/tag-input.tsx index 091435f7..0a5900b7 100644 --- a/packages/datum-ui/src/components/features/tag-input/tag-input.tsx +++ b/packages/datum-ui/src/components/features/tag-input/tag-input.tsx @@ -124,7 +124,7 @@ export function TagsInput({ ref, value, onValueChange, placeholder, maxItems, mi {/* Hidden input for form submission. The visible wrapper above already carries any consumer-provided `id` (via `...props`), so the select must not duplicate it — a duplicate DOM id breaks label association and a11y - tooling (BUG-158). */} + tooling. */}