@kennycorrea/ui 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +120 -14
  2. package/README.md +204 -273
  3. package/dist/atoms/ActionSheet/ActionSheet.d.ts +30 -0
  4. package/dist/atoms/ActionSheet/ActionSheet.js +42 -0
  5. package/dist/atoms/ActionSheet/confirm.d.ts +21 -0
  6. package/dist/atoms/ActionSheet/confirm.js +65 -0
  7. package/dist/atoms/ActionSheet/index.d.ts +2 -0
  8. package/dist/atoms/ActionSheet/index.js +2 -0
  9. package/dist/atoms/Button/Button.js +3 -3
  10. package/dist/atoms/Button/Button.types.d.ts +7 -1
  11. package/dist/atoms/Card/Card.d.ts +28 -7
  12. package/dist/atoms/Card/Card.js +45 -9
  13. package/dist/atoms/ChipGroup/ChipGroup.d.ts +31 -0
  14. package/dist/atoms/ChipGroup/ChipGroup.js +69 -0
  15. package/dist/atoms/ChipGroup/index.d.ts +1 -0
  16. package/dist/atoms/ChipGroup/index.js +1 -0
  17. package/dist/atoms/Drawer/Drawer.d.ts +1 -1
  18. package/dist/atoms/Drawer/Drawer.js +9 -13
  19. package/dist/atoms/Input/Input.js +7 -1
  20. package/dist/atoms/List/List.d.ts +73 -0
  21. package/dist/atoms/List/List.js +88 -0
  22. package/dist/atoms/List/index.d.ts +1 -0
  23. package/dist/atoms/List/index.js +1 -0
  24. package/dist/atoms/List/swipe.d.ts +68 -0
  25. package/dist/atoms/List/swipe.js +207 -0
  26. package/dist/atoms/NavBar/BackButton.d.ts +10 -0
  27. package/dist/atoms/NavBar/BackButton.js +11 -0
  28. package/dist/atoms/NavBar/NavBar.d.ts +22 -0
  29. package/dist/atoms/NavBar/NavBar.js +29 -0
  30. package/dist/atoms/NavBar/index.d.ts +2 -0
  31. package/dist/atoms/NavBar/index.js +2 -0
  32. package/dist/atoms/NotificationCard/NotificationCard.d.ts +52 -0
  33. package/dist/atoms/NotificationCard/NotificationCard.js +78 -0
  34. package/dist/atoms/NotificationCard/index.d.ts +1 -0
  35. package/dist/atoms/NotificationCard/index.js +1 -0
  36. package/dist/atoms/PullToRefresh/PullToRefresh.d.ts +31 -0
  37. package/dist/atoms/PullToRefresh/PullToRefresh.js +110 -0
  38. package/dist/atoms/PullToRefresh/index.d.ts +1 -0
  39. package/dist/atoms/PullToRefresh/index.js +1 -0
  40. package/dist/atoms/ResponsiveDialog/ResponsiveDialog.d.ts +13 -0
  41. package/dist/atoms/ResponsiveDialog/ResponsiveDialog.js +27 -0
  42. package/dist/atoms/ResponsiveDialog/index.d.ts +1 -0
  43. package/dist/atoms/ResponsiveDialog/index.js +1 -0
  44. package/dist/atoms/ScrollStrip/ScrollStrip.d.ts +17 -0
  45. package/dist/atoms/ScrollStrip/ScrollStrip.js +15 -0
  46. package/dist/atoms/ScrollStrip/index.d.ts +1 -0
  47. package/dist/atoms/ScrollStrip/index.js +1 -0
  48. package/dist/atoms/SegmentedControl/SegmentedControl.d.ts +23 -0
  49. package/dist/atoms/SegmentedControl/SegmentedControl.js +41 -0
  50. package/dist/atoms/SegmentedControl/index.d.ts +1 -0
  51. package/dist/atoms/SegmentedControl/index.js +1 -0
  52. package/dist/atoms/Select/Select.js +1 -1
  53. package/dist/atoms/Sheet/Sheet.d.ts +63 -0
  54. package/dist/atoms/Sheet/Sheet.js +261 -0
  55. package/dist/atoms/Sheet/index.d.ts +1 -0
  56. package/dist/atoms/Sheet/index.js +1 -0
  57. package/dist/atoms/StatTile/StatTile.d.ts +36 -0
  58. package/dist/atoms/StatTile/StatTile.js +17 -0
  59. package/dist/atoms/StatTile/index.d.ts +1 -0
  60. package/dist/atoms/StatTile/index.js +1 -0
  61. package/dist/atoms/TabBar/TabBar.d.ts +42 -0
  62. package/dist/atoms/TabBar/TabBar.js +36 -0
  63. package/dist/atoms/TabBar/index.d.ts +1 -0
  64. package/dist/atoms/TabBar/index.js +1 -0
  65. package/dist/atoms/Textarea/Textarea.d.ts +4 -0
  66. package/dist/atoms/Textarea/Textarea.js +1 -1
  67. package/dist/atoms/Toast/Toast.d.ts +8 -2
  68. package/dist/atoms/Toast/Toast.js +19 -4
  69. package/dist/atoms/index.d.ts +12 -0
  70. package/dist/atoms/index.js +12 -0
  71. package/dist/primitives/index.d.ts +1 -1
  72. package/dist/primitives/index.js +1 -1
  73. package/dist/strings.d.ts +13 -3
  74. package/dist/strings.js +5 -0
  75. package/dist/utils/themePortal.d.ts +19 -0
  76. package/dist/utils/themePortal.js +28 -0
  77. package/package.json +4 -2
  78. package/tokens.css +454 -0
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import * as AlertDialog from '@radix-ui/react-alert-dialog';
3
+ import { Theme } from '@radix-ui/themes';
4
+ import { useSyncExternalStore } from 'react';
5
+ import { useUiStrings } from '../../strings.js';
6
+ import { cn } from '../../utils/cn.js';
7
+ import { useThemePortalTarget } from '../../utils/themePortal.js';
8
+ /** Tailwind's `md`. At and above it the sheet becomes a small centred dialog. */
9
+ const MD = '(min-width: 768px)';
10
+ function subscribe(onChange) {
11
+ const query = window.matchMedia(MD);
12
+ query.addEventListener('change', onChange);
13
+ return () => query.removeEventListener('change', onChange);
14
+ }
15
+ function useIsMdUp() {
16
+ return useSyncExternalStore(subscribe, () => window.matchMedia(MD).matches, () => true);
17
+ }
18
+ const TONE = {
19
+ neutral: 'text-ink',
20
+ brand: 'text-brand-700',
21
+ danger: 'text-[var(--red-11)]',
22
+ };
23
+ /**
24
+ * A short list of actions to choose between, on Radix `AlertDialog`: below `md` anchored to the
25
+ * bottom as a grouped list with a separated Cancel; at `md` and up a small centred dialog.
26
+ *
27
+ * AlertDialog rather than Dialog because the user is being asked to decide — it cannot be
28
+ * dismissed by clicking outside, and focus lands on Cancel, so Enter never fires a destructive
29
+ * action by accident. Escape and Cancel both close it without running anything.
30
+ */
31
+ export function ActionSheet({ open, onOpenChange, actions, title, message, cancelLabel, container, }) {
32
+ const strings = useUiStrings();
33
+ const mdUp = useIsMdUp();
34
+ const { anchorRef, target, resolved } = useThemePortalTarget(container);
35
+ const cancel = cancelLabel ?? strings.cancel;
36
+ const hasHeader = title != null || message != null;
37
+ const header = hasHeader ? (_jsxs("div", { className: "px-4 py-3 text-center", children: [title != null ? (_jsx(AlertDialog.Title, { className: "m-0 text-sm font-medium text-ink", children: title })) : (_jsx(AlertDialog.Title, { className: "sr-only", children: cancel })), message != null ? (_jsx(AlertDialog.Description, { className: "m-0 mt-0.5 text-sm text-ink-muted", children: message })) : null] })) : null;
38
+ const rows = actions.map((a) => (_jsxs(AlertDialog.Action, { onClick: a.onSelect, "data-tone": a.tone ?? 'neutral', className: cn('flex min-h-[var(--tap-min,44px)] w-full items-center justify-center gap-2 border-t border-hairline px-4 text-base', 'outline-none hover:bg-[var(--gray-a3)] focus-visible:bg-[var(--gray-a3)] active:bg-[var(--gray-a4)]', TONE[a.tone ?? 'neutral']), children: [a.icon ? _jsx("span", { "aria-hidden": "true", children: a.icon }) : null, a.label] }, a.label)));
39
+ const cancelButton = (_jsx(AlertDialog.Cancel, { className: cn('flex min-h-[var(--tap-min,44px)] w-full items-center justify-center px-4 text-base font-medium text-ink', 'outline-none hover:bg-[var(--gray-a3)] focus-visible:bg-[var(--gray-a3)] active:bg-[var(--gray-a4)]'), children: cancel }));
40
+ const body = mdUp ? (_jsxs(AlertDialog.Content, { "data-presentation": "dialog", ...(message == null ? { 'aria-describedby': undefined } : {}), className: "ui-sheet-dialog fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-2xl bg-sheet text-ink shadow-2xl outline-none ring-1 ring-hairline", children: [header, _jsx("div", { className: cn(!hasHeader && '[&>*:first-child]:border-t-0'), children: rows }), _jsx("div", { className: "border-t border-hairline", children: cancelButton })] })) : (_jsxs(AlertDialog.Content, { "data-presentation": "sheet", ...(message == null ? { 'aria-describedby': undefined } : {}), className: "ui-sheet fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full max-w-lg flex-col gap-2 px-2 pb-[max(0.5rem,var(--safe-bottom,0px))] outline-none", children: [_jsxs("div", { className: "overflow-hidden rounded-xl bg-grouped", children: [header, _jsx("div", { className: cn(!hasHeader && '[&>*:first-child]:border-t-0'), children: rows })] }), _jsx("div", { className: "overflow-hidden rounded-xl bg-grouped", children: cancelButton })] }));
41
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true }), resolved ? (_jsx(AlertDialog.Root, { open: open, onOpenChange: onOpenChange, children: _jsx(AlertDialog.Portal, { container: target ?? undefined, children: _jsxs(Theme, { hasBackground: false, children: [_jsx(AlertDialog.Overlay, { className: "ui-sheet-scrim fixed inset-0 z-40 bg-scrim" }), body] }) }) })) : null] }));
42
+ }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type ActionSheetTone } from './ActionSheet.js';
3
+ export interface ConfirmOptions {
4
+ title: ReactNode;
5
+ message?: ReactNode;
6
+ /** The accept button. Default: the `confirm` UI string. */
7
+ confirmLabel?: string;
8
+ /** The dismiss button. Default: the `cancel` UI string. */
9
+ cancelLabel?: string;
10
+ /** `danger` for destructive confirmations. Default `neutral`. */
11
+ tone?: ActionSheetTone;
12
+ }
13
+ /**
14
+ * Asks and resolves `true` on accept, `false` on Cancel, Escape or any other dismissal.
15
+ * Never rejects.
16
+ *
17
+ * if (await confirm({ title: 'Delete booking?', confirmLabel: 'Delete', tone: 'danger' })) …
18
+ */
19
+ export declare function confirm(options: ConfirmOptions): Promise<boolean>;
20
+ /** Renders the pending confirmation. Mount once, inside `<Theme>`, next to `<Toaster>`. */
21
+ export declare function ConfirmProvider(): import("react").JSX.Element | null;
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from 'react';
3
+ import { useUiStrings } from '../../strings.js';
4
+ import { ActionSheet } from './ActionSheet.js';
5
+ /**
6
+ * A module-level queue, like `toast()`: `confirm()` can be called from any handler without
7
+ * threading a hook through. One `<ConfirmProvider>` per app; calls made while one is showing
8
+ * wait their turn rather than replacing it, so nobody's question is silently answered "no".
9
+ */
10
+ let queue = [];
11
+ const listeners = new Set();
12
+ /** A fresh array per change, so `useSyncExternalStore` sees it. */
13
+ let snapshot;
14
+ function emit(next) {
15
+ queue = next;
16
+ snapshot = queue[0];
17
+ for (const l of listeners)
18
+ l();
19
+ }
20
+ function subscribe(l) {
21
+ listeners.add(l);
22
+ return () => listeners.delete(l);
23
+ }
24
+ /**
25
+ * Asks and resolves `true` on accept, `false` on Cancel, Escape or any other dismissal.
26
+ * Never rejects.
27
+ *
28
+ * if (await confirm({ title: 'Delete booking?', confirmLabel: 'Delete', tone: 'danger' })) …
29
+ */
30
+ export function confirm(options) {
31
+ return new Promise((resolve) => emit([...queue, { ...options, resolve }]));
32
+ }
33
+ /** Renders the pending confirmation. Mount once, inside `<Theme>`, next to `<Toaster>`. */
34
+ export function ConfirmProvider() {
35
+ const current = useSyncExternalStore(subscribe, () => snapshot, () => undefined);
36
+ const strings = useUiStrings();
37
+ if (!current)
38
+ return null;
39
+ const settle = (ok) => {
40
+ if (queue[0] !== current)
41
+ return;
42
+ current.resolve(ok);
43
+ emit(queue.slice(1));
44
+ };
45
+ return (_jsx(ActionSheet, { open: true, onOpenChange: (open) => {
46
+ if (!open)
47
+ settle(false);
48
+ }, title: current.title, message: current.message, cancelLabel: current.cancelLabel, actions: [
49
+ {
50
+ label: current.confirmLabel ?? strings.confirm,
51
+ tone: current.tone,
52
+ onSelect: () => settle(true),
53
+ },
54
+ ] }, queue.length === 0 ? 'none' : String(queueId(current))));
55
+ }
56
+ const ids = new WeakMap();
57
+ let nextId = 0;
58
+ function queueId(p) {
59
+ let id = ids.get(p);
60
+ if (id === undefined) {
61
+ id = ++nextId;
62
+ ids.set(p, id);
63
+ }
64
+ return id;
65
+ }
@@ -0,0 +1,2 @@
1
+ export * from './ActionSheet.js';
2
+ export * from './confirm.js';
@@ -0,0 +1,2 @@
1
+ export * from './ActionSheet.js';
2
+ export * from './confirm.js';
@@ -18,8 +18,8 @@ const VARIANTS = {
18
18
  danger: { variant: 'solid', color: 'red' },
19
19
  };
