@kennycorrea/ui 0.10.0 → 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 (70) hide show
  1. package/CHANGELOG.md +110 -16
  2. package/README.md +85 -0
  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/Input/Input.js +7 -1
  18. package/dist/atoms/List/List.d.ts +73 -0
  19. package/dist/atoms/List/List.js +88 -0
  20. package/dist/atoms/List/index.d.ts +1 -0
  21. package/dist/atoms/List/index.js +1 -0
  22. package/dist/atoms/List/swipe.d.ts +68 -0
  23. package/dist/atoms/List/swipe.js +207 -0
  24. package/dist/atoms/NavBar/BackButton.d.ts +10 -0
  25. package/dist/atoms/NavBar/BackButton.js +11 -0
  26. package/dist/atoms/NavBar/NavBar.d.ts +22 -0
  27. package/dist/atoms/NavBar/NavBar.js +29 -0
  28. package/dist/atoms/NavBar/index.d.ts +2 -0
  29. package/dist/atoms/NavBar/index.js +2 -0
  30. package/dist/atoms/PullToRefresh/PullToRefresh.d.ts +31 -0
  31. package/dist/atoms/PullToRefresh/PullToRefresh.js +110 -0
  32. package/dist/atoms/PullToRefresh/index.d.ts +1 -0
  33. package/dist/atoms/PullToRefresh/index.js +1 -0
  34. package/dist/atoms/ResponsiveDialog/ResponsiveDialog.d.ts +13 -0
  35. package/dist/atoms/ResponsiveDialog/ResponsiveDialog.js +27 -0
  36. package/dist/atoms/ResponsiveDialog/index.d.ts +1 -0
  37. package/dist/atoms/ResponsiveDialog/index.js +1 -0
  38. package/dist/atoms/ScrollStrip/ScrollStrip.d.ts +17 -0
  39. package/dist/atoms/ScrollStrip/ScrollStrip.js +15 -0
  40. package/dist/atoms/ScrollStrip/index.d.ts +1 -0
  41. package/dist/atoms/ScrollStrip/index.js +1 -0
  42. package/dist/atoms/SegmentedControl/SegmentedControl.d.ts +23 -0
  43. package/dist/atoms/SegmentedControl/SegmentedControl.js +41 -0
  44. package/dist/atoms/SegmentedControl/index.d.ts +1 -0
  45. package/dist/atoms/SegmentedControl/index.js +1 -0
  46. package/dist/atoms/Select/Select.js +1 -1
  47. package/dist/atoms/Sheet/Sheet.d.ts +63 -0
  48. package/dist/atoms/Sheet/Sheet.js +261 -0
  49. package/dist/atoms/Sheet/index.d.ts +1 -0
  50. package/dist/atoms/Sheet/index.js +1 -0
  51. package/dist/atoms/StatTile/StatTile.d.ts +36 -0
  52. package/dist/atoms/StatTile/StatTile.js +17 -0
  53. package/dist/atoms/StatTile/index.d.ts +1 -0
  54. package/dist/atoms/StatTile/index.js +1 -0
  55. package/dist/atoms/TabBar/TabBar.d.ts +42 -0
  56. package/dist/atoms/TabBar/TabBar.js +36 -0
  57. package/dist/atoms/TabBar/index.d.ts +1 -0
  58. package/dist/atoms/TabBar/index.js +1 -0
  59. package/dist/atoms/Textarea/Textarea.d.ts +4 -0
  60. package/dist/atoms/Textarea/Textarea.js +1 -1
  61. package/dist/atoms/Toast/Toast.d.ts +8 -2
  62. package/dist/atoms/Toast/Toast.js +19 -4
  63. package/dist/atoms/index.d.ts +11 -0
  64. package/dist/atoms/index.js +11 -0
  65. package/dist/primitives/index.d.ts +1 -1
  66. package/dist/primitives/index.js +1 -1
  67. package/dist/strings.d.ts +10 -0
  68. package/dist/strings.js +5 -0
  69. package/package.json +3 -1
  70. package/tokens.css +393 -0
