@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,110 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { useUiStrings } from '../../strings.js';
4
+ import { cn } from '../../utils/cn.js';
5
+ import { Spinner } from '../Spinner/index.js';
6
+ /** A finger travels this many pixels for each pixel the content moves. */
7
+ const RESISTANCE = 2;
8
+ const query = (q) => typeof window.matchMedia === 'function' && window.matchMedia(q).matches;
9
+ /**
10
+ * Pull-to-refresh for touch screens.
11
+ *
12
+ * **A shortcut, never the only way**: the parent must still offer a visible refresh action,
13
+ * because the gesture is invisible, touch-only and unavailable to switch and keyboard users.
14
+ *
15
+ * Active only on coarse pointers, only when the scroller is at the very top when the touch
16
+ * begins, and only for a downward drag; otherwise the browser's own scroll is untouched.
17
+ * `onRefresh` should handle its own errors — a rejection just ends the wait.
18
+ */
19
+ export function PullToRefresh({ onRefresh, children, scroller = 'document', threshold = 64, maxPull = 112, disabled = false, label, className, }) {
20
+ const strings = useUiStrings();
21
+ const rootRef = useRef(null);
22
+ const [pull, setPull] = useState(0);
23
+ const [dragging, setDragging] = useState(false);
24
+ const [refreshing, setRefreshing] = useState(false);
25
+ const busy = useRef(false);
26
+ const refresh = useRef(onRefresh);
27
+ refresh.current = onRefresh;
28
+ useEffect(() => {
29
+ const root = rootRef.current;
30
+ if (!root || disabled)
31
+ return;
32
+ let startY = 0;
33
+ let tracking = false;
34
+ let current = 0;
35
+ const atTop = () => (scroller === 'self' ? root.scrollTop <= 0 : window.scrollY <= 0);
36
+ const set = (v) => {
37
+ current = v;
38
+ setPull(v);
39
+ };
40
+ const start = (e) => {
41
+ tracking = false;
42
+ if (busy.current || e.touches.length !== 1)
43
+ return;
44
+ if (!query('(pointer: coarse)') || !atTop())
45
+ return;
46
+ startY = e.touches[0].clientY;
47
+ tracking = true;
48
+ };
49
+ const move = (e) => {
50
+ if (!tracking)
51
+ return;
52
+ const dy = e.touches[0].clientY - startY;
53
+ if (dy <= 0 || !atTop()) {
54
+ if (current > 0)
55
+ set(0);
56
+ setDragging(false);
57
+ if (dy <= 0)
58
+ tracking = false;
59
+ return;
60
+ }
61
+ if (e.cancelable)
62
+ e.preventDefault();
63
+ setDragging(true);
64
+ set(Math.min(maxPull, dy / RESISTANCE));
65
+ };
66
+ const end = async () => {
67
+ if (!tracking)
68
+ return;
69
+ tracking = false;
70
+ setDragging(false);
71
+ if (current < threshold)
72
+ return set(0);
73
+ busy.current = true;
74
+ setRefreshing(true);
75
+ set(threshold);
76
+ try {
77
+ await refresh.current();
78
+ }
79
+ catch {
80
+ // The caller owns error reporting; the gesture only ends the wait.
81
+ }
82
+ finally {
83
+ busy.current = false;
84
+ setRefreshing(false);
85
+ set(0);
86
+ }
87
+ };
88
+ const cancel = () => {
89
+ if (!tracking)
90
+ return;
91
+ tracking = false;
92
+ setDragging(false);
93
+ if (!busy.current)
94
+ set(0);
95
+ };
96
+ root.addEventListener('touchstart', start, { passive: true });
97
+ root.addEventListener('touchmove', move, { passive: false });
98
+ root.addEventListener('touchend', end);
99
+ root.addEventListener('touchcancel', cancel);
100
+ return () => {
101
+ root.removeEventListener('touchstart', start);
102
+ root.removeEventListener('touchmove', move);
103
+ root.removeEventListener('touchend', end);
104
+ root.removeEventListener('touchcancel', cancel);
105
+ };
106
+ }, [disabled, scroller, threshold, maxPull]);
107
+ const progress = Math.min(1, pull / threshold);
108
+ const name = label ?? strings.refreshing;
109
+ return (_jsxs("div", { ref: rootRef, "aria-busy": refreshing || undefined, className: cn('relative', scroller === 'self' && 'overflow-y-auto overscroll-y-contain', className), children: [_jsx("div", { className: "pointer-events-none absolute inset-x-0 top-0 flex items-center justify-center overflow-hidden", style: { height: pull, opacity: refreshing ? 1 : progress }, children: refreshing ? (_jsx(Spinner, { label: name })) : pull > 0 ? (_jsx("span", { "aria-hidden": "true", className: "size-5 rounded-full border-2 border-[var(--gray-8)] border-t-[var(--accent-9)] motion-reduce:rotate-0!", style: { transform: `rotate(${progress * 270}deg)` } })) : null }), _jsx("div", { className: cn(!dragging && 'transition-transform duration-200 ease-out motion-reduce:transition-none'), style: { transform: pull ? `translateY(${pull}px)` : undefined }, children: children })] }));
110
+ }
@@ -0,0 +1 @@
1
+ export * from './PullToRefresh.js';
@@ -0,0 +1 @@
1
+ export * from './PullToRefresh.js';
@@ -0,0 +1,13 @@
1
+ import { type DialogProps } from '../Dialog/Dialog.js';
2
+ import { type SheetDetent } from '../Sheet/Sheet.js';
3
+ export interface ResponsiveDialogProps extends DialogProps {
4
+ /** The detent the sheet opens at below `md`. Default `fit`. */
5
+ sheetDetent?: SheetDetent;
6
+ }
7
+ /**
8
+ * The same props as {@link Dialog}, presented the way the screen asks: a centred dialog on a
9
+ * desktop, a bottom sheet on a phone. Both sit on Radix Dialog, so the role, the focus trap,
10
+ * Escape and the labelling are the same either way. Crossing the breakpoint while open remounts
11
+ * the content, which loses uncommitted state inside `children` — keep form state in the parent.
12
+ */
13
+ export declare function ResponsiveDialog({ sheetDetent, ...props }: ResponsiveDialogProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from 'react';
3
+ import { Dialog } from '../Dialog/Dialog.js';
4
+ import { Sheet } from '../Sheet/Sheet.js';
5
+ /** Tailwind's `md`. Below it the dialog becomes a bottom sheet. */
6
+ const MD = '(min-width: 768px)';
7
+ function subscribe(onChange) {
8
+ const query = window.matchMedia(MD);
9
+ query.addEventListener('change', onChange);
10
+ return () => query.removeEventListener('change', onChange);
11
+ }
12
+ /** A Dialog at `md` and up, a Sheet below. Without `matchMedia` (SSR) the Dialog. */
13
+ function useIsMdUp() {
14
+ return useSyncExternalStore(subscribe, () => window.matchMedia(MD).matches, () => true);
15
+ }
16
+ /**
17
+ * The same props as {@link Dialog}, presented the way the screen asks: a centred dialog on a
18
+ * desktop, a bottom sheet on a phone. Both sit on Radix Dialog, so the role, the focus trap,
19
+ * Escape and the labelling are the same either way. Crossing the breakpoint while open remounts
20
+ * the content, which loses uncommitted state inside `children` — keep form state in the parent.
21
+ */
22
+ export function ResponsiveDialog({ sheetDetent = 'fit', ...props }) {
23
+ const mdUp = useIsMdUp();
24
+ if (mdUp)
25
+ return _jsx(Dialog, { ...props });
26
+ return (_jsx(Sheet, { open: props.open, onOpenChange: props.onOpenChange, title: props.title, description: props.description, footer: props.footer, className: props.className, detent: sheetDetent, showCloseButton: true, children: props.children }));
27
+ }
@@ -0,0 +1 @@
1
+ export * from './ResponsiveDialog.js';
@@ -0,0 +1 @@
1
+ export * from './ResponsiveDialog.js';
@@ -0,0 +1,17 @@
1
+ import type { HTMLAttributes } from 'react';
2
+ export type ScrollStripBreakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
3
+ export interface ScrollStripProps extends HTMLAttributes<HTMLDivElement> {
4
+ /** From this breakpoint the strip stops scrolling and lays its children out as a grid. */
5
+ gridFrom?: ScrollStripBreakpoint;
6
+ /** Grid columns from `gridFrom`. */
7
+ columns?: number;
8
+ /** Width of each item while scrolling, so the next one peeks at the edge. Any CSS length. */
9
+ itemWidth?: string;
10
+ }
11
+ /**
12
+ * A horizontal scroll-snap row that becomes a grid at a breakpoint. Items snap to the start edge,
13
+ * the scroll padding equals the page gutter (`--page-gutter`, 16px unless the app sets it) so a
14
+ * snapped item lines up with the content above it, and the scrollbar is hidden. Items default to
15
+ * 78% of the strip so the next one shows. Use it for KPI rows and card carousels.
16
+ */
17
+ export declare function ScrollStrip({ gridFrom, columns, itemWidth, className, style, ...props }: ScrollStripProps): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from '../../utils/cn.js';
3
+ /**
4
+ * A horizontal scroll-snap row that becomes a grid at a breakpoint. Items snap to the start edge,
5
+ * the scroll padding equals the page gutter (`--page-gutter`, 16px unless the app sets it) so a
6
+ * snapped item lines up with the content above it, and the scrollbar is hidden. Items default to
7
+ * 78% of the strip so the next one shows. Use it for KPI rows and card carousels.
8
+ */
9
+ export function ScrollStrip({ gridFrom = 'md', columns = 3, itemWidth, className, style, ...props }) {
10
+ return (_jsx("div", { className: cn('ui-strip', className), "data-grid-from": gridFrom, style: {
11
+ '--strip-cols': columns,
12
+ ...(itemWidth ? { '--strip-item': itemWidth } : null),
13
+ ...style,
14
+ }, ...props }));
15
+ }
@@ -0,0 +1 @@
1
+ export * from './ScrollStrip.js';
@@ -0,0 +1 @@
1
+ export * from './ScrollStrip.js';
@@ -0,0 +1,23 @@
1
+ import { type ReactNode } from 'react';
2
+ export interface SegmentedControlOption {
3
+ value: string;
4
+ label: ReactNode;
5
+ disabled?: boolean;
6
+ }
7
+ export interface SegmentedControlProps {
8
+ /** 2–5 options. */
9
+ options: SegmentedControlOption[];
10
+ value: string;
11
+ onValueChange?: (value: string) => void;
12
+ size?: 'sm' | 'md';
13
+ /** Stretch the track to its container; segments are always equal width. */
14
+ fullWidth?: boolean;
15
+ 'aria-label'?: string;
16
+ 'aria-labelledby'?: string;
17
+ className?: string;
18
+ }
19
+ /**
20
+ * A pill track with a sliding thumb: one choice from 2–5. Radio semantics with a roving tabindex —
21
+ * arrow keys move and select (wrapping, skipping disabled), Home/End jump to the ends.
22
+ */
23
+ export declare function SegmentedControl({ options, value, onValueChange, size, fullWidth, className, ...aria }: SegmentedControlProps): import("react").JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from 'react';
3
+ /**
4
+ * A pill track with a sliding thumb: one choice from 2–5. Radio semantics with a roving tabindex —
5
+ * arrow keys move and select (wrapping, skipping disabled), Home/End jump to the ends.
6
+ */
7
+ export function SegmentedControl({ options, value, onValueChange, size = 'md', fullWidth = false, className, ...aria }) {
8
+ const refs = useRef([]);
9
+ const count = options.length;
10
+ const active = options.findIndex((o) => o.value === value);
11
+ const tabbable = active >= 0 && !options[active].disabled ? active : options.findIndex((o) => !o.disabled);
12
+ const select = (to) => {
13
+ refs.current[to]?.focus();
14
+ onValueChange?.(options[to].value);
15
+ };
16
+ const step = (from, dir) => {
17
+ let i = from;
18
+ for (let n = 0; n < count; n++) {
19
+ i = (i + dir + count) % count;
20
+ if (!options[i].disabled)
21
+ return select(i);
22
+ }
23
+ };
24
+ const onKeyDown = (e, i) => {
25
+ const enabled = options.map((o, k) => (o.disabled ? -1 : k)).filter((k) => k >= 0);
26
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown')
27
+ step(i, 1);
28
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
29
+ step(i, -1);
30
+ else if (e.key === 'Home')
31
+ select(enabled[0]);
32
+ else if (e.key === 'End')
33
+ select(enabled[enabled.length - 1]);
34
+ else
35
+ return;
36
+ e.preventDefault();
37
+ };
38
+ return (_jsxs("div", { role: "radiogroup", className: ['ui-segmented', className].filter(Boolean).join(' '), "data-size": size, "data-full": fullWidth ? 'true' : undefined, style: { '--seg-count': count, '--seg-index': Math.max(active, 0) }, ...aria, children: [active >= 0 && _jsx("span", { className: "ui-segmented__thumb", "aria-hidden": "true" }), options.map((o, i) => (_jsx("button", { ref: (el) => {
39
+ refs.current[i] = el;
40
+ }, type: "button", role: "radio", "aria-checked": i === active, disabled: o.disabled, tabIndex: i === tabbable ? 0 : -1, className: "ui-segmented__item", onClick: () => onValueChange?.(o.value), onKeyDown: (e) => onKeyDown(e, i), children: o.label }, o.value)))] }));
41
+ }
@@ -0,0 +1 @@
1
+ export * from './SegmentedControl.js';
@@ -0,0 +1 @@
1
+ export * from './SegmentedControl.js';
@@ -12,5 +12,5 @@ import { cn } from '../../utils/cn.js';
12
12
  */
13
13
  export const Select = forwardRef(function Select({ value, onValueChange, options, placeholder, invalid = false, disabled, required, id, name, className, ...props }, ref) {
14
14
  const strings = useUiStrings();
15
- return (_jsxs(ThemedSelect.Root, { value: value, onValueChange: onValueChange, disabled: disabled, required: required, name: name, children: [_jsx(ThemedSelect.Trigger, { ref: ref, id: id, placeholder: placeholder ?? strings.select, color: invalid ? 'red' : undefined, "aria-invalid": invalid || undefined, className: cn('w-full', className), ...props }), _jsx(ThemedSelect.Content, { position: "popper", children: options.map((option) => (_jsx(ThemedSelect.Item, { value: option.value, children: option.label }, option.value))) })] }));
15
+ return (_jsxs(ThemedSelect.Root, { value: value, onValueChange: onValueChange, disabled: disabled, required: required, name: name, children: [_jsx(ThemedSelect.Trigger, { ref: ref, id: id, placeholder: placeholder ?? strings.select, color: invalid ? 'red' : undefined, "aria-invalid": invalid || undefined, className: cn('ui-select-trigger w-full', className), ...props }), _jsx(ThemedSelect.Content, { position: "popper", children: options.map((option) => (_jsx(ThemedSelect.Item, { value: option.value, children: option.label }, option.value))) })] }));
16
16
  });
@@ -0,0 +1,63 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Resting heights of a sheet. `medium` is half the visible viewport, `large` nearly all of it, and
4
+ * `fit` is the content's own height, capped at 90% of the viewport.
5
+ */
6
+ export type SheetDetent = 'medium' | 'large' | 'fit';
7
+ export interface SheetProps {
8
+ open: boolean;
9
+ onOpenChange: (open: boolean) => void;
10
+ /** Labels the sheet (`aria-labelledby`). */
11
+ title: ReactNode;
12
+ /** Optional, wired up via `aria-describedby`. */
13
+ description?: ReactNode;
14
+ /** The body. Scrolls inside the sheet; a drag down from its top moves the sheet instead. */
15
+ children?: ReactNode;
16
+ /** Action row pinned below the body, above the bottom safe area and above the keyboard. */
17
+ footer?: ReactNode;
18
+ /** Controlled resting detent. */
19
+ detent?: SheetDetent;
20
+ /** Initial detent when uncontrolled. Default `fit`. */
21
+ defaultDetent?: SheetDetent;
22
+ /** Called when a drag or the handle moves the sheet to another detent. */
23
+ onDetentChange?: (detent: SheetDetent) => void;
24
+ /**
25
+ * The detents the sheet may rest at, shortest first (`fit` < `medium` < `large`). Dragging up
26
+ * moves to the next, down to the previous, and down from the lowest dismisses. Default: just
27
+ * the current detent, so the sheet dismisses or springs back and never resizes.
28
+ */
29
+ detents?: SheetDetent[];
30
+ /**
31
+ * Scale and round the page behind while the sheet is at `large`, as iOS does. The page is
32
+ * everything beside the sheet inside the same Theme. Note that a transformed ancestor becomes
33
+ * the containing block of any `position: fixed` descendant, so fixed bars in the page scale too.
34
+ */
35
+ scaleBackground?: boolean;
36
+ /** Whether a drag down, Escape, the scrim or the close button may close it. Default `true`. */
37
+ dismissible?: boolean;
38
+ /** Render a close button in the header. Default `false`. */
39
+ showCloseButton?: boolean;
40
+ /** Overrides the close button's accessible name (otherwise the `close` UI string). */
41
+ closeLabel?: string;
42
+ /** Overrides the handle's accessible name when it raises the sheet (otherwise `expand`). */
43
+ expandLabel?: string;
44
+ /** Overrides the handle's accessible name when it lowers the sheet (otherwise `collapse`). */
45
+ collapseLabel?: string;
46
+ /** Caps the width on wider screens. Default `max-w-lg`; pass `max-w-none` for full width. */
47
+ maxWidthClassName?: string;
48
+ className?: string;
49
+ /** Where the portal mounts. Default: the nearest `<Theme>` (`.radix-themes`), else rendered in place. */
50
+ container?: HTMLElement | null;
51
+ }
52
+ /**
53
+ * A native-feeling bottom sheet on Radix Dialog.
54
+ *
55
+ * Radix owns what must not be reimplemented: the focus trap and its return on close, Escape,
56
+ * the scrim, `aria-modal`, and the page behind being hidden from assistive technology. This adds
57
+ * the sheet: a drag handle, detents, velocity-based dismissal, a body that scrolls until it is at
58
+ * its top and only then hands the gesture to the sheet, a footer that stays above the safe area
59
+ * and the keyboard, and an optional iOS-style scaled page behind the `large` detent.
60
+ *
61
+ * Like every floating atom here it portals into the nearest `<Theme>` rather than `<body>`.
62
+ */
63
+ export declare function Sheet({ open, onOpenChange, title, description, children, footer, detent: detentProp, defaultDetent, onDetentChange, detents, scaleBackground, dismissible, showCloseButton, closeLabel, expandLabel, collapseLabel, maxWidthClassName, className, container, }: SheetProps): import("react").JSX.Element;
@@ -0,0 +1,261 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as RadixDialog from '@radix-ui/react-dialog';
3
+ import { Theme } from '@radix-ui/themes';
4
+ import { useCallback, useEffect, useRef, useState, } from 'react';
5
+ import { useUiStrings } from '../../strings.js';
6
+ import { cn } from '../../utils/cn.js';
7
+ import { useThemePortalTarget } from '../../utils/themePortal.js';
8
+ const RANK = { fit: 0, medium: 1, large: 2 };
9
+ /** Share of the visible viewport each detent may take. */
10
+ const SHARE = { fit: 0.9, medium: 0.5, large: 0.92 };
11
+ /** Pixels per millisecond that commit a flick regardless of distance. */
12
+ const VELOCITY = 0.5;
13
+ /** Pixels of upward travel that commit a drag regardless of speed. */
14
+ const RAISE_DISTANCE = 60;
15
+ /** Fraction of the sheet's height a downward drag must cover to commit. */
16
+ const LOWER_FRACTION = 0.25;
17
+ function useControllable(value, initial, onChange) {
18
+ const [inner, setInner] = useState(initial);
19
+ const controlled = value !== undefined;
20
+ const set = useCallback((next) => {
21
+ if (!controlled)
22
+ setInner(next);
23
+ onChange?.(next);
24
+ }, [controlled, onChange]);
25
+ return [controlled ? value : inner, set];
26
+ }
27
+ /**
28
+ * The visible viewport: its height, and how far the on-screen keyboard covers the layout viewport.
29
+ *
30
+ * On Android Chrome the keyboard resizes the layout viewport, so `kb` stays 0 and the sheet's
31
+ * `bottom: 0` is already above it. On iOS Safari it only shrinks the *visual* viewport, which is
32
+ * where `innerHeight - height - offsetTop` comes from. Either way `height` is what is left.
33
+ */
34
+ function useVisibleViewport(active, contentRef) {
35
+ const [vv, setVv] = useState(null);
36
+ useEffect(() => {
37
+ const viewport = window.visualViewport;
38
+ if (!active || !viewport)
39
+ return;
40
+ const read = () => {
41
+ const kb = Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop));
42
+ setVv({ height: Math.round(viewport.height), kb });
43
+ // Keep the focused field on screen once the layout has caught up with the keyboard.
44
+ const focused = document.activeElement;
45
+ if (focused && contentRef.current?.contains(focused) && focused !== contentRef.current) {
46
+ requestAnimationFrame(() => focused.scrollIntoView?.({ block: 'nearest' }));
47
+ }
48
+ };
49
+ read();
50
+ viewport.addEventListener('resize', read);
51
+ viewport.addEventListener('scroll', read);
52
+ return () => {
53
+ viewport.removeEventListener('resize', read);
54
+ viewport.removeEventListener('scroll', read);
55
+ setVv(null);
56
+ };
57
+ }, [active, contentRef]);
58
+ return vv;
59
+ }
60
+ /**
61
+ * A native-feeling bottom sheet on Radix Dialog.
62
+ *
63
+ * Radix owns what must not be reimplemented: the focus trap and its return on close, Escape,
64
+ * the scrim, `aria-modal`, and the page behind being hidden from assistive technology. This adds
65
+ * the sheet: a drag handle, detents, velocity-based dismissal, a body that scrolls until it is at
66
+ * its top and only then hands the gesture to the sheet, a footer that stays above the safe area
67
+ * and the keyboard, and an optional iOS-style scaled page behind the `large` detent.
68
+ *
69
+ * Like every floating atom here it portals into the nearest `<Theme>` rather than `<body>`.
70
+ */
71
+ export function Sheet({ open, onOpenChange, title, description, children, footer, detent: detentProp, defaultDetent = 'fit', onDetentChange, detents, scaleBackground = false, dismissible = true, showCloseButton = false, closeLabel, expandLabel, collapseLabel, maxWidthClassName = 'max-w-lg', className, container, }) {
72
+ const strings = useUiStrings();
73
+ const [detent, setDetent] = useControllable(detentProp, defaultDetent, onDetentChange);
74
+ const [offset, setOffset] = useState(0);
75
+ const [dragging, setDragging] = useState(false);
76
+ const drag = useRef(null);
77
+ const suppressClick = useRef(false);
78
+ const contentRef = useRef(null);
79
+ const bodyRef = useRef(null);
80
+ const rootRef = useRef(null);
81
+ const { anchorRef, target, resolved } = useThemePortalTarget(container);
82
+ const vv = useVisibleViewport(open, contentRef);
83
+ const ladder = [...new Set([...(detents ?? []), detent])].sort((a, b) => RANK[a] - RANK[b]);
84
+ const index = ladder.indexOf(detent);
85
+ const higher = ladder[index + 1];
86
+ const lower = ladder[index - 1];
87
+ const requestClose = useCallback(() => onOpenChange(false), [onOpenChange]);
88
+ // The page behind scales while the sheet is at `large`; everything is put back on the way out.
89
+ const scaled = open && scaleBackground && detent === 'large';
90
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `resolved` is when the content first mounts.
91
+ useEffect(() => {
92
+ if (!scaled || !rootRef.current)
93
+ return;
94
+ // rootRef sits inside the nested Theme; its parent is the element the portal appended.
95
+ const mine = rootRef.current.parentElement;
96
+ const host = mine?.parentElement;
97
+ if (!mine || !host)
98
+ return;
99
+ const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
100
+ const pages = Array.from(host.children).filter((el) => el instanceof HTMLElement && el !== mine && !el.hasAttribute('data-sheet-root'));
101
+ const saved = pages.map((el) => el.getAttribute('style'));
102
+ for (const el of pages) {
103
+ el.style.transformOrigin = 'top center';
104
+ el.style.transform = 'translateY(calc(var(--safe-top, 0px) + 8px)) scale(0.94)';
105
+ el.style.borderRadius = '12px';
106
+ el.style.overflow = 'hidden';
107
+ el.style.filter = 'brightness(0.85)';
108
+ if (!reduce)
109
+ el.style.transition = 'transform 250ms ease-out, filter 250ms ease-out';
110
+ }
111
+ return () => {
112
+ pages.forEach((el, i) => {
113
+ if (saved[i] === null)
114
+ el.removeAttribute('style');
115
+ else
116
+ el.setAttribute('style', saved[i]);
117
+ });
118
+ };
119
+ }, [scaled, resolved]);
120
+ // A body that is scrolled to its top and pulled down would normally start a native overscroll
121
+ // and cancel the pointer stream; cancelling the touch lets the sheet take the gesture instead.
122
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `resolved` is when the body first mounts.
123
+ useEffect(() => {
124
+ const body = bodyRef.current;
125
+ if (!open || !body)
126
+ return;
127
+ let startY = 0;
128
+ const onStart = (e) => {
129
+ startY = e.touches[0]?.clientY ?? 0;
130
+ };
131
+ const onMove = (e) => {
132
+ const y = e.touches[0]?.clientY ?? 0;
133
+ if (e.cancelable && body.scrollTop <= 0 && y > startY)
134
+ e.preventDefault();
135
+ };
136
+ body.addEventListener('touchstart', onStart, { passive: true });
137
+ body.addEventListener('touchmove', onMove, { passive: false });
138
+ return () => {
139
+ body.removeEventListener('touchstart', onStart);
140
+ body.removeEventListener('touchmove', onMove);
141
+ };
142
+ }, [open, resolved]);
143
+ const begin = (e, fromBody) => {
144
+ if (e.button !== 0)
145
+ return;
146
+ if (fromBody && (bodyRef.current?.scrollTop ?? 0) > 0)
147
+ return;
148
+ drag.current = {
149
+ pointerId: e.pointerId,
150
+ startY: e.clientY,
151
+ lastY: e.clientY,
152
+ lastT: e.timeStamp,
153
+ velocity: 0,
154
+ moved: false,
155
+ fromBody,
156
+ };
157
+ };
158
+ const move = (e) => {
159
+ const d = drag.current;
160
+ if (!d || d.pointerId !== e.pointerId)
161
+ return;
162
+ const dy = e.clientY - d.startY;
163
+ if (!d.moved) {
164
+ if (Math.abs(dy) < 4)
165
+ return;
166
+ // Scrolling the content up is the content's business, not the sheet's.
167
+ if (d.fromBody && dy < 0) {
168
+ drag.current = null;
169
+ return;
170
+ }
171
+ d.moved = true;
172
+ setDragging(true);
173
+ e.currentTarget.setPointerCapture?.(e.pointerId);
174
+ }
175
+ // At least one frame, so two events in the same tick do not read as an infinite flick.
176
+ const dt = Math.max(16, e.timeStamp - d.lastT);
177
+ d.velocity = (e.clientY - d.lastY) / dt;
178
+ d.lastY = e.clientY;
179
+ d.lastT = e.timeStamp;
180
+ // Pulling up resists; pulling down past a sheet that cannot close resists too.
181
+ const resists = dy < 0 || (!lower && !dismissible);
182
+ setOffset(resists ? Math.max(dy / 4, -40) : dy);
183
+ };
184
+ const end = (e) => {
185
+ const d = drag.current;
186
+ if (!d || d.pointerId !== e.pointerId)
187
+ return;
188
+ drag.current = null;
189
+ if (!d.moved)
190
+ return;
191
+ suppressClick.current = true;
192
+ // The click that follows a drag is swallowed; if none arrives, do not eat the next real one.
193
+ setTimeout(() => {
194
+ suppressClick.current = false;
195
+ }, 0);
196
+ const dy = e.clientY - d.startY;
197
+ const height = contentRef.current?.getBoundingClientRect().height || 0;
198
+ const up = dy < -RAISE_DISTANCE || d.velocity < -VELOCITY;
199
+ const down = dy > height * LOWER_FRACTION || d.velocity > VELOCITY;
200
+ setDragging(false);
201
+ if (down && dy > 0 && lower) {
202
+ setOffset(0);
203
+ setDetent(lower);
204
+ }
205
+ else if (down && dy > 0 && dismissible) {
206
+ // Leave the offset where the finger let go: the exit animation starts from it.
207
+ requestClose();
208
+ }
209
+ else {
210
+ setOffset(0);
211
+ if (up && higher)
212
+ setDetent(higher);
213
+ }
214
+ };
215
+ const cancel = () => {
216
+ drag.current = null;
217
+ setDragging(false);
218
+ setOffset(0);
219
+ };
220
+ const onHandleClick = () => {
221
+ if (suppressClick.current) {
222
+ suppressClick.current = false;
223
+ return;
224
+ }
225
+ // Keyboard and pointer alike: raise until the top, then lower back down.
226
+ if (higher)
227
+ setDetent(higher);
228
+ else if (lower)
229
+ setDetent(lower);
230
+ };
231
+ const dragHandlers = { onPointerMove: move, onPointerUp: end, onPointerCancel: cancel };
232
+ const cycles = ladder.length > 1;
233
+ const raising = !!higher;
234
+ const keyboard = (vv?.kb ?? 0) > 0;
235
+ const viewport = vv ? `${vv.height}px` : '100dvh';
236
+ const style = {
237
+ '--sheet-vh': viewport,
238
+ '--sheet-drag': `${offset}px`,
239
+ bottom: vv?.kb ? `${vv.kb}px` : undefined,
240
+ height: detent === 'fit' ? undefined : `calc(var(--sheet-vh) * ${SHARE[detent]})`,
241
+ maxHeight: `calc(var(--sheet-vh) * ${SHARE[detent]})`,
242
+ };
243
+ const dismissGuard = dismissible
244
+ ? undefined
245
+ : (e) => {
246
+ e.preventDefault();
247
+ };
248
+ const content = (_jsxs("div", { ref: rootRef, "data-sheet-root": "", className: "contents", children: [_jsx(RadixDialog.Overlay, { "data-testid": "sheet-scrim", className: "ui-sheet-scrim fixed inset-0 z-40 bg-scrim" }), _jsxs(RadixDialog.Content, { ref: contentRef, "data-detent": detent, "data-dragging": dragging ? '' : undefined, ...(description ? {} : { 'aria-describedby': undefined }), onEscapeKeyDown: dismissGuard, onPointerDownOutside: dismissGuard, onInteractOutside: dismissGuard,
249
+ // The sheet moves focus to its first control; for a sheet that is the handle or close
250
+ // button, which is correct, so Radix's default is kept.
251
+ style: style, className: cn('ui-sheet fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full flex-col overflow-hidden', 'rounded-t-2xl border border-b-0 border-hairline bg-sheet text-ink shadow-2xl outline-none', maxWidthClassName, className), children: [_jsxs("div", { className: "touch-none select-none px-5 pt-2", onPointerDown: (e) => begin(e, false), ...dragHandlers, children: [cycles ? (_jsx("button", { type: "button", "aria-label": raising ? (expandLabel ?? strings.expand) : (collapseLabel ?? strings.collapse), 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" }) })) : (_jsx("div", { "aria-hidden": "true", className: "mx-auto flex h-6 w-16 items-center justify-center", children: _jsx("span", { 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(RadixDialog.Title, { className: "m-0 text-base font-medium text-brand-900", children: title }), description ? (_jsx(RadixDialog.Description, { className: "m-0 mt-1 text-sm text-ink-muted", children: description })) : null] }), showCloseButton && dismissible ? (_jsx(RadixDialog.Close, { "aria-label": closeLabel ?? strings.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] })] }), _jsx("div", { ref: bodyRef, "data-sheet-body": "", className: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-4", onPointerDown: (e) => begin(e, true), ...dragHandlers, children: children }), footer ? (_jsx("div", { "data-sheet-footer": "", className: "flex shrink-0 justify-end gap-2 border-t border-hairline px-5 pt-3",
252
+ // Over the keyboard there is no home indicator to clear, only a gap to leave.
253
+ style: {
254
+ paddingBottom: keyboard ? '0.75rem' : 'max(0.75rem, var(--safe-bottom, 0px))',
255
+ }, children: footer })) : (_jsx("div", { "aria-hidden": "true", className: "shrink-0", style: { height: keyboard ? 0 : 'var(--safe-bottom, 0px)' } }))] })] }));
256
+ // A hidden marker keeps the component's DOM position so the portal can find its Theme.
257
+ const anchor = _jsx("span", { ref: anchorRef, hidden: true, "data-sheet-anchor": "" });
258
+ if (!resolved)
259
+ return anchor;
260
+ return (_jsxs(RadixDialog.Root, { open: open, onOpenChange: onOpenChange, children: [anchor, target ? (_jsx(RadixDialog.Portal, { container: target, children: _jsx(Theme, { hasBackground: false, children: content }) })) : (content)] }));
261
+ }
@@ -0,0 +1 @@
1
+ export * from './Sheet.js';
@@ -0,0 +1 @@
1
+ export * from './Sheet.js';