20
20
  /** Themes sizes are 1–4; ours are the three a form actually needs. */
21
- const SIZES = { sm: '2', md: '3', lg: '4' };
22
- export const Button = forwardRef(function Button({ variant = 'primary', size = 'md', loading = false, fullWidth = false, asChild = false, href, className, disabled, children, ...props }, ref) {
21
+ const SIZES = { sm: '2', md: '3', lg: '4', touch: '3' };
22
+ export const Button = forwardRef(function Button({ variant = 'primary', size = 'md', loading = false, fullWidth = false, iconOnly = false, asChild = false, href, className, disabled, children, ...props }, ref) {
23
23
  const intent = VARIANTS[variant];
24
24
  // An anchor is `asChild` underneath, so everything below treats the two the same: neither a
25
25
  // link nor a slotted child may be given `loading` or `disabled`, because those are button
@@ -29,6 +29,6 @@ export const Button = forwardRef(function Button({ variant = 'primary', size = '
29
29
  // Themes renders its own spinner and keeps the label's width while it spins, so the
30
30
  // button does not jump. It does not disable the button, which is why `disabled` still
31
31
  // accounts for it.
32
- loading: slotted ? undefined : loading, disabled: slotted ? undefined : disabled || loading, className: cn(fullWidth && 'w-full', className), ...props, children: href !== undefined ? _jsx("a", { href: href, children: children }) : children }));
32
+ loading: slotted ? undefined : loading, disabled: slotted ? undefined : disabled || loading, "data-variant": variant, "data-size": size, "data-icon-only": iconOnly || undefined, className: cn('ui-button', fullWidth && 'w-full', className), ...props, children: href !== undefined ? _jsx("a", { href: href, children: children }) : children }));
33
33
  return themed;
34
34
  });