@@ -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';
@@ -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;
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ChevronRightIcon } from '@radix-ui/react-icons';
3
+ import { Skeleton as ThemedSkeleton } from '@radix-ui/themes';
4
+ import { createContext, useContext, useId, useState, } from 'react';
5
+ import { Slot } from '../../primitives/index.js';
6
+ import { useUiStrings } from '../../strings.js';
7
+ import { cn } from '../../utils/cn.js';
8
+ import { ContextMenu } from '../ContextMenu/index.js';
9
+ import { OpenRowContext, OverflowMenu, SwipeTray, useOpenRowState, useSwipeEnabled, useSwipeRow, } from './swipe.js';
10
+ const VariantContext = createContext('inset');
11
+ /**
12
+ * A native-style grouped list: rows on one grouped surface with hairlines between them.
13
+ *
14
+ * The list is a real `<ul role="list">` (the explicit role keeps VoiceOver announcing it once
15
+ * list-style is removed) and `ListRow` supplies the `<li>`. Header and footer name and describe
16
+ * the list, so a screen reader hears them with it.
17
+ */
18
+ export function ListSection({ header, footer, variant = 'inset', className, children, ...props }) {
19
+ const id = useId();
20
+ const [openRowId, setOpenRowId] = useState(null);
21
+ const headerId = `${id}-h`;
22
+ const footerId = `${id}-f`;
23
+ return (_jsx(VariantContext.Provider, { value: variant, children: _jsx(OpenRowContext.Provider, { value: { openId: openRowId, setOpenId: setOpenRowId }, children: _jsxs("div", { className: cn('ui-list-section', className), "data-variant": variant, ...props, children: [header != null && (_jsx("div", { id: headerId, className: "ui-list-header", children: header })), _jsx("ul", {
24
+ // biome-ignore lint/a11y/noRedundantRoles: Safari drops list semantics once list-style is none
25
+ role: "list", className: "ui-list", "aria-labelledby": header != null ? headerId : undefined, "aria-describedby": footer != null ? footerId : undefined, children: children }), footer != null && (_jsx("div", { id: footerId, className: "ui-list-footer", children: footer }))] }) }) }));
26
+ }
27
+ /**
28
+ * One row. A `button` with `onClick`, an `a` with `href`, whatever you pass with `asChild`,
29
+ * and a plain `div` when it does nothing — so a row that is not interactive is not announced
30
+ * as if it were. A row holding a switch is such a row: the switch is the control.
31
+ */
32
+ export function ListRow(props) {
33
+ const { leading, title, subtitle, trailing, chevron = false, disabled = false, destructive = false, asChild = false, className, swipeActions, fullSwipe = false, ...rest } = props;
34
+ const rowId = useId();
35
+ const { moreActions } = useUiStrings();
36
+ const swipeEnabled = useSwipeEnabled();
37
+ const openState = useOpenRowState();
38
+ const sw = useSwipeRow({
39
+ id: rowId,
40
+ enabled: swipeEnabled && !disabled,
41
+ actions: swipeActions ?? {},
42
+ fullSwipe,
43
+ open: openState,
44
+ });
45
+ const variant = useContext(VariantContext);
46
+ const interactive = asChild || props.href !== undefined || props.onClick !== undefined;
47
+ const body = (_jsxs(_Fragment, { children: [leading != null && _jsx("span", { className: "ui-list-lead", children: leading }), _jsxs("span", { className: "ui-list-text", children: [_jsx("span", { className: "ui-list-title", children: title }), subtitle != null && _jsx("span", { className: "ui-list-subtitle", children: subtitle })] }), trailing != null && _jsx("span", { className: "ui-list-trailing", children: trailing }), chevron && _jsx(ChevronRightIcon, { className: "ui-list-chevron", "aria-hidden": true })] }));
48
+ const shared = {
49
+ className: 'ui-list-row',
50
+ 'data-destructive': destructive || undefined,
51
+ 'data-disabled': disabled || undefined,
52
+ 'data-interactive': interactive || undefined,
53
+ };
54
+ let row;
55
+ if (asChild) {
56
+ // The child's own content is kept; the slot only lends it the row's behaviour and look.
57
+ const { children, ...slotProps } = rest;
58
+ row = (_jsx(Slot, { ...shared, ...slotProps, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : undefined, children: children }));
59
+ }
60
+ else if (props.href !== undefined) {
61
+ const { href, onClick, ...a } = rest;
62
+ row = (_jsx("a", { ...shared, ...a,
63
+ // A disabled link has no destination; `aria-disabled` alone would still navigate.
64
+ href: disabled ? undefined : href, onClick: disabled ? undefined : onClick, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : undefined, children: body }));
65
+ }
66
+ else if (interactive) {
67
+ row = (_jsx("button", { type: "button", ...shared, ...rest, disabled: disabled, children: body }));
68
+ }
69
+ else {
70
+ row = (_jsx("div", { ...shared, ...rest, "aria-disabled": disabled || undefined, children: body }));
71
+ }
72
+ // Trailing first: it is the primary side, and the menus read top to bottom.
73
+ const all = [...(swipeActions?.trailing ?? []), ...(swipeActions?.leading ?? [])];
74
+ if (all.length === 0 || disabled) {
75
+ return (_jsx("li", { className: cn('ui-list-item', className), "data-variant": variant, "data-has-lead": leading != null || undefined, children: row }));
76
+ }
77
+ const surface = (_jsxs("div", { className: "ui-list-surface", "data-dragging": sw.dragging || undefined, "data-swipe": swipeEnabled || undefined, style: { transform: sw.offset ? `translateX(${sw.offset}px)` : undefined }, ...sw.surfaceProps, children: [row, _jsx(OverflowMenu, { label: moreActions, actions: all, hidden: swipeEnabled })] }));
78
+ return (_jsxs("li", { ref: sw.root, className: cn('ui-list-item', 'ui-list-item-swipe', className), "data-variant": variant, "data-has-lead": leading != null || undefined, children: [swipeEnabled && swipeActions?.leading?.length ? (_jsx(SwipeTray, { side: "leading", actions: swipeActions.leading, offset: sw.offset, active: sw.isOpen && sw.openSide === 'leading', onDone: sw.close })) : null, swipeEnabled && swipeActions?.trailing?.length ? (_jsx(SwipeTray, { side: "trailing", actions: swipeActions.trailing, offset: sw.offset, active: sw.isOpen && sw.openSide === 'trailing', onDone: sw.close })) : null, _jsx(ContextMenu, { items: all.map((a, index) => ({
79
+ id: String(index),
80
+ label: a.label,
81
+ destructive: a.tone === 'danger',
82
+ })), onSelect: (key) => all[Number(key)]?.onAction(), children: surface })] }));
83
+ }
84
+ /** A row-shaped placeholder; announces "Loading" once through the strings provider. */
85
+ export function ListRowSkeleton({ leading = true, subtitle = false, className, }) {
86
+ const { loading } = useUiStrings();
87
+ return (_jsx("li", { className: cn('ui-list-item', className), "data-has-lead": leading || undefined, "aria-busy": "true", children: _jsxs("div", { className: "ui-list-row", children: [_jsx("span", { className: "ui-list-sr", children: loading }), _jsxs("span", { "aria-hidden": true, className: "ui-list-skeleton", children: [leading && (_jsx("span", { className: "ui-list-lead", children: _jsx(ThemedSkeleton, { width: "100%", height: "100%", style: { borderRadius: 8 } }) })), _jsxs("span", { className: "ui-list-text", children: [_jsx(ThemedSkeleton, { height: "0.875rem", width: "55%" }), subtitle && _jsx(ThemedSkeleton, { height: "0.75rem", width: "35%" })] })] })] }) }));
88
+ }
@@ -0,0 +1 @@
1
+ export * from './List.js';
@@ -0,0 +1 @@
1
+ export * from './List.js';
@@ -0,0 +1,68 @@
1
+ import { type ReactNode, type PointerEvent as ReactPointerEvent } from 'react';
2
+ export type RowActionTone = 'neutral' | 'brand' | 'danger';
3
+ export interface RowAction {
4
+ label: string;
5
+ icon?: ReactNode;
6
+ /** `danger` is red in the swipe tray and destructive in the menus. Default `neutral`. */
7
+ tone?: RowActionTone;
8
+ onAction: () => void;
9
+ }
10
+ export interface SwipeActions {
11
+ /** Revealed by dragging the row towards the end (right in LTR). */
12
+ leading?: RowAction[];
13
+ /** Revealed by dragging the row towards the start (left in LTR). */
14
+ trailing?: RowAction[];
15
+ }
16
+ /** Width of one revealed action button, in px. */
17
+ export declare const ACTION_W = 72;
18
+ /**
19
+ * Whether the swipe gesture is offered: a touch device and no request for reduced motion.
20
+ * Everywhere else the visible overflow button is the way in. False on the server, so the
21
+ * button is what renders before hydration decides.
22
+ */
23
+ export declare function useSwipeEnabled(): boolean;
24
+ /** One row open at a time per list: the list holds which. */
25
+ export interface OpenRowContextValue {
26
+ openId: string | null;
27
+ setOpenId: (id: string | null) => void;
28
+ }
29
+ export declare const OpenRowContext: import("react").Context<OpenRowContextValue | null>;
30
+ export declare function useOpenRowState(): OpenRowContextValue;
31
+ interface UseSwipeArgs {
32
+ id: string;
33
+ enabled: boolean;
34
+ actions: SwipeActions;
35
+ fullSwipe: boolean;
36
+ open: OpenRowContextValue;
37
+ }
38
+ export declare function useSwipeRow({ id, enabled, actions, fullSwipe, open }: UseSwipeArgs): {
39
+ root: import("react").RefObject<HTMLLIElement | null>;
40
+ offset: number;
41
+ dragging: boolean;
42
+ isOpen: boolean;
43
+ openSide: "leading" | "trailing" | null;
44
+ close: () => void;
45
+ surfaceProps: {
46
+ onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
47
+ onPointerMove: (e: ReactPointerEvent<HTMLElement>) => void;
48
+ onPointerUp: (e: ReactPointerEvent<HTMLElement>) => void;
49
+ onPointerCancel: (e: ReactPointerEvent<HTMLElement>) => void;
50
+ onClickCapture: (e: React.MouseEvent) => void;
51
+ };
52
+ };
53
+ /** The revealed buttons of one side. Inert unless that side is open. */
54
+ export declare function SwipeTray({ side, actions, offset, active, onDone, }: {
55
+ side: 'leading' | 'trailing';
56
+ actions: RowAction[];
57
+ offset: number;
58
+ active: boolean;
59
+ onDone: () => void;
60
+ }): import("react").JSX.Element;
61
+ /** The same actions as a visible menu button; the way in when the gesture is not offered. */
62
+ export declare function OverflowMenu({ label, actions, hidden, }: {
63
+ label: string;
64
+ actions: RowAction[];
65
+ /** Visually hidden until focused; still in the tab order for keyboards and screen readers. */
66
+ hidden: boolean;
67
+ }): import("react").JSX.Element;
68
+ export {};
@@ -0,0 +1,207 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DotsHorizontalIcon } from '@radix-ui/react-icons';
3
+ import { DropdownMenu as ThemedDropdownMenu } from '@radix-ui/themes';
4
+ import { createContext, Fragment, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore, } from 'react';
5
+ /** Width of one revealed action button, in px. */
6
+ export const ACTION_W = 72;
7
+ const AXIS_LOCK = 8;
8
+ const FULL_SWIPE_RATIO = 0.6;
9
+ /** iOS-style resistance: the further past the limit, the less the row follows the finger. */
10
+ function rubber(excess, limit) {
11
+ const d = Math.max(limit, 1);
12
+ return (1 - 1 / ((excess * 0.55) / d + 1)) * d;
13
+ }
14
+ function subscribeMedia(onChange) {
15
+ const lists = [
16
+ window.matchMedia('(prefers-reduced-motion: reduce)'),
17
+ window.matchMedia('(pointer: coarse)'),
18
+ ];
19
+ for (const l of lists)
20
+ l.addEventListener?.('change', onChange);
21
+ return () => {
22
+ for (const l of lists)
23
+ l.removeEventListener?.('change', onChange);
24
+ };
25
+ }
26
+ function swipeSnapshot() {
27
+ if (typeof window === 'undefined' || !window.matchMedia)
28
+ return false;
29
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
30
+ return false;
31
+ const touch = window.matchMedia('(pointer: coarse)').matches ||
32
+ (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0);
33
+ return touch;
34
+ }
35
+ /**
36
+ * Whether the swipe gesture is offered: a touch device and no request for reduced motion.
37
+ * Everywhere else the visible overflow button is the way in. False on the server, so the
38
+ * button is what renders before hydration decides.
39
+ */
40
+ export function useSwipeEnabled() {
41
+ return useSyncExternalStore((cb) => (typeof window === 'undefined' || !window.matchMedia ? () => { } : subscribeMedia(cb)), swipeSnapshot, () => false);
42
+ }
43
+ export const OpenRowContext = createContext(null);
44
+ export function useOpenRowState() {
45
+ const ctx = useContext(OpenRowContext);
46
+ const [local, setLocal] = useState(null);
47
+ return ctx ?? { openId: local, setOpenId: setLocal };
48
+ }
49
+ export function useSwipeRow({ id, enabled, actions, fullSwipe, open }) {
50
+ const leadingW = (actions.leading?.length ?? 0) * ACTION_W;
51
+ const trailingW = (actions.trailing?.length ?? 0) * ACTION_W;
52
+ const [side, setSide] = useState(null);
53
+ const [drag, setDrag] = useState(null);
54
+ const isOpen = side !== null && open.openId === id;
55
+ const rest = !isOpen ? 0 : side === 'leading' ? leadingW : -trailingW;
56
+ const root = useRef(null);
57
+ const track = useRef(null);
58
+ const swallowClick = useRef(false);
59
+ const close = useCallback(() => {
60
+ setSide(null);
61
+ setDrag(null);
62
+ if (open.openId === id)
63
+ open.setOpenId(null);
64
+ }, [id, open]);
65
+ // Tapping elsewhere, or Escape, closes an open row.
66
+ useEffect(() => {
67
+ if (!isOpen)
68
+ return;
69
+ const down = (e) => {
70
+ if (!root.current?.contains(e.target))
71
+ close();
72
+ };
73
+ const key = (e) => {
74
+ if (e.key === 'Escape')
75
+ close();
76
+ };
77
+ document.addEventListener('pointerdown', down, true);
78
+ document.addEventListener('keydown', key);
79
+ return () => {
80
+ document.removeEventListener('pointerdown', down, true);
81
+ document.removeEventListener('keydown', key);
82
+ };
83
+ }, [isOpen, close]);
84
+ const settle = (offset, width) => {
85
+ setDrag(null);
86
+ if (fullSwipe && trailingW > 0 && -offset >= width * FULL_SWIPE_RATIO) {
87
+ close();
88
+ actions.trailing?.[0]?.onAction();
89
+ return;
90
+ }
91
+ if (offset < 0 && -offset > trailingW / 2 && trailingW > 0) {
92
+ setSide('trailing');
93
+ open.setOpenId(id);
94
+ }
95
+ else if (offset > 0 && offset > leadingW / 2 && leadingW > 0) {
96
+ setSide('leading');
97
+ open.setOpenId(id);
98
+ }
99
+ else {
100
+ close();
101
+ }
102
+ };
103
+ const onPointerDown = (e) => {
104
+ if (!enabled || (e.pointerType === 'mouse' && e.button !== 0))
105
+ return;
106
+ track.current = {
107
+ pointerId: e.pointerId,
108
+ x: e.clientX,
109
+ y: e.clientY,
110
+ from: rest,
111
+ active: false,
112
+ width: e.currentTarget.getBoundingClientRect().width,
113
+ offset: rest,
114
+ };
115
+ };
116
+ const onPointerMove = (e) => {
117
+ const t = track.current;
118
+ if (!t || t.pointerId !== e.pointerId)
119
+ return;
120
+ const dx = e.clientX - t.x;
121
+ const dy = e.clientY - t.y;
122
+ if (!t.active) {
123
+ if (Math.abs(dy) > AXIS_LOCK && Math.abs(dy) > Math.abs(dx)) {
124
+ track.current = null; // a vertical scroll, not ours
125
+ return;
126
+ }
127
+ if (Math.abs(dx) <= AXIS_LOCK)
128
+ return;
129
+ t.active = true;
130
+ e.currentTarget.setPointerCapture?.(e.pointerId);
131
+ open.setOpenId(id);
132
+ }
133
+ const raw = t.from + dx;
134
+ let offset;
135
+ if (raw < 0) {
136
+ if (-raw <= trailingW)
137
+ offset = raw;
138
+ else if (fullSwipe && trailingW > 0)
139
+ offset = Math.max(raw, -t.width); // follows the finger to the edge
140
+ else
141
+ offset = -(trailingW + rubber(-raw - trailingW, 24));
142
+ }
143
+ else {
144
+ offset = raw <= leadingW ? raw : leadingW + rubber(raw - leadingW, 24);
145
+ }
146
+ t.offset = offset;
147
+ setDrag(offset);
148
+ };
149
+ const finish = (e, cancelled) => {
150
+ const t = track.current;
151
+ if (!t || t.pointerId !== e.pointerId)
152
+ return;
153
+ track.current = null;
154
+ if (!t.active)
155
+ return;
156
+ swallowClick.current = true;
157
+ setTimeout(() => {
158
+ swallowClick.current = false;
159
+ }, 50);
160
+ if (cancelled) {
161
+ setDrag(null);
162
+ return;
163
+ }
164
+ settle(t.offset, t.width);
165
+ };
166
+ const onClickCapture = (e) => {
167
+ if (swallowClick.current) {
168
+ swallowClick.current = false;
169
+ e.preventDefault();
170
+ e.stopPropagation();
171
+ }
172
+ else if (isOpen) {
173
+ // A tap on an open row closes it instead of activating it.
174
+ e.preventDefault();
175
+ e.stopPropagation();
176
+ close();
177
+ }
178
+ };
179
+ const offset = drag ?? rest;
180
+ return {
181
+ root,
182
+ offset,
183
+ dragging: drag !== null,
184
+ isOpen,
185
+ openSide: side,
186
+ close,
187
+ surfaceProps: {
188
+ onPointerDown,
189
+ onPointerMove,
190
+ onPointerUp: (e) => finish(e, false),
191
+ onPointerCancel: (e) => finish(e, true),
192
+ onClickCapture,
193
+ },
194
+ };
195
+ }
196
+ /** The revealed buttons of one side. Inert unless that side is open. */
197
+ export function SwipeTray({ side, actions, offset, active, onDone, }) {
198
+ const width = side === 'leading' ? Math.max(offset, 0) : Math.max(-offset, 0);
199
+ return (_jsx("div", { className: "ui-list-tray", "data-side": side, style: { width }, inert: !active || width === 0, "aria-hidden": !active || undefined, children: actions.map((a, i) => (_jsxs("button", { type: "button", className: "ui-list-action", "data-tone": a.tone ?? 'neutral', onClick: () => {
200
+ onDone();
201
+ a.onAction();
202
+ }, children: [a.icon != null && _jsx("span", { "aria-hidden": true, children: a.icon }), _jsx("span", { className: "ui-list-action-label", children: a.label })] }, i))) }));
203
+ }
204
+ /** The same actions as a visible menu button; the way in when the gesture is not offered. */
205
+ export function OverflowMenu({ label, actions, hidden, }) {
206
+ return (_jsxs(ThemedDropdownMenu.Root, { children: [_jsx(ThemedDropdownMenu.Trigger, { children: _jsx("button", { type: "button", className: "ui-list-more", "data-hidden": hidden || undefined, "aria-label": label, children: _jsx(DotsHorizontalIcon, { "aria-hidden": true }) }) }), _jsx(ThemedDropdownMenu.Content, { size: "1", children: actions.map((a, i) => (_jsxs(Fragment, { children: [a.tone === 'danger' && i > 0 && actions[i - 1].tone !== 'danger' ? (_jsx(ThemedDropdownMenu.Separator, {})) : null, _jsx(ThemedDropdownMenu.Item, { color: a.tone === 'danger' ? 'red' : undefined, onSelect: () => a.onAction(), children: a.label })] }, i))) })] }));
207
+ }
@@ -0,0 +1,10 @@
1
+ import type { ButtonHTMLAttributes } from 'react';
2
+ export interface BackButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
3
+ /** Visible text beside the chevron (the previous page's title, usually). */
4
+ label?: string;
5
+ }
6
+ /**
7
+ * A chevron with an optional label. The target is at least 44px square; with no `label` the
8
+ * accessible name comes from `UiStrings.back`.
9
+ */
10
+ export declare function BackButton({ label, className, type, ...rest }: BackButtonProps): import("react").JSX.Element;