@kennycorrea/ui 0.8.0 → 0.9.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.
- package/CHANGELOG.md +36 -0
- package/README.md +58 -0
- package/dist/atoms/Badge/Badge.d.ts +9 -2
- package/dist/atoms/Badge/Badge.js +15 -2
- package/dist/atoms/Drawer/Drawer.d.ts +62 -0
- package/dist/atoms/Drawer/Drawer.js +190 -0
- package/dist/atoms/Drawer/index.d.ts +1 -0
- package/dist/atoms/Drawer/index.js +1 -0
- package/dist/atoms/Toast/Toast.js +2 -1
- package/dist/atoms/index.d.ts +1 -0
- package/dist/atoms/index.js +1 -0
- package/dist/strings.d.ts +6 -0
- package/dist/strings.js +3 -0
- package/dist/utils/scales.d.ts +9 -0
- package/dist/utils/scales.js +30 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 — 2026-10-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **`Drawer`**, a bottom sheet with `collapsed` and `expanded` snap points, in two modes:
|
|
7
|
+
`modal` (scrim, focus trapped, page `inert`, Escape and scrim close) and `non-modal` (a
|
|
8
|
+
floating banner; the page stays usable). Drag the header with touch or mouse to change snap,
|
|
9
|
+
or past collapsed to close when `dismissible`. The handle is a real button with
|
|
10
|
+
`aria-expanded`/`aria-controls`. Controlled or uncontrolled `open` and `snap`.
|
|
11
|
+
- **`UiStrings`: `close`, `expand`, `collapse`**, used by the Drawer's close button and handle.
|
|
12
|
+
English defaults; the `closeLabel`, `expandLabel` and `collapseLabel` props override them.
|
|
13
|
+
- `examples/Drawers.tsx`: a non-modal promo banner and a modal form sheet.
|
|
14
|
+
|
|
15
|
+
### Not verified
|
|
16
|
+
- Only exercised in jsdom with simulated pointer events, not on a real touch device.
|
|
17
|
+
|
|
18
|
+
## 0.8.1 — 2026-10-11
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **`Badge` text passes WCAG AA in both appearances.** Soft badges wrote their text in step a11
|
|
22
|
+
on an a3 wash, which fails 4.5:1 in light for `success` (jade, 4.27:1), `warning` (amber,
|
|
23
|
+
4.25:1), blue and several accents a business can pick for `brand`. Every variant is now
|
|
24
|
+
`highContrast` (step 12), which clears 9.8:1 on every Radix scale in light and dark. `Toast`
|
|
25
|
+
already passed (lowest: amber action text at 4.61:1) and is unchanged.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **`test/contrast.test.tsx`**: every `Badge` variant (`brand` against every Radix accent) and
|
|
29
|
+
every `Toast` type, light and dark, computed from Radix's own token files. It fails if
|
|
30
|
+
`highContrast` is dropped. Not axe: jsdom applies no stylesheet, so axe's colour-contrast rule
|
|
31
|
+
can only report "incomplete" there; this checks the same pairs it would check in a browser.
|
|
32
|
+
- **A dev-only warning when a needed colour scale is missing.** The first `Badge` or `Toaster`
|
|
33
|
+
to mount checks for `--{gray,jade,amber,red,blue}-9` and names the missing
|
|
34
|
+
`@radix-ui/themes/tokens/colors/*.css` imports.
|
|
35
|
+
|
|
36
|
+
### Docs
|
|
37
|
+
- README: the colour scales the atoms need, and their order relative to `base.css`.
|
|
38
|
+
|
|
3
39
|
## 0.8.0 — 2026-10-09
|
|
4
40
|
|
|
5
41
|
### Added
|
package/README.md
CHANGED
|
@@ -63,6 +63,29 @@ And in the app's own `index.css`:
|
|
|
63
63
|
every utility class used *inside* the atoms — `w-full`, `inline-flex` — is missing from the
|
|
64
64
|
generated CSS. The components come out subtly unstyled and nothing appears in the console.
|
|
65
65
|
|
|
66
|
+
### Colour scales — required
|
|
67
|
+
|
|
68
|
+
The atoms name five Radix scales: **gray**, **jade** (`Badge` success, `Toast` success),
|
|
69
|
+
**amber** (warning), **red** (danger/error) and **blue** (`Toast`/`Alert` info). If the app
|
|
70
|
+
imports Radix Themes piecemeal rather than the whole `@radix-ui/themes/styles.css`, every one of
|
|
71
|
+
them must be imported, **after `base.css`** as Radix documents it, plus the app's own accent:
|
|
72
|
+
|
|
73
|
+
```css
|
|
74
|
+
@import '@radix-ui/themes/tokens/base.css';
|
|
75
|
+
@import '@radix-ui/themes/tokens/colors/gray.css';
|
|
76
|
+
@import '@radix-ui/themes/tokens/colors/jade.css';
|
|
77
|
+
@import '@radix-ui/themes/tokens/colors/amber.css';
|
|
78
|
+
@import '@radix-ui/themes/tokens/colors/red.css';
|
|
79
|
+
@import '@radix-ui/themes/tokens/colors/blue.css';
|
|
80
|
+
/* …the accent the app offers, if not one of the above */
|
|
81
|
+
@import '@radix-ui/themes/components.css';
|
|
82
|
+
@import '@radix-ui/themes/utilities.css';
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A missing scale is not an error to CSS — the variables are just undefined, and badges and toasts
|
|
86
|
+
render colourless. Since 0.8.1 the first `Badge` or `Toaster` to mount checks for them and
|
|
87
|
+
`console.warn`s the missing imports by name, outside production builds.
|
|
88
|
+
|
|
66
89
|
## Theming — 0.2.0
|
|
67
90
|
|
|
68
91
|
**A product configures its own accents, default and storage namespace.** Until 0.2.0 all three
|
|
@@ -287,3 +310,38 @@ is in `examples/BudgetCycleSlider.tsx`.
|
|
|
287
310
|
|
|
288
311
|
⚠ `--ui-slider-thumb-size` mirrors Themes' size-2 thumb (`--space-2 + --space-1`). The fill
|
|
289
312
|
and the marks are placed from it, so if Themes ever resizes its thumb, change it there.
|
|
313
|
+
|
|
314
|
+
## Drawer — bottom sheet
|
|
315
|
+
|
|
316
|
+
A sheet anchored to the bottom of the viewport: full width on phones, `max-w-lg` and centred
|
|
317
|
+
above that (`maxWidthClassName` to change it), safe-area aware, themed from the house tokens.
|
|
318
|
+
|
|
319
|
+
```tsx
|
|
320
|
+
// Non-modal: a floating banner, the page stays usable, no focus trap.
|
|
321
|
+
<Drawer mode="non-modal" title="Take deposits" peek={<p>Swipe up to see how.</p>}
|
|
322
|
+
footer={<Button>Get started</Button>}>
|
|
323
|
+
<ol>…steps…</ol>
|
|
324
|
+
</Drawer>
|
|
325
|
+
|
|
326
|
+
// Modal: scrim, focus trapped, page inert, Escape and the scrim close it.
|
|
327
|
+
<Drawer open={open} onOpenChange={setOpen} defaultSnap="expanded" title="New client"
|
|
328
|
+
showCloseButton footer={<Button type="submit" form="f">Save</Button>}>
|
|
329
|
+
<form id="f">…</form>
|
|
330
|
+
</Drawer>
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
- **Snap points** `collapsed` (header + `peek`) and `expanded` (body, scrolls inside, up to
|
|
334
|
+
80vh). Controlled with `snap`/`onSnapChange`, or uncontrolled from `defaultSnap`; likewise
|
|
335
|
+
`open`/`onOpenChange`/`defaultOpen`.
|
|
336
|
+
- **Drag** the header with touch or mouse: it follows the pointer, then settles past 60px or a
|
|
337
|
+
flick faster than 0.5px/ms. Down from collapsed closes when `dismissible` (the default).
|
|
338
|
+
A drag from inside the body counts only when the body is scrolled to the top and moving down.
|
|
339
|
+
- **The handle is a real button** with `aria-expanded`/`aria-controls`, so keyboard and screen
|
|
340
|
+
reader users toggle it the same way.
|
|
341
|
+
- **No words of its own.** The handle and close button names come from `expandLabel`,
|
|
342
|
+
`collapseLabel` and `closeLabel`, or the new `expand`, `collapse` and `close` keys of
|
|
343
|
+
`UiStringsProvider`.
|
|
344
|
+
- Motion is a CSS transform, switched off under `prefers-reduced-motion`.
|
|
345
|
+
|
|
346
|
+
Built by hand rather than on Radix Dialog because the non-modal half must not do what Dialog
|
|
347
|
+
does to the rest of the page. Both examples are in `examples/Drawers.tsx`.
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Badge as ThemedBadge } from '@radix-ui/themes';
|
|
2
|
-
import type { ComponentPropsWithoutRef } from 'react';
|
|
2
|
+
import type { ComponentPropsWithoutRef, Ref } from 'react';
|
|
3
3
|
export type BadgeVariant = 'neutral' | 'brand' | 'success' | 'warning' | 'danger';
|
|
4
4
|
export interface BadgeProps extends Omit<ComponentPropsWithoutRef<typeof ThemedBadge>, 'color' | 'variant'> {
|
|
5
5
|
variant?: BadgeVariant;
|
|
6
|
+
ref?: Ref<HTMLSpanElement>;
|
|
6
7
|
}
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* **Always `highContrast`**, which writes the text in step 12 instead of a11. Soft badges are
|
|
10
|
+
* 12px text on an a3 wash, and a11 there fails WCAG AA in light for jade (4.27:1), amber
|
|
11
|
+
* (4.25:1), blue and several accents a business can pick for `brand`. Step 12 clears 9.8:1
|
|
12
|
+
* on every scale in both appearances; `test/contrast.test.tsx` holds it there.
|
|
13
|
+
*/
|
|
14
|
+
export declare function Badge({ variant, ref, ...props }: BadgeProps): import("react").JSX.Element;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Badge as ThemedBadge } from '@radix-ui/themes';
|
|
3
|
+
import { warnMissingScales } from '../../utils/scales.js';
|
|
3
4
|
/**
|
|
4
5
|
* The five meanings this library names, in Themes' colour vocabulary.
|
|
5
6
|
*
|
|
@@ -18,6 +19,18 @@ const COLORS = {
|
|
|
18
19
|
warning: 'amber',
|
|
19
20
|
danger: 'red',
|
|
20
21
|
};
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* **Always `highContrast`**, which writes the text in step 12 instead of a11. Soft badges are
|
|
24
|
+
* 12px text on an a3 wash, and a11 there fails WCAG AA in light for jade (4.27:1), amber
|
|
25
|
+
* (4.25:1), blue and several accents a business can pick for `brand`. Step 12 clears 9.8:1
|
|
26
|
+
* on every scale in both appearances; `test/contrast.test.tsx` holds it there.
|
|
27
|
+
*/
|
|
28
|
+
export function Badge({ variant = 'neutral', ref, ...props }) {
|
|
29
|
+
return (_jsx(ThemedBadge, { ref: (el) => {
|
|
30
|
+
warnMissingScales(el);
|
|
31
|
+
if (typeof ref === 'function')
|
|
32
|
+
ref(el);
|
|
33
|
+
else if (ref)
|
|
34
|
+
ref.current = el;
|
|
35
|
+
}, color: COLORS[variant], variant: "soft", radius: "full", highContrast: true, ...props }));
|
|
23
36
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** The two resting heights of a drawer. */
|
|
3
|
+
export type DrawerSnap = 'collapsed' | 'expanded';
|
|
4
|
+
/** `modal` traps focus behind a scrim; `non-modal` floats over a page that stays usable. */
|
|
5
|
+
export type DrawerMode = 'modal' | 'non-modal';
|
|
6
|
+
export interface DrawerProps {
|
|
7
|
+
/** `modal` (scrim, focus trap, inert page) or `non-modal` (floating banner). Default `modal`. */
|
|
8
|
+
mode?: DrawerMode;
|
|
9
|
+
/** Controlled visibility. Leave undefined to let the drawer own it. */
|
|
10
|
+
open?: boolean;
|
|
11
|
+
/** Initial visibility when uncontrolled. Default `true`. */
|
|
12
|
+
defaultOpen?: boolean;
|
|
13
|
+
/** Called whenever the drawer asks to open or close. */
|
|
14
|
+
onOpenChange?: (open: boolean) => void;
|
|
15
|
+
/** Called once when the drawer closes, after `onOpenChange(false)`. */
|
|
16
|
+
onClose?: () => void;
|
|
17
|
+
/** Controlled snap point. */
|
|
18
|
+
snap?: DrawerSnap;
|
|
19
|
+
/** Initial snap point when uncontrolled. Default `collapsed`. */
|
|
20
|
+
defaultSnap?: DrawerSnap;
|
|
21
|
+
/** Called whenever the drawer asks to change snap point. */
|
|
22
|
+
onSnapChange?: (snap: DrawerSnap) => void;
|
|
23
|
+
/** Whether dragging past collapsed (or the close button, Escape, the scrim) may close it. Default `true`. */
|
|
24
|
+
dismissible?: boolean;
|
|
25
|
+
/** Labels the region via `aria-labelledby`. */
|
|
26
|
+
title: ReactNode;
|
|
27
|
+
/** Optional, wired up via `aria-describedby`. */
|
|
28
|
+
description?: ReactNode;
|
|
29
|
+
/** Shown while collapsed, below the header. */
|
|
30
|
+
peek?: ReactNode;
|
|
31
|
+
/** The body, shown when expanded; scrolls inside the sheet. */
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
/** Action row pinned below the body. Shown in both snaps. */
|
|
34
|
+
footer?: ReactNode;
|
|
35
|
+
/** Render a close button in the header. Default `false`. */
|
|
36
|
+
showCloseButton?: boolean;
|
|
37
|
+
/** Overrides the close button's accessible name (otherwise the `close` UI string). */
|
|
38
|
+
closeLabel?: string;
|
|
39
|
+
/** Overrides the handle's accessible name while collapsed (otherwise the `expand` UI string). */
|
|
40
|
+
expandLabel?: string;
|
|
41
|
+
/** Overrides the handle's accessible name while expanded (otherwise the `collapse` UI string). */
|
|
42
|
+
collapseLabel?: string;
|
|
43
|
+
/** Caps the width on wider screens. Default `max-w-lg`; pass `max-w-none` for full width. */
|
|
44
|
+
maxWidthClassName?: string;
|
|
45
|
+
className?: string;
|
|
46
|
+
/** Where the portal mounts. Default `document.body`. */
|
|
47
|
+
container?: HTMLElement | null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A bottom sheet with two snap points and a drag handle.
|
|
51
|
+
*
|
|
52
|
+
* Built by hand rather than on Radix Dialog because half of it is non-modal: Dialog's focus
|
|
53
|
+
* trap and `aria-hidden` on the rest of the page are exactly what a floating banner must not do.
|
|
54
|
+
* In `modal` mode the same guarantees are provided here — the page's other top-level nodes are
|
|
55
|
+
* made `inert` and Tab cycles inside the sheet.
|
|
56
|
+
*
|
|
57
|
+
* Dragging follows the pointer with a transform and, on release, settles on the snap point the
|
|
58
|
+
* gesture points at: past {@link DISTANCE} pixels or faster than {@link VELOCITY} px/ms. A drag
|
|
59
|
+
* from inside the body only counts when the body is scrolled to its top and moving down, so the
|
|
60
|
+
* content still scrolls normally.
|
|
61
|
+
*/
|
|
62
|
+
export declare function Drawer({ mode, open: openProp, defaultOpen, onOpenChange, onClose, snap: snapProp, defaultSnap, onSnapChange, dismissible, title, description, peek, children, footer, showCloseButton, closeLabel, expandLabel, collapseLabel, maxWidthClassName, className, container, }: DrawerProps): import("react").ReactPortal | null;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useId, useRef, useState, } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { useUiStrings } from '../../strings.js';
|
|
5
|
+
import { cn } from '../../utils/cn.js';
|
|
6
|
+
/** Pixels of travel that commit a drag regardless of speed. */
|
|
7
|
+
const DISTANCE = 60;
|
|
8
|
+
/** Pixels per millisecond that commit a flick regardless of distance. */
|
|
9
|
+
const VELOCITY = 0.5;
|
|
10
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
11
|
+
function useControllable(value, initial, onChange) {
|
|
12
|
+
const [inner, setInner] = useState(initial);
|
|
13
|
+
const controlled = value !== undefined;
|
|
14
|
+
const current = controlled ? value : inner;
|
|
15
|
+
const set = useCallback((next) => {
|
|
16
|
+
if (!controlled)
|
|
17
|
+
setInner(next);
|
|
18
|
+
onChange?.(next);
|
|
19
|
+
}, [controlled, onChange]);
|
|
20
|
+
return [current, set];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A bottom sheet with two snap points and a drag handle.
|
|
24
|
+
*
|
|
25
|
+
* Built by hand rather than on Radix Dialog because half of it is non-modal: Dialog's focus
|
|
26
|
+
* trap and `aria-hidden` on the rest of the page are exactly what a floating banner must not do.
|
|
27
|
+
* In `modal` mode the same guarantees are provided here — the page's other top-level nodes are
|
|
28
|
+
* made `inert` and Tab cycles inside the sheet.
|
|
29
|
+
*
|
|
30
|
+
* Dragging follows the pointer with a transform and, on release, settles on the snap point the
|
|
31
|
+
* gesture points at: past {@link DISTANCE} pixels or faster than {@link VELOCITY} px/ms. A drag
|
|
32
|
+
* from inside the body only counts when the body is scrolled to its top and moving down, so the
|
|
33
|
+
* content still scrolls normally.
|
|
34
|
+
*/
|
|
35
|
+
export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onOpenChange, onClose, snap: snapProp, defaultSnap = 'collapsed', onSnapChange, dismissible = true, title, description, peek, children, footer, showCloseButton = false, closeLabel, expandLabel, collapseLabel, maxWidthClassName = 'max-w-lg', className, container, }) {
|
|
36
|
+
const strings = useUiStrings();
|
|
37
|
+
const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);
|
|
38
|
+
const [snap, setSnap] = useControllable(snapProp, defaultSnap, onSnapChange);
|
|
39
|
+
const [offset, setOffset] = useState(0);
|
|
40
|
+
const [dragging, setDragging] = useState(false);
|
|
41
|
+
const drag = useRef(null);
|
|
42
|
+
const suppressClick = useRef(false);
|
|
43
|
+
const sheetRef = useRef(null);
|
|
44
|
+
const bodyRef = useRef(null);
|
|
45
|
+
const wrapperRef = useRef(null);
|
|
46
|
+
const id = useId();
|
|
47
|
+
const titleId = `${id}-title`;
|
|
48
|
+
const descId = `${id}-desc`;
|
|
49
|
+
const bodyId = `${id}-body`;
|
|
50
|
+
const modal = mode === 'modal';
|
|
51
|
+
const expanded = snap === 'expanded';
|
|
52
|
+
const close = useCallback(() => {
|
|
53
|
+
setOpen(false);
|
|
54
|
+
onClose?.();
|
|
55
|
+
}, [setOpen, onClose]);
|
|
56
|
+
// Modal: everything else on the page goes inert, focus moves in, and comes back on close.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!open || !modal)
|
|
59
|
+
return;
|
|
60
|
+
const wrapper = wrapperRef.current;
|
|
61
|
+
const root = wrapper?.parentElement;
|
|
62
|
+
const previous = document.activeElement;
|
|
63
|
+
const made = [];
|
|
64
|
+
if (root) {
|
|
65
|
+
for (const el of Array.from(root.children)) {
|
|
66
|
+
if (el !== wrapper && !el.hasAttribute('inert')) {
|
|
67
|
+
el.setAttribute('inert', '');
|
|
68
|
+
made.push(el);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const first = sheetRef.current?.querySelector(FOCUSABLE);
|
|
73
|
+
(first ?? sheetRef.current)?.focus();
|
|
74
|
+
return () => {
|
|
75
|
+
for (const el of made)
|
|
76
|
+
el.removeAttribute('inert');
|
|
77
|
+
previous?.focus?.();
|
|
78
|
+
};
|
|
79
|
+
}, [open, modal]);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!open || !modal || !dismissible)
|
|
82
|
+
return;
|
|
83
|
+
const onKey = (e) => {
|
|
84
|
+
if (e.key === 'Escape')
|
|
85
|
+
close();
|
|
86
|
+
};
|
|
87
|
+
document.addEventListener('keydown', onKey);
|
|
88
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
89
|
+
}, [open, modal, dismissible, close]);
|
|
90
|
+
const trapTab = (e) => {
|
|
91
|
+
if (!modal || e.key !== 'Tab' || !sheetRef.current)
|
|
92
|
+
return;
|
|
93
|
+
const items = Array.from(sheetRef.current.querySelectorAll(FOCUSABLE));
|
|
94
|
+
if (items.length === 0)
|
|
95
|
+
return;
|
|
96
|
+
const first = items[0];
|
|
97
|
+
const last = items[items.length - 1];
|
|
98
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
last.focus();
|
|
101
|
+
}
|
|
102
|
+
else if (!e.shiftKey && document.activeElement === last) {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
first.focus();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const begin = (e, fromBody) => {
|
|
108
|
+
if (e.button !== 0)
|
|
109
|
+
return;
|
|
110
|
+
if (fromBody && (!expanded || (bodyRef.current?.scrollTop ?? 0) > 0))
|
|
111
|
+
return;
|
|
112
|
+
drag.current = {
|
|
113
|
+
pointerId: e.pointerId,
|
|
114
|
+
startY: e.clientY,
|
|
115
|
+
lastY: e.clientY,
|
|
116
|
+
lastT: e.timeStamp,
|
|
117
|
+
velocity: 0,
|
|
118
|
+
moved: false,
|
|
119
|
+
fromBody,
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
const move = (e) => {
|
|
123
|
+
const d = drag.current;
|
|
124
|
+
if (!d || d.pointerId !== e.pointerId)
|
|
125
|
+
return;
|
|
126
|
+
const dy = e.clientY - d.startY;
|
|
127
|
+
if (!d.moved) {
|
|
128
|
+
if (Math.abs(dy) < 4)
|
|
129
|
+
return;
|
|
130
|
+
// Scrolling up inside the body is the content's business, not ours.
|
|
131
|
+
if (d.fromBody && dy < 0) {
|
|
132
|
+
drag.current = null;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
d.moved = true;
|
|
136
|
+
setDragging(true);
|
|
137
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
138
|
+
}
|
|
139
|
+
// At least one frame, so two events in the same tick do not read as an infinite flick.
|
|
140
|
+
const dt = Math.max(16, e.timeStamp - d.lastT);
|
|
141
|
+
d.velocity = (e.clientY - d.lastY) / dt;
|
|
142
|
+
d.lastY = e.clientY;
|
|
143
|
+
d.lastT = e.timeStamp;
|
|
144
|
+
// Past the top snap the sheet resists rather than detaching.
|
|
145
|
+
setOffset(dy < 0 && expanded ? dy / 4 : dy);
|
|
146
|
+
};
|
|
147
|
+
const end = (e) => {
|
|
148
|
+
const d = drag.current;
|
|
149
|
+
if (!d || d.pointerId !== e.pointerId)
|
|
150
|
+
return;
|
|
151
|
+
drag.current = null;
|
|
152
|
+
if (!d.moved)
|
|
153
|
+
return;
|
|
154
|
+
suppressClick.current = true;
|
|
155
|
+
const dy = e.clientY - d.startY;
|
|
156
|
+
const up = dy < -DISTANCE || d.velocity < -VELOCITY;
|
|
157
|
+
const down = dy > DISTANCE || d.velocity > VELOCITY;
|
|
158
|
+
setDragging(false);
|
|
159
|
+
setOffset(0);
|
|
160
|
+
if (up && !expanded)
|
|
161
|
+
setSnap('expanded');
|
|
162
|
+
else if (down && expanded)
|
|
163
|
+
setSnap('collapsed');
|
|
164
|
+
else if (down && dismissible)
|
|
165
|
+
close();
|
|
166
|
+
};
|
|
167
|
+
const cancel = () => {
|
|
168
|
+
drag.current = null;
|
|
169
|
+
setDragging(false);
|
|
170
|
+
setOffset(0);
|
|
171
|
+
};
|
|
172
|
+
const onHandleClick = () => {
|
|
173
|
+
if (suppressClick.current) {
|
|
174
|
+
suppressClick.current = false;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
setSnap(expanded ? 'collapsed' : 'expanded');
|
|
178
|
+
};
|
|
179
|
+
if (!open)
|
|
180
|
+
return null;
|
|
181
|
+
const target = container ?? (typeof document === 'undefined' ? null : document.body);
|
|
182
|
+
if (!target)
|
|
183
|
+
return null;
|
|
184
|
+
const dragHandlers = {
|
|
185
|
+
onPointerMove: move,
|
|
186
|
+
onPointerUp: end,
|
|
187
|
+
onPointerCancel: cancel,
|
|
188
|
+
};
|
|
189
|
+
return 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 && '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);
|
|
190
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Drawer.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Drawer.js';
|
|
@@ -3,6 +3,7 @@ import * as RadixToast from '@radix-ui/react-toast';
|
|
|
3
3
|
import { useSyncExternalStore } from 'react';
|
|
4
4
|
import { useUiStrings } from '../../strings.js';
|
|
5
5
|
import { cn } from '../../utils/cn.js';
|
|
6
|
+
import { warnMissingScales } from '../../utils/scales.js';
|
|
6
7
|
import { Icon } from '../Icon/Icon.js';
|
|
7
8
|
/**
|
|
8
9
|
* A module-level store, so `toast()` can be called from anywhere — an event handler, a
|
|
@@ -92,5 +93,5 @@ export function Toaster({ duration = 5000, position = 'bottom-right', label }) {
|
|
|
92
93
|
if (item.action?.keepOpen)
|
|
93
94
|
event.preventDefault();
|
|
94
95
|
}, className: cn('shrink-0 rounded-md px-2 py-1 text-sm font-medium', 'text-[var(--accent-11)] hover:bg-[var(--accent-a3)]'), children: item.action.label })) : null, _jsx(RadixToast.Close, { "aria-label": strings.dismiss, className: "shrink-0 rounded-md p-1 text-ink-muted hover:bg-[var(--gray-a3)]", children: _jsx(Icon, { name: "close", className: "size-4" }) })] }, item.id));
|
|
95
|
-
}), _jsx(RadixToast.Viewport, { className: cn('fixed z-50 m-0 flex w-full max-w-sm list-none flex-col gap-2 p-4 outline-none', POSITIONS[position]) })] }));
|
|
96
|
+
}), _jsx(RadixToast.Viewport, { ref: warnMissingScales, className: cn('fixed z-50 m-0 flex w-full max-w-sm list-none flex-col gap-2 p-4 outline-none', POSITIONS[position]) })] }));
|
|
96
97
|
}
|
package/dist/atoms/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './Card/index.js';
|
|
|
6
6
|
export * from './Checkbox/index.js';
|
|
7
7
|
export * from './ContextMenu/index.js';
|
|
8
8
|
export * from './Dialog/index.js';
|
|
9
|
+
export * from './Drawer/index.js';
|
|
9
10
|
export * from './Icon/index.js';
|
|
10
11
|
export * from './Input/index.js';
|
|
11
12
|
export * from './Label/index.js';
|
package/dist/atoms/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export * from './Card/index.js';
|
|
|
6
6
|
export * from './Checkbox/index.js';
|
|
7
7
|
export * from './ContextMenu/index.js';
|
|
8
8
|
export * from './Dialog/index.js';
|
|
9
|
+
export * from './Drawer/index.js';
|
|
9
10
|
export * from './Icon/index.js';
|
|
10
11
|
export * from './Input/index.js';
|
|
11
12
|
export * from './Label/index.js';
|
package/dist/strings.d.ts
CHANGED
|
@@ -28,6 +28,12 @@ export interface UiStrings {
|
|
|
28
28
|
heading: string;
|
|
29
29
|
bulletList: string;
|
|
30
30
|
orderedList: string;
|
|
31
|
+
/** Drawer: the close button's accessible name. */
|
|
32
|
+
close: string;
|
|
33
|
+
/** Drawer: the handle's accessible name while collapsed. */
|
|
34
|
+
expand: string;
|
|
35
|
+
/** Drawer: the handle's accessible name while expanded. */
|
|
36
|
+
collapse: string;
|
|
31
37
|
}
|
|
32
38
|
/**
|
|
33
39
|
* Feeds the atoms their strings, once, near the root.
|
package/dist/strings.js
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Radix colour scales the atoms name. A consumer that imports only gray and its accent
|
|
3
|
+
* gets badges and toasts with no colour at all, and nothing says so — the variables are simply
|
|
4
|
+
* undefined. This is that something, in development only.
|
|
5
|
+
*/
|
|
6
|
+
export declare const REQUIRED_SCALES: readonly ["gray", "jade", "amber", "red", "blue"];
|
|
7
|
+
export declare function warnMissingScales(el: Element | null): void;
|
|
8
|
+
/** Test seam. */
|
|
9
|
+
export declare function resetScaleCheck(): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Radix colour scales the atoms name. A consumer that imports only gray and its accent
|
|
3
|
+
* gets badges and toasts with no colour at all, and nothing says so — the variables are simply
|
|
4
|
+
* undefined. This is that something, in development only.
|
|
5
|
+
*/
|
|
6
|
+
export const REQUIRED_SCALES = ['gray', 'jade', 'amber', 'red', 'blue'];
|
|
7
|
+
const isDev = globalThis.process?.env?.NODE_ENV !==
|
|
8
|
+
'production';
|
|
9
|
+
let checked = false;
|
|
10
|
+
export function warnMissingScales(el) {
|
|
11
|
+
if (checked || !el || typeof getComputedStyle === 'undefined')
|
|
12
|
+
return;
|
|
13
|
+
if (!isDev)
|
|
14
|
+
return;
|
|
15
|
+
const style = getComputedStyle(el);
|
|
16
|
+
const missing = REQUIRED_SCALES.filter((s) => !style.getPropertyValue(`--${s}-9`).trim());
|
|
17
|
+
// Everything missing means no stylesheet yet (or jsdom) — not a choice; check again later.
|
|
18
|
+
if (missing.length === REQUIRED_SCALES.length)
|
|
19
|
+
return;
|
|
20
|
+
checked = true;
|
|
21
|
+
if (missing.length === 0)
|
|
22
|
+
return;
|
|
23
|
+
console.warn(`@kennycorrea/ui: missing Radix colour scale(s) ${missing.join(', ')}. Import ` +
|
|
24
|
+
`${missing.map((s) => `@radix-ui/themes/tokens/colors/${s}.css`).join(', ')} after ` +
|
|
25
|
+
'@radix-ui/themes/tokens/base.css, or Badge and Toast render colourless.');
|
|
26
|
+
}
|
|
27
|
+
/** Test seam. */
|
|
28
|
+
export function resetScaleCheck() {
|
|
29
|
+
checked = false;
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kennycorrea/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Upfront's house component layer: atoms over Radix Themes, the token bridge, and the accent logic.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"@radix-ui/themes": "^3.3.0",
|
|
51
51
|
"@testing-library/react": "^16.3.3",
|
|
52
52
|
"@testing-library/user-event": "^14.6.7",
|
|
53
|
+
"@types/node": "^24.19.2",
|
|
53
54
|
"@types/react": "^19.2.18",
|
|
54
55
|
"@types/react-dom": "^19.2.7",
|
|
55
56
|
"jsdom": "^30.1.2",
|