@@ -12,7 +12,8 @@ import type { ComponentPropsWithoutRef, ReactNode } from 'react';
12
12
  * panel. So `secondary` means exactly one thing everywhere and the other look has its own name.
13
13
  */
14
14
  export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
15
- export type ButtonSize = 'sm' | 'md' | 'lg';
15
+ /** `touch` is `md` type with a 44px minimum height, for primary actions on touch screens. */
16
+ export type ButtonSize = 'sm' | 'md' | 'lg' | 'touch';
16
17
  /**
17
18
  * Derived from Themes' own props rather than from `ButtonHTMLAttributes`.
18
19
  *
@@ -26,6 +27,11 @@ export interface ButtonProps extends Omit<ComponentPropsWithoutRef<typeof Themed
26
27
  /** Renders a spinner and disables the button. */
27
28
  loading?: boolean;
28
29
  fullWidth?: boolean;
30
+ /**
31
+ * The button holds only an icon. Its visual size is unchanged, but on coarse pointers the hit
32
+ * area grows to 44px with an invisible `::before`, so neighbours do not shrink.
33
+ */
34
+ iconOnly?: boolean;
29
35
  /** Renders the child element instead of a `button`, e.g. a router `Link`. */
30
36
  asChild?: boolean;
31
37
  /**
@@ -1,5 +1,19 @@
1
- import type { HTMLAttributes } from 'react';
2
- export interface CardProps extends HTMLAttributes<HTMLDivElement> {
1
+ import { type HTMLAttributes } from 'react';
2
+ export type CardVariant = 'outlined' | 'grouped' | 'plain';
3
+ export type CardDensity = 'comfortable' | 'compact';
4
+ /** Radix Themes' breakpoints, in the order they apply. `initial` is the unconditional value. */
5
+ export declare const CARD_BREAKPOINTS: readonly ["initial", "xs", "sm", "md", "lg", "xl"];
6
+ export type CardBreakpoint = (typeof CARD_BREAKPOINTS)[number];
7
+ /** A value, or one per breakpoint — Radix Themes' responsive prop convention. */
8
+ export type Responsive<T> = T | Partial<Record<CardBreakpoint, T>>;
9
+ interface CardStyleProps {
10
+ /** `outlined` is the bordered card; `grouped` a borderless rounded tile for tinted canvases;
11
+ * `plain` has no surface at all. Grouped and plain ignore `raised`. */
12
+ variant?: Responsive<CardVariant>;
13
+ /** `compact` tightens padding to the row spacing tokens and quietens the title. */
14
+ density?: Responsive<CardDensity>;
15
+ }
16
+ export interface CardProps extends HTMLAttributes<HTMLDivElement>, CardStyleProps {
3
17
  /** Lifts the card off the ground — for dialogs and anything that floats. */
4
18
  raised?: boolean;
5
19
  }
