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/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/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/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/.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/.changeset/form-copy-box-theme-tokens.md b/.changeset/form-copy-box-theme-tokens.md new file mode 100644 index 00000000..5ccc5fba --- /dev/null +++ b/.changeset/form-copy-box-theme-tokens.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui": patch +--- + +`Form.CopyBox` now uses theme tokens for its surface and value text, so it is readable in dark mode and the value meets 4.5:1 contrast in both themes. diff --git a/.changeset/more-actions-confirm.md b/.changeset/more-actions-confirm.md new file mode 100644 index 00000000..d91016b0 --- /dev/null +++ b/.changeset/more-actions-confirm.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui": minor +--- + +Add an optional `confirm` field to `ActionItem` so MoreActions and data-table or grouped-table row actions ask in a dialog before running `onClick`, and an optional `onCloseAutoFocus` prop on `Dialog.Content` for returning focus from a controlled dialog. diff --git a/.changeset/native-button-a11y-state.md b/.changeset/native-button-a11y-state.md new file mode 100644 index 00000000..e77b538c --- /dev/null +++ b/.changeset/native-button-a11y-state.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui-native": patch +--- + +Keep `Button` announcing its disabled and busy state when a caller passes its own `accessibilityState`, by merging the two instead of letting the caller's value replace them. diff --git a/.changeset/native-button-touch-target.md b/.changeset/native-button-touch-target.md new file mode 100644 index 00000000..4ed5f2fb --- /dev/null +++ b/.changeset/native-button-touch-target.md @@ -0,0 +1,5 @@ +--- +"@datum-cloud/datum-ui-native": patch +--- + +Give `Button` a default `hitSlop` that grows its touch area to 44pt, vertically for every size and on both axes for icon-only buttons, without changing how it looks; pass `hitSlop` to override it or `null` to turn it off. 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/.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/.changeset/toast-status-styling.md b/.changeset/toast-status-styling.md new file mode 100644 index 00000000..e59a6c8a --- /dev/null +++ b/.changeset/toast-status-styling.md @@ -0,0 +1,6 @@ +--- +"@datum-cloud/datum-ui": patch +"@datum-cloud/datum-ui-tokens": patch +--- + +Warning and info toasts now have their own muted colours (new `toast-warning-*` and `toast-info-*` tokens) instead of reusing the error and success ones, and every status toast shows a labelled status icon. `toast.message` keeps its current look. The toast root now lays out as a flex row and carries a `data-variant` attribute. diff --git a/apps/storybook/stories/features/more-actions.stories.tsx b/apps/storybook/stories/features/more-actions.stories.tsx index 95fbb174..08ed0589 100644 --- a/apps/storybook/stories/features/more-actions.stories.tsx +++ b/apps/storybook/stories/features/more-actions.stories.tsx @@ -43,6 +43,7 @@ const meta: Meta = { + 'Each action supports an optional `icon`, a `variant` of `\'default\'` or `\'destructive\'` (red text), ' + 'and `disabled`/`hidden` as booleans or functions receiving the `row` data. ' + 'Pass a `tooltip` string or function for additional context on hover. ' + + 'Set `confirm` to ask in a dialog before `onClick` runs. ' + 'When all actions are hidden the component renders nothing.', }, }, @@ -104,6 +105,38 @@ export const WithRowData: Story = { }, } +export const ConfirmDestructive: Story = { + parameters: { + docs: { + description: { + story: + 'An action with `confirm` opens a dialog and runs `onClick` only after the user confirms. ' + + '`title` and `description` take a node or a function of the `row`, and a destructive action gets a danger confirm button. ' + + 'Here `onClick` returns a promise, so both buttons stay disabled until it settles: the dialog closes when it resolves and stays open when it rejects.', + }, + }, + }, + render: () => { + const row = { id: 'proj-1', name: 'My Project' } + const confirmActions: ActionItem[] = [ + { key: 'edit', label: 'Edit', icon: , onClick: () => {} }, + { + key: 'delete', + label: 'Delete', + variant: 'destructive', + icon: , + onClick: () => new Promise(resolve => setTimeout(resolve, 1500)), + confirm: { + title: r => `Delete ${r.name}?`, + description: 'The project and its resources are removed for everyone. This cannot be undone.', + confirmLabel: 'Delete project', + }, + }, + ] + return + }, +} + export const ConditionalVisibility: Story = { parameters: { docs: { diff --git a/packages/datum-ui/src/components/base/dialog/README.md b/packages/datum-ui/src/components/base/dialog/README.md index a963601f..00ae8b14 100644 --- a/packages/datum-ui/src/components/base/dialog/README.md +++ b/packages/datum-ui/src/components/base/dialog/README.md @@ -63,10 +63,11 @@ Button or element that triggers the dialog to open. Container for the dialog content. Applies Datum UI styling. -| Prop | Type | Default | Description | -| ----------- | ----------- | ------- | ---------------- | -| `children` | `ReactNode` | - | Dialog content | -| `className` | `string` | - | Additional class | +| Prop | Type | Default | Description | +| ------------------ | ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------- | +| `children` | `ReactNode` | - | Dialog content | +| `className` | `string` | - | Additional class | +| `onCloseAutoFocus` | `(event: Event) => void` | - | Runs as focus leaves the closing dialog. Call `event.preventDefault()` and focus an element when there is no Trigger | ### Dialog.Header diff --git a/packages/datum-ui/src/components/base/dialog/__tests__/dialog.test.tsx b/packages/datum-ui/src/components/base/dialog/__tests__/dialog.test.tsx index 744e50e0..0e40815e 100644 --- a/packages/datum-ui/src/components/base/dialog/__tests__/dialog.test.tsx +++ b/packages/datum-ui/src/components/base/dialog/__tests__/dialog.test.tsx @@ -1,6 +1,7 @@ /// import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { useRef, useState } from 'react' import { describe, expect, it, vi } from 'vitest' import { Dialog } from '../dialog' @@ -87,4 +88,36 @@ describe('dialog', () => { expect(screen.getByText('Body content')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() }) + + it('lets a controlled dialog choose where focus goes on close', async () => { + const user = userEvent.setup() + function Controlled() { + const [open, setOpen] = useState(false) + const openerRef = useRef(null) + return ( + <> + + + { + e.preventDefault() + openerRef.current?.focus() + }} + > + + + + + + + + ) + } + render() + + await user.click(screen.getByRole('button', { name: 'Open' })) + await user.click(await screen.findByRole('button', { name: 'Done' })) + + await waitFor(() => expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()) + }) }) diff --git a/packages/datum-ui/src/components/base/dialog/dialog.tsx b/packages/datum-ui/src/components/base/dialog/dialog.tsx index 1caf6d13..7332c2d5 100644 --- a/packages/datum-ui/src/components/base/dialog/dialog.tsx +++ b/packages/datum-ui/src/components/base/dialog/dialog.tsx @@ -65,9 +65,15 @@ function Trigger({ children, asChild = true }: DialogTriggerProps) { interface DialogContentProps { children: React.ReactNode className?: string + /** + * Called when focus moves out of the dialog as it closes. Without a + * `Dialog.Trigger` there is nothing to return focus to, so a controlled + * dialog can call `event.preventDefault()` here and focus an element itself. + */ + onCloseAutoFocus?: (event: Event) => void } -function Content({ children, className }: DialogContentProps) { +function Content({ children, className, onCloseAutoFocus }: DialogContentProps) { return ( @@ -76,6 +82,7 @@ function Content({ children, className }: DialogContentProps) { 'dark:bg-muted dark:border-dialog-border data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex max-h-[80vh] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] flex-col gap-0 overflow-hidden rounded-lg bg-white p-0 shadow-xl duration-200 ease-out sm:max-w-lg dark:border', className, )} + onCloseAutoFocus={onCloseAutoFocus} > {children} 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/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/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/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/__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/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/__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/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/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/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/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/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/__tests__/form-copy-box.test.tsx b/packages/datum-ui/src/components/features/form/__tests__/form-copy-box.test.tsx new file mode 100644 index 00000000..44a82ec9 --- /dev/null +++ b/packages/datum-ui/src/components/features/form/__tests__/form-copy-box.test.tsx @@ -0,0 +1,46 @@ +/// +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { ConformAdapter } from '../adapters/conform' +import { RHFAdapter } from '../adapters/rhf' +import { Form } from '../index' + +const adapters = [ + { name: 'Conform', Adapter: ConformAdapter }, + { name: 'RHF', Adapter: RHFAdapter }, +] + +const schema = z.object({ organizationId: z.string() }) + +describe.each(adapters)('form.CopyBox ($name adapter)', ({ Adapter }) => { + function renderCopyBox() { + render( + + + + + + + , + ) + // The Conform adapter also mirrors the value into a hidden