Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/autosearch-latest-callbacks.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/copy-through-clipboard-hook.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/data-table-shared-store-sync.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/deprecate-data-table-context-value.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/form-constraints-through-transforms.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions .changeset/form-copy-box-theme-tokens.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/more-actions-confirm.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/native-button-a11y-state.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/native-button-touch-target.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/resolved-theme-editor-map.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/rich-text-editor-destroyed-guard.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/toast-status-styling.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions apps/storybook/stories/features/more-actions.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const meta: Meta<typeof MoreActions> = {
+ '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.',
},
},
Expand Down Expand Up @@ -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<typeof row>[] = [
{ key: 'edit', label: 'Edit', icon: <PencilIcon />, onClick: () => {} },
{
key: 'delete',
label: 'Delete',
variant: 'destructive',
icon: <TrashIcon />,
onClick: () => new Promise<void>(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 <MoreActions row={row} actions={confirmActions} />
},
}

export const ConditionalVisibility: Story = {
parameters: {
docs: {
Expand Down
9 changes: 5 additions & 4 deletions packages/datum-ui/src/components/base/dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="@testing-library/jest-dom/vitest" />
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'

Expand Down Expand Up @@ -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<HTMLButtonElement>(null)
return (
<>
<button ref={openerRef} type="button" onClick={() => setOpen(true)}>Open</button>
<Dialog open={open} onOpenChange={setOpen}>
<Dialog.Content
onCloseAutoFocus={(e) => {
e.preventDefault()
openerRef.current?.focus()
}}
>
<Dialog.Header title="Title" description="Description" />
<Dialog.Footer>
<button type="button" onClick={() => setOpen(false)}>Done</button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</>
)
}
render(<Controlled />)

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())
})
})
9 changes: 8 additions & 1 deletion packages/datum-ui/src/components/base/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<DialogPortal>
<DialogOverlay />
Expand All @@ -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}
</DialogPrimitive.Content>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => <div data-testid="tile-layer" data-url={url} />,
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(
<ThemeProvider defaultTheme="light">
<MapTileLayer url={LIGHT_URL} darkUrl={DARK_URL} />
</ThemeProvider>,
)

expect(tileUrl()).toBe(LIGHT_URL)
})

it('uses the dark tiles when the theme is dark, even on a light OS', () => {
stubSystemTheme('light')
render(
<ThemeProvider defaultTheme="dark">
<MapTileLayer url={LIGHT_URL} darkUrl={DARK_URL} />
</ThemeProvider>,
)

expect(tileUrl()).toBe(DARK_URL)
})

it('follows a dark OS preference when the theme is system', () => {
stubSystemTheme('dark')
render(
<ThemeProvider defaultTheme="system">
<MapTileLayer url={LIGHT_URL} darkUrl={DARK_URL} />
</ThemeProvider>,
)

expect(tileUrl()).toBe(DARK_URL)
})

it('follows a light OS preference when the theme is system', () => {
stubSystemTheme('light')
render(
<ThemeProvider defaultTheme="system">
<MapTileLayer url={LIGHT_URL} darkUrl={DARK_URL} />
</ThemeProvider>,
)

expect(tileUrl()).toBe(LIGHT_URL)
})

it('lets an explicit theme prop win over the provider', () => {
stubSystemTheme('light')
render(
<ThemeProvider defaultTheme="light">
<MapTileLayer url={LIGHT_URL} darkUrl={DARK_URL} theme="dark" />
</ThemeProvider>,
)

expect(tileUrl()).toBe(DARK_URL)
})

it('reads data-theme from <html> when there is no provider', async () => {
stubSystemTheme('light')
document.documentElement.setAttribute('data-theme', 'dark')
render(<MapTileLayer url={LIGHT_URL} darkUrl={DARK_URL} />)

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)
})
})
3 changes: 3 additions & 0 deletions packages/datum-ui/src/components/base/map/index.ts
Original file line number Diff line number Diff line change
@@ -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'
19 changes: 19 additions & 0 deletions packages/datum-ui/src/components/base/map/map-tile-layer.tsx
Original file line number Diff line number Diff line change
@@ -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 `<html>`.
*/
export function MapTileLayer({ theme, ...props }: ComponentProps<typeof BaseMapTileLayer>) {
const { resolvedTheme } = useTheme()
const providerTheme = resolvedTheme === 'dark' || resolvedTheme === 'light' ? resolvedTheme : undefined

return <BaseMapTileLayer theme={theme ?? providerTheme} {...props} />
}
Loading
Loading