@@ -8,9 +22,16 @@ export interface CardProps extends HTMLAttributes<HTMLDivElement> {
8
22
  *
9
23
  * Themes pads its card for you; the house card is built from a header and a body with a
10
24
  * rule between them, so the padding belongs to those rather than to the card. `p-0` is the
11
- * whole of the customisation.
25
+ * whole of the customisation. Variant and density reach the header, title and body through
26
+ * context, and each can still override them.
12
27
  */
13
- export declare function Card({ raised, className, ...props }: CardProps): import("react").JSX.Element;
14
- export declare function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>): import("react").JSX.Element;
15
- export declare function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>): import("react").JSX.Element;
16
- export declare function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>): import("react").JSX.Element;
28
+ export declare function Card({ raised, variant, density, className, ...props }: CardProps): import("react").JSX.Element;
29
+ export interface CardPartProps extends HTMLAttributes<HTMLDivElement>, CardStyleProps {
30
+ }
31
+ export declare function CardHeader({ variant, density, className, ...props }: CardPartProps): import("react").JSX.Element;
32
+ export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
33
+ density?: Responsive<CardDensity>;
34
+ }
35
+ export declare function CardTitle({ density, className, ...props }: CardTitleProps): import("react").JSX.Element;
36
+ export declare function CardBody({ variant, density, className, ...props }: CardPartProps): import("react").JSX.Element;
37
+ export {};
@@ -1,22 +1,58 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Card as ThemedCard } from '@radix-ui/themes';
3
+ import { createContext, useContext } from 'react';
3
4
  import { cn } from '../../utils/cn.js';
5
+ /** Radix Themes' breakpoints, in the order they apply. `initial` is the unconditional value. */
6
+ export const CARD_BREAKPOINTS = ['initial', 'xs', 'sm', 'md', 'lg', 'xl'];
7
+ const CardContext = createContext({});
8
+ /**
9
+ * Spreads a responsive value into data attributes the stylesheet matches on: `data-<name>` for
10
+ * `initial`, `data-<name>-<bp>` for the rest. Breakpoints are CSS media queries in `tokens.css`,
11
+ * because Tailwind's `md` is not Radix's `md` and a class cannot be generated for both.
12
+ */
13
+ function responsiveAttrs(name, value) {
14
+ const attrs = {};
15
+ if (value === undefined)
16
+ return attrs;
17
+ if (typeof value === 'string') {
18
+ attrs[`data-${name}`] = value;
19
+ return attrs;
20
+ }
21
+ for (const bp of CARD_BREAKPOINTS) {
22
+ const v = value[bp];
23
+ if (v !== undefined)
24
+ attrs[bp === 'initial' ? `data-${name}` : `data-${name}-${bp}`] = v;
25
+ }
26
+ return attrs;
27
+ }
28
+ function useStyleAttrs(own) {
29
+ const inherited = useContext(CardContext);
30
+ return {
31
+ ...responsiveAttrs('variant', own.variant ?? inherited.variant),
32
+ ...responsiveAttrs('density', own.density ?? inherited.density),
33
+ };
34
+ }
4
35
  /**
5
36
  * Radix Themes' Card, with the padding taken off.
6
37
  *
7
38
  * Themes pads its card for you; the house card is built from a header and a body with a
8
39
  * rule between them, so the padding belongs to those rather than to the card. `p-0` is the
9
- * whole of the customisation.
40
+ * whole of the customisation. Variant and density reach the header, title and body through
41
+ * context, and each can still override them.
10
42
  */
11
- export function Card({ raised = false, className, ...props }) {
12
- return (_jsx(ThemedCard, { size: "2", variant: raised ? 'classic' : 'surface', className: cn('p-0', className), ...props }));
43
+ export function Card({ raised = false, variant = 'outlined', density = 'comfortable', className, ...props }) {
44
+ const attrs = useStyleAttrs({ variant, density });
45
+ return (_jsx(CardContext.Provider, { value: { variant, density }, children: _jsx(ThemedCard, { size: "2", variant: raised ? 'classic' : 'surface', className: cn('ui-card p-0', className), ...attrs, ...props }) }));
13
46
  }
14
- export function CardHeader({ className, ...props }) {
15
- return _jsx("div", { className: cn('border-b border-hairline px-5 py-4', className), ...props });
47
+ export function CardHeader({ variant, density, className, ...props }) {
48
+ const attrs = useStyleAttrs({ variant, density });
49
+ return (_jsx("div", { className: cn('ui-card-header border-b border-hairline', className), ...attrs, ...props }));
16
50
  }
17
- export function CardTitle({ className, ...props }) {
18
- return _jsx("h2", { className: cn('font-medium text-brand-900', className), ...props });
51
+ export function CardTitle({ density, className, ...props }) {
52
+ const attrs = useStyleAttrs({ density });
53
+ return _jsx("h2", { className: cn('ui-card-title text-brand-900', className), ...attrs, ...props });
19
54
  }
20
- export function CardBody({ className, ...props }) {
21
- return _jsx("div", { className: cn('px-5 py-4', className), ...props });
55
+ export function CardBody({ variant, density, className, ...props }) {
56
+ const attrs = useStyleAttrs({ variant, density });
57
+ return _jsx("div", { className: cn('ui-card-body', className), ...attrs, ...props });
22
58
  }
@@ -0,0 +1,31 @@
1
+ import { type ReactNode } from 'react';
2
+ export interface ChipOption {
3
+ value: string;
4
+ label: ReactNode;
5
+ disabled?: boolean;
6
+ }
7
+ interface ChipGroupBase {
8
+ options: ChipOption[];
9
+ 'aria-label'?: string;
10
+ 'aria-labelledby'?: string;
11
+ className?: string;
12
+ }
13
+ export interface ChipGroupSingleProps extends ChipGroupBase {
14
+ multiple?: false;
15
+ value: string | null;
16
+ onValueChange?: (value: string | null) => void;
17
+ }
18
+ export interface ChipGroupMultiProps extends ChipGroupBase {
19
+ multiple: true;
20
+ value: string[];
21
+ onValueChange?: (value: string[]) => void;
22
+ }
23
+ export type ChipGroupProps = ChipGroupSingleProps | ChipGroupMultiProps;
24
+ /**
25
+ * Horizontally scrolling filter chips with scroll-snap and fade edges that appear only where there
26
+ * is more to scroll. Single mode: clicking the selected chip again clears it. Multi mode toggles
27
+ * each chip independently. The first selected chip is scrolled into view. Left/Right move focus
28
+ * (Home/End jump); Space/Enter toggles.
29
+ */
30
+ export declare function ChipGroup(props: ChipGroupProps): import("react").JSX.Element;
31
+ export {};
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ /**
4
+ * Horizontally scrolling filter chips with scroll-snap and fade edges that appear only where there
5
+ * is more to scroll. Single mode: clicking the selected chip again clears it. Multi mode toggles
6
+ * each chip independently. The first selected chip is scrolled into view. Left/Right move focus
7
+ * (Home/End jump); Space/Enter toggles.
8
+ */
9
+ export function ChipGroup(props) {
10
+ const { options, className, 'aria-label': label, 'aria-labelledby': labelledBy } = props;
11
+ const selected = props.multiple ? props.value : props.value === null ? [] : [props.value];
12
+ const scroller = useRef(null);
13
+ const [edges, setEdges] = useState({ start: false, end: false });
14
+ const key = selected.join('\u0000');
15
+ const measure = () => {
16
+ const el = scroller.current;
17
+ if (!el)
18
+ return;
19
+ const start = el.scrollLeft > 1;
20
+ const end = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
21
+ setEdges((e) => (e.start === start && e.end === end ? e : { start, end }));
22
+ };
23
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-run when the selection changes
24
+ useEffect(() => {
25
+ const chip = scroller.current?.querySelector('[data-selected="true"]');
26
+ chip?.scrollIntoView?.({ inline: 'center', block: 'nearest', behavior: 'smooth' });
27
+ measure();
28
+ }, [key]);
29
+ // biome-ignore lint/correctness/useExhaustiveDependencies: measure only touches refs and a bailing setState
30
+ useEffect(() => {
31
+ const el = scroller.current;
32
+ if (!el || typeof ResizeObserver === 'undefined')
33
+ return;
34
+ const ro = new ResizeObserver(measure);
35
+ ro.observe(el);
36
+ return () => ro.disconnect();
37
+ }, []);
38
+ const toggle = (v) => {
39
+ if (props.multiple) {
40
+ props.onValueChange?.(selected.includes(v) ? selected.filter((s) => s !== v) : [...selected, v]);
41
+ }
42
+ else {
43
+ props.onValueChange?.(props.value === v ? null : v);
44
+ }
45
+ };
46
+ const onKeyDown = (e) => {
47
+ const chips = [...e.currentTarget.querySelectorAll('button:not(:disabled)')];
48
+ const i = chips.indexOf(document.activeElement);
49
+ if (i < 0)
50
+ return;
51
+ let to;
52
+ if (e.key === 'ArrowRight')
53
+ to = (i + 1) % chips.length;
54
+ else if (e.key === 'ArrowLeft')
55
+ to = (i - 1 + chips.length) % chips.length;
56
+ else if (e.key === 'Home')
57
+ to = 0;
58
+ else if (e.key === 'End')
59
+ to = chips.length - 1;
60
+ else
61
+ return;
62
+ e.preventDefault();
63
+ chips[to].focus();
64
+ };
65
+ return (_jsx("div", { className: ['ui-chips', className].filter(Boolean).join(' '), "data-fade-start": edges.start ? 'true' : undefined, "data-fade-end": edges.end ? 'true' : undefined, children: _jsx("div", { ref: scroller, role: "group", "aria-label": label, "aria-labelledby": labelledBy, className: "ui-chips__scroller", onScroll: measure, onKeyDown: onKeyDown, children: options.map((o) => {
66
+ const on = selected.includes(o.value);
67
+ return (_jsx("button", { type: "button", "aria-pressed": on, "data-selected": on ? 'true' : 'false', disabled: o.disabled, className: "ui-chips__chip", onClick: () => toggle(o.value), children: o.label }, o.value));
68
+ }) }) }));
69
+ }
@@ -0,0 +1 @@
1
+ export * from './ChipGroup.js';
@@ -0,0 +1 @@
1
+ export * from './ChipGroup.js';
@@ -43,7 +43,7 @@ export interface DrawerProps {
43
43
  /** Caps the width on wider screens. Default `max-w-lg`; pass `max-w-none` for full width. */
44
44
  maxWidthClassName?: string;
45
45
  className?: string;
46
- /** Where the portal mounts. Default: the nearest `<Theme>` (`.radix-themes`), else `document.body`. */
46
+ /** Where the portal mounts. Default: the nearest `<Theme>` (`.radix-themes`), else rendered in place. */
47
47
  container?: HTMLElement | null;
48
48
  }
49
49
  /**
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from 'react';
3
- import { createPortal } from 'react-dom';
2
+ import { useCallback, useEffect, useId, useRef, useState, } from 'react';
4
3
  import { useUiStrings } from '../../strings.js';
5
4
  import { cn } from '../../utils/cn.js';
5
+ import { ThemePortal, useThemePortalTarget } from '../../utils/themePortal.js';
6
6
  /** Pixels of travel that commit a drag regardless of speed. */
7
7
  const DISTANCE = 60;
8
8
  /** Pixels per millisecond that commit a flick regardless of distance. */
@@ -55,16 +55,12 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
55
55
  }, [setOpen, onClose]);
56
56
  // Portal into the nearest <Theme> so the sheet inherits its appearance, accent and tokens;
57
57
  // a portal under <body> would sit outside `.radix-themes`, where none of them are defined.
58
- const anchorRef = useRef(null);
59
- const [themeRoot, setThemeRoot] = useState(null);
60
- useLayoutEffect(() => {
61
- setThemeRoot(anchorRef.current?.closest('.radix-themes') ?? document.body);
62
- }, []);
63
- const target = container ?? themeRoot;
58
+ // With no Theme above it the sheet renders in place rather than escaping to <body>.
59
+ const { anchorRef, target, resolved } = useThemePortalTarget(container);
64
60
  // Modal: everything else on the page goes inert, focus moves in, and comes back on close.
65
61
  useEffect(() => {
66
62
  // The wrapper only exists once the target is known, so wait for it.
67
- if (!open || !modal || !target)
63
+ if (!open || !modal || !resolved)
68
64
  return;
69
65
  const wrapper = wrapperRef.current;
70
66
  const previous = document.activeElement;
@@ -87,7 +83,7 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
87
83
  el.removeAttribute('inert');
88
84
  previous?.focus?.();
89
85
  };
90
- }, [open, modal, target]);
86
+ }, [open, modal, resolved]);
91
87
  useEffect(() => {
92
88
  if (!open || !modal || !dismissible)
93
89
  return;
@@ -189,13 +185,13 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
189
185
  };
190
186
  // A hidden marker stays in the React tree's DOM position, so the portal can find its Theme.
191
187
  const anchor = _jsx("span", { ref: anchorRef, hidden: true, "data-drawer-anchor": "" });
192
- if (!open || !target)
188
+ if (!open || !resolved)
193
189
  return anchor;
194
190
  const dragHandlers = {
195
191
  onPointerMove: move,
196
192
  onPointerUp: end,
197
193
  onPointerCancel: cancel,
198
194
  };
199
- return (_jsxs(_Fragment, { children: [anchor, createPortal(_jsxs("div", { ref: wrapperRef, "data-drawer-root": "", children: [modal ? (_jsx("div", { "data-testid": "drawer-scrim", "aria-hidden": "true", className: "fixed inset-0 z-40 bg-scrim", onClick: dismissible ? close : undefined })) : null, _jsxs("div", { ref: sheetRef, role: modal ? 'dialog' : 'region', "aria-modal": modal ? true : undefined, "aria-labelledby": titleId, "aria-describedby": description ? descId : undefined, tabIndex: -1, "data-snap": snap, "data-mode": mode, onKeyDown: trapTab, style: { transform: `translateY(${Math.max(offset, -40)}px)` }, className: cn('fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full flex-col', 'rounded-t-2xl border border-b-0 border-hairline bg-sheet text-ink shadow-2xl', 'pb-[env(safe-area-inset-bottom)] outline-none', !dragging &&
200
- 'transition-transform duration-200 ease-out motion-reduce:transition-none', maxWidthClassName, className), children: [_jsxs("div", { className: "touch-none select-none px-5 pt-2", onPointerDown: (e) => begin(e, false), ...dragHandlers, children: [_jsx("button", { type: "button", "aria-expanded": expanded, "aria-controls": bodyId, "aria-label": expanded ? (collapseLabel ?? strings.collapse) : (expandLabel ?? strings.expand), onClick: onHandleClick, className: "mx-auto flex h-6 w-16 cursor-grab items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-brand-700 active:cursor-grabbing", children: _jsx("span", { "aria-hidden": "true", className: "h-1.5 w-10 rounded-full bg-hairline" }) }), _jsxs("div", { className: "flex items-start gap-3 pb-3 pt-1", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h2", { id: titleId, className: "m-0 text-base font-medium text-brand-900", children: title }), description ? (_jsx("p", { id: descId, className: "m-0 mt-1 text-sm text-ink-muted", children: description })) : null] }), showCloseButton && dismissible ? (_jsx("button", { type: "button", "aria-label": closeLabel ?? strings.close, onClick: close, className: "-mr-1 flex size-8 shrink-0 items-center justify-center rounded-full text-ink-muted hover:bg-brand-700/8 focus-visible:outline-2 focus-visible:outline-brand-700", children: _jsx("svg", { "aria-hidden": "true", width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("path", { d: "M3.5 3.5l8 8m0-8l-8 8", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) }) })) : null] })] }), !expanded && peek ? _jsx("div", { className: "px-5 pb-4", children: peek }) : null, _jsx("div", { ref: bodyRef, id: bodyId, hidden: !expanded, className: "max-h-[80vh] overflow-y-auto overscroll-contain px-5 pb-4", onPointerDown: (e) => begin(e, true), ...dragHandlers, children: children }), footer ? (_jsx("div", { className: "flex justify-end gap-2 border-t border-hairline px-5 py-3", children: footer })) : null] })] }), target)] }));
195
+ return (_jsxs(_Fragment, { children: [anchor, _jsx(ThemePortal, { target: target, children: _jsxs("div", { ref: wrapperRef, "data-drawer-root": "", children: [modal ? (_jsx("div", { "data-testid": "drawer-scrim", "aria-hidden": "true", className: "fixed inset-0 z-40 bg-scrim", onClick: dismissible ? close : undefined })) : null, _jsxs("div", { ref: sheetRef, role: modal ? 'dialog' : 'region', "aria-modal": modal ? true : undefined, "aria-labelledby": titleId, "aria-describedby": description ? descId : undefined, tabIndex: -1, "data-snap": snap, "data-mode": mode, onKeyDown: trapTab, style: { transform: `translateY(${Math.max(offset, -40)}px)` }, className: cn('fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full flex-col', 'rounded-t-2xl border border-b-0 border-hairline bg-sheet text-ink shadow-2xl', 'pb-[env(safe-area-inset-bottom)] outline-none', !dragging &&
196
+ 'transition-transform duration-200 ease-out motion-reduce:transition-none', maxWidthClassName, className), children: [_jsxs("div", { className: "touch-none select-none px-5 pt-2", onPointerDown: (e) => begin(e, false), ...dragHandlers, children: [_jsx("button", { type: "button", "aria-expanded": expanded, "aria-controls": bodyId, "aria-label": expanded ? (collapseLabel ?? strings.collapse) : (expandLabel ?? strings.expand), onClick: onHandleClick, className: "mx-auto flex h-6 w-16 cursor-grab items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-brand-700 active:cursor-grabbing", children: _jsx("span", { "aria-hidden": "true", className: "h-1.5 w-10 rounded-full bg-hairline" }) }), _jsxs("div", { className: "flex items-start gap-3 pb-3 pt-1", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h2", { id: titleId, className: "m-0 text-base font-medium text-brand-900", children: title }), description ? (_jsx("p", { id: descId, className: "m-0 mt-1 text-sm text-ink-muted", children: description })) : null] }), showCloseButton && dismissible ? (_jsx("button", { type: "button", "aria-label": closeLabel ?? strings.close, onClick: close, className: "-mr-1 flex size-8 shrink-0 items-center justify-center rounded-full text-ink-muted hover:bg-brand-700/8 focus-visible:outline-2 focus-visible:outline-brand-700", children: _jsx("svg", { "aria-hidden": "true", width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("path", { d: "M3.5 3.5l8 8m0-8l-8 8", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) }) })) : null] })] }), !expanded && peek ? _jsx("div", { className: "px-5 pb-4", children: peek }) : null, _jsx("div", { ref: bodyRef, id: bodyId, hidden: !expanded, className: "max-h-[80vh] overflow-y-auto overscroll-contain px-5 pb-4", onPointerDown: (e) => begin(e, true), ...dragHandlers, children: children }), footer ? (_jsx("div", { className: "flex justify-end gap-2 border-t border-hairline px-5 py-3", children: footer })) : null] })] }) })] }));
201
197
  }
@@ -2,10 +2,16 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { TextField } from '@radix-ui/themes';
3
3
  import { forwardRef } from 'react';
4
4
  import { cn } from '../../utils/cn.js';
5
+ /**
6
+ * `inputMode` and `enterKeyHint` pass straight through to the `<input>` (they are native
7
+ * attributes), and are worth setting on mobile: `inputMode="numeric"` / `"decimal"` / `"tel"` /
8
+ * `"email"` pick the keyboard, `enterKeyHint="next" | "search" | "send" | "done"` labels the
9
+ * return key. On coarse pointers the text is never below 16px, or iOS zooms the page on focus.
10
+ */
5
11
  const SIZES = { sm: '1', md: '2', lg: '3' };
6
12
  export const Input = forwardRef(function Input({ size = 'md', invalid = false, className, ...props }, ref) {
7
13
  return (_jsx(TextField.Root, { ref: ref, size: SIZES[size],
8
14
  // Themes has no invalid variant; the red tint and the ARIA state are ours, and they
9
15
  // have to agree — a field that only looks wrong is no use to a screen reader.
10
- color: invalid ? 'red' : undefined, "aria-invalid": invalid || undefined, className: cn('w-full', className), ...props }));
16
+ color: invalid ? 'red' : undefined, "aria-invalid": invalid || undefined, className: cn('ui-input w-full', className), ...props }));
11
17
  });
@@ -0,0 +1,73 @@
1
+ import { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from 'react';
2
+ import { type SwipeActions } from './swipe.js';
3
+ export type { RowAction, RowActionTone, SwipeActions } from './swipe.js';
4
+ export type ListSectionVariant = 'inset' | 'full';
5
+ export interface ListSectionProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
6
+ /** Small muted text above the group, outside the surface. */
7
+ header?: ReactNode;
8
+ /** Small muted text below the group, outside the surface. */
9
+ footer?: ReactNode;
10
+ /** `inset` is a rounded tile with margins; `full` runs edge to edge with square corners. */
11
+ variant?: ListSectionVariant;
12
+ }
13
+ /**
14
+ * A native-style grouped list: rows on one grouped surface with hairlines between them.
15
+ *
16
+ * The list is a real `<ul role="list">` (the explicit role keeps VoiceOver announcing it once
17
+ * list-style is removed) and `ListRow` supplies the `<li>`. Header and footer name and describe
18
+ * the list, so a screen reader hears them with it.
19
+ */
20
+ export declare function ListSection({ header, footer, variant, className, children, ...props }: ListSectionProps): import("react").JSX.Element;
21
+ interface RowOwnProps {
22
+ /** An icon, avatar or coloured icon tile. Hairlines start after it. */
23
+ leading?: ReactNode;
24
+ title: ReactNode;
25
+ subtitle?: ReactNode;
26
+ /** Value text, a badge, a switch or any node. Numbers are tabular. */
27
+ trailing?: ReactNode;
28
+ /** Draws a disclosure chevron after the trailing slot. */
29
+ chevron?: boolean;
30
+ disabled?: boolean;
31
+ /** Danger-coloured title. */
32
+ destructive?: boolean;
33
+ /**
34
+ * Actions behind the row. Dragging reveals them on touch devices; everywhere else (and for
35
+ * keyboards and screen readers) the same actions are in a menu button and in the row's
36
+ * context menu (right-click, long-press). One row is open at a time per `ListSection`.
37
+ */
38
+ swipeActions?: SwipeActions;
39
+ /** Dragging a long way past the trailing actions triggers the first of them. Opt-in. */
40
+ fullSwipe?: boolean;
41
+ /** Class for the `<li>`. */
42
+ className?: string;
43
+ }
44
+ type ButtonRow = RowOwnProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof RowOwnProps | 'title'> & {
45
+ href?: undefined;
46
+ asChild?: false;
47
+ };
48
+ type LinkRow = RowOwnProps & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof RowOwnProps | 'title'> & {
49
+ href: string;
50
+ asChild?: false;
51
+ };
52
+ /** The child is the row's element (a router link, say) and supplies its own content. */
53
+ type SlotRow = Omit<RowOwnProps, 'title'> & Omit<HTMLAttributes<HTMLElement>, keyof RowOwnProps | 'title'> & {
54
+ asChild: true;
55
+ title?: ReactNode;
56
+ href?: string;
57
+ };
58
+ export type ListRowProps = ButtonRow | LinkRow | SlotRow;
59
+ /**
60
+ * One row. A `button` with `onClick`, an `a` with `href`, whatever you pass with `asChild`,
61
+ * and a plain `div` when it does nothing — so a row that is not interactive is not announced
62
+ * as if it were. A row holding a switch is such a row: the switch is the control.
63
+ */
64
+ export declare function ListRow(props: ListRowProps): import("react").JSX.Element;
65
+ export interface ListRowSkeletonProps {
66
+ /** Reserve the leading slot. */
67
+ leading?: boolean;
68
+ /** Reserve a subtitle line. */
69
+ subtitle?: boolean;
70
+ className?: string;
71
+ }
72
+ /** A row-shaped placeholder; announces "Loading" once through the strings provider. */
73
+ export declare function ListRowSkeleton({ leading, subtitle, className, }: ListRowSkeletonProps): import("react").JSX.Element;