@stevederico/skateboard-ui 4.1.0 → 4.5.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 +35 -0
- package/README.md +3 -1
- package/dist/components/ThemeToggle.js +2 -2
- package/dist/components/core/Context.js +6 -6
- package/dist/components/core/Utilities.d.ts +2 -1
- package/dist/components/core/Utilities.js +20 -19
- package/dist/ui/alert-dialog.js +24 -16
- package/dist/ui/checkbox.d.ts +9 -6
- package/dist/ui/checkbox.js +21 -9
- package/dist/ui/context-menu.js +28 -8
- package/dist/ui/dialog.js +18 -17
- package/dist/ui/drawer.js +28 -16
- package/dist/ui/dropdown-menu.js +44 -14
- package/dist/ui/menubar.js +35 -14
- package/dist/ui/popover.js +24 -5
- package/dist/ui/select.d.ts +1 -1
- package/dist/ui/select.js +27 -6
- package/dist/ui/sheet.js +27 -16
- package/dist/ui/slider.d.ts +2 -1
- package/dist/ui/slider.js +11 -6
- package/dist/ui/slot.d.ts +4 -0
- package/dist/ui/slot.js +19 -1
- package/dist/ui/tooltip.js +27 -1
- package/dist/ui/use-labelling.d.ts +21 -0
- package/dist/ui/use-labelling.js +43 -0
- package/dist/ui/use-pointer-moved.d.ts +12 -0
- package/dist/ui/use-pointer-moved.js +21 -0
- package/dist/ui/use-scroll-lock.d.ts +6 -0
- package/dist/ui/use-scroll-lock.js +29 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
4.5.0
|
|
4
|
+
|
|
5
|
+
Toggle uses resolvedTheme
|
|
6
|
+
ThemeProvider owns dark
|
|
7
|
+
Drop dark-mode stripping
|
|
8
|
+
Slot render child fallback
|
|
9
|
+
|
|
10
|
+
4.4.0
|
|
11
|
+
|
|
12
|
+
Document a11y releases
|
|
13
|
+
Fix peer-dep naming
|
|
14
|
+
|
|
15
|
+
4.3.0
|
|
16
|
+
|
|
17
|
+
Submenu aria-controls wiring
|
|
18
|
+
Menu labelled by trigger
|
|
19
|
+
Tooltip dismiss on outside tap
|
|
20
|
+
Checkbox indeterminate mixed state
|
|
21
|
+
Replace catch any narrowing
|
|
22
|
+
Match DOMException error names
|
|
23
|
+
Fix relative-time date casts
|
|
24
|
+
Add a11y regression tests
|
|
25
|
+
|
|
26
|
+
4.2.0
|
|
27
|
+
|
|
28
|
+
Restore menu focus on select
|
|
29
|
+
Guard menu pointer focus-steal
|
|
30
|
+
Add menu keyboard focus ring
|
|
31
|
+
Conditional dialog aria labelling
|
|
32
|
+
Reentrant overlay scroll lock
|
|
33
|
+
Name popover dialog
|
|
34
|
+
Slider name and form
|
|
35
|
+
Fix dead slider classes
|
|
36
|
+
Add a11y regression tests
|
|
37
|
+
|
|
3
38
|
4.1.0
|
|
4
39
|
|
|
5
40
|
Add menu/select typeahead
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ React component library for rapid application development. Built with TypeScript
|
|
|
6
6
|
|
|
7
7
|
**Self-contained components (v4.0).** Every component is now hand-written with no primitive framework underneath — the previously-vendored `@base-ui/react` bundle (~31k lines) is gone. Floating positioning, focus trapping, dismiss layers, and animations are small in-house hooks; modals use the native `<dialog>` element. The legacy `shadcn/ui/*` import paths still work — they re-export the new `ui/*` components — so existing apps need no changes.
|
|
8
8
|
|
|
9
|
+
**Accessibility-hardened (v4.1–4.3).** The self-contained tier was put through three rounds of a11y work: menu/select typeahead, correct keyboard focus management and tab-stops (menus, tabs, radios, popovers, navigation menu), ARIA wiring (`aria-controls`, labelled-by, indeterminate checkbox mixed state, named popover/slider dialogs), scroll-lock and exit-animation fixes, plus matching DOMException error names. Each release adds to a Playwright a11y regression suite that runs `workers: 1`.
|
|
10
|
+
|
|
9
11
|
**TypeScript with full type declarations.** Source is TypeScript, compiled to plain JavaScript + `.d.ts` in `dist/` (`npm run build`). JavaScript apps consume the compiled JS exactly as before — import paths are unchanged — and TypeScript apps get real types for every export (`SkateboardConstants`, `User`, component props, `VariantProps`, typed icons).
|
|
10
12
|
|
|
11
13
|
**Node.js 24+** is required in the app repo for build tooling and backend (see `engines` in `package.json`). The UI package itself runs in the browser.
|
|
@@ -1309,7 +1311,7 @@ All components support dark mode automatically and accept a `className` prop for
|
|
|
1309
1311
|
### Required peer dependencies
|
|
1310
1312
|
- React 19.1+
|
|
1311
1313
|
- react-dom 19.1+
|
|
1312
|
-
- react-router
|
|
1314
|
+
- react-router 7.0+
|
|
1313
1315
|
|
|
1314
1316
|
### Hard dependencies
|
|
1315
1317
|
|
|
@@ -5,14 +5,14 @@ import { Sun, Moon } from '../icons/index.js';
|
|
|
5
5
|
import { Button } from '../shadcn/ui/button.js';
|
|
6
6
|
import { cn } from '../shadcn/lib/utils.js';
|
|
7
7
|
export default function ThemeToggle({ className = "", iconSize = 16, variant = "settings", ...props }) {
|
|
8
|
-
const {
|
|
8
|
+
const { resolvedTheme, setTheme } = useTheme();
|
|
9
9
|
const [mounted, setMounted] = React.useState(false);
|
|
10
10
|
React.useEffect(() => {
|
|
11
11
|
setMounted(true);
|
|
12
12
|
}, []);
|
|
13
13
|
if (!mounted)
|
|
14
14
|
return null;
|
|
15
|
-
const isDarkMode =
|
|
15
|
+
const isDarkMode = resolvedTheme === 'dark';
|
|
16
16
|
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
|
|
17
17
|
return (_jsx(Button, { variant: variant === "landing" ? "outline" : "ghost", size: "icon", onClick: toggleTheme, className: cn(className), "aria-label": "Toggle dark mode", ...props, children: isDarkMode
|
|
18
18
|
? _jsx(Sun, { size: iconSize })
|
|
@@ -43,7 +43,7 @@ function isLocalStorageAvailable() {
|
|
|
43
43
|
return true;
|
|
44
44
|
}
|
|
45
45
|
catch (e) {
|
|
46
|
-
console.warn('localStorage not available:', e.message);
|
|
46
|
+
console.warn('localStorage not available:', e instanceof Error ? e.message : String(e));
|
|
47
47
|
return false;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -58,7 +58,7 @@ function safeLSSetItem(key, value) {
|
|
|
58
58
|
return true;
|
|
59
59
|
}
|
|
60
60
|
catch (error) {
|
|
61
|
-
console.error('localStorage setItem error:', error.message);
|
|
61
|
+
console.error('localStorage setItem error:', error instanceof Error ? error.message : String(error));
|
|
62
62
|
return false;
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -69,7 +69,7 @@ function safeLSGetItem(key) {
|
|
|
69
69
|
return localStorage.getItem(key);
|
|
70
70
|
}
|
|
71
71
|
catch (error) {
|
|
72
|
-
console.error('localStorage getItem error:', error.message);
|
|
72
|
+
console.error('localStorage getItem error:', error instanceof Error ? error.message : String(error));
|
|
73
73
|
return null;
|
|
74
74
|
}
|
|
75
75
|
}
|
|
@@ -81,7 +81,7 @@ function safeLSRemoveItem(key) {
|
|
|
81
81
|
return true;
|
|
82
82
|
}
|
|
83
83
|
catch (error) {
|
|
84
|
-
console.error('localStorage removeItem error:', error.message);
|
|
84
|
+
console.error('localStorage removeItem error:', error instanceof Error ? error.message : String(error));
|
|
85
85
|
return false;
|
|
86
86
|
}
|
|
87
87
|
}
|
|
@@ -99,7 +99,7 @@ export function ContextProvider({ children, constants }) {
|
|
|
99
99
|
return JSON.parse(storedUser);
|
|
100
100
|
}
|
|
101
101
|
catch (e) {
|
|
102
|
-
console.error('Error parsing user data:', e.message);
|
|
102
|
+
console.error('Error parsing user data:', e instanceof Error ? e.message : String(e));
|
|
103
103
|
return null;
|
|
104
104
|
}
|
|
105
105
|
};
|
|
@@ -126,7 +126,7 @@ export function ContextProvider({ children, constants }) {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
catch (error) {
|
|
129
|
-
console.error('Error setting user:', error.message);
|
|
129
|
+
console.error('Error setting user:', error instanceof Error ? error.message : String(error));
|
|
130
130
|
}
|
|
131
131
|
return { ...state, user: action.payload };
|
|
132
132
|
}
|
|
@@ -321,7 +321,8 @@ export declare function showUpgradeSheet(upgradeSheetRef: RefObject<{
|
|
|
321
321
|
*/
|
|
322
322
|
export declare function timestampToString(input: number | Date, format?: string): string | number | Date;
|
|
323
323
|
/**
|
|
324
|
-
* Hook that sets the document title
|
|
324
|
+
* Hook that sets the document title on navigation.
|
|
325
|
+
* Dark mode is owned by ThemeProvider — never touch the class here.
|
|
325
326
|
*
|
|
326
327
|
* @param {Object} location - react-router location object
|
|
327
328
|
*/
|
|
@@ -21,7 +21,7 @@ function isLocalStorageAvailable() {
|
|
|
21
21
|
return true;
|
|
22
22
|
}
|
|
23
23
|
catch (e) {
|
|
24
|
-
console.warn('localStorage not available (private mode or quota exceeded):', e.message);
|
|
24
|
+
console.warn('localStorage not available (private mode or quota exceeded):', e instanceof Error ? e.message : String(e));
|
|
25
25
|
_localStorageAvailable = false;
|
|
26
26
|
return false;
|
|
27
27
|
}
|
|
@@ -37,11 +37,13 @@ function safeSetItem(key, value) {
|
|
|
37
37
|
return true;
|
|
38
38
|
}
|
|
39
39
|
catch (error) {
|
|
40
|
-
|
|
40
|
+
// DOMException (browser storage/abort errors) does not extend Error in
|
|
41
|
+
// the browser, so match either to read `.name` safely without a cast.
|
|
42
|
+
if ((error instanceof DOMException || error instanceof Error) && error.name === 'QuotaExceededError') {
|
|
41
43
|
console.error('localStorage quota exceeded - data not persisted');
|
|
42
44
|
}
|
|
43
45
|
else {
|
|
44
|
-
console.error('localStorage setItem error:', error.message);
|
|
46
|
+
console.error('localStorage setItem error:', error instanceof Error ? error.message : String(error));
|
|
45
47
|
}
|
|
46
48
|
return false;
|
|
47
49
|
}
|
|
@@ -53,7 +55,7 @@ function safeGetItem(key) {
|
|
|
53
55
|
return localStorage.getItem(key);
|
|
54
56
|
}
|
|
55
57
|
catch (error) {
|
|
56
|
-
console.error('localStorage getItem error:', error.message);
|
|
58
|
+
console.error('localStorage getItem error:', error instanceof Error ? error.message : String(error));
|
|
57
59
|
return null;
|
|
58
60
|
}
|
|
59
61
|
}
|
|
@@ -65,7 +67,7 @@ function safeRemoveItem(key) {
|
|
|
65
67
|
return true;
|
|
66
68
|
}
|
|
67
69
|
catch (error) {
|
|
68
|
-
console.error('localStorage removeItem error:', error.message);
|
|
70
|
+
console.error('localStorage removeItem error:', error instanceof Error ? error.message : String(error));
|
|
69
71
|
return false;
|
|
70
72
|
}
|
|
71
73
|
}
|
|
@@ -523,7 +525,7 @@ export async function showUpgradeSheet(upgradeSheetRef) {
|
|
|
523
525
|
(!user?.subscription?.expires || user?.subscription?.expires > Math.floor(Date.now() / 1000));
|
|
524
526
|
}
|
|
525
527
|
catch (error) {
|
|
526
|
-
console.error('Error parsing user data from storage:', error.message);
|
|
528
|
+
console.error('Error parsing user data from storage:', error instanceof Error ? error.message : String(error));
|
|
527
529
|
subscriber = false;
|
|
528
530
|
}
|
|
529
531
|
}
|
|
@@ -580,7 +582,7 @@ export function timestampToString(input, format = "DOB") {
|
|
|
580
582
|
return date.toLocaleString();
|
|
581
583
|
case "ago": {
|
|
582
584
|
const now = new Date();
|
|
583
|
-
const diffInSeconds = Math.floor((now - date) / 1000);
|
|
585
|
+
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
584
586
|
const minute = 60, hour = minute * 60, day = hour * 24;
|
|
585
587
|
const week = day * 7, month = day * 30, year = day * 365;
|
|
586
588
|
if (diffInSeconds < minute)
|
|
@@ -655,17 +657,14 @@ export function timestampToString(input, format = "DOB") {
|
|
|
655
657
|
}
|
|
656
658
|
}
|
|
657
659
|
/**
|
|
658
|
-
* Hook that sets the document title
|
|
660
|
+
* Hook that sets the document title on navigation.
|
|
661
|
+
* Dark mode is owned by ThemeProvider — never touch the class here.
|
|
659
662
|
*
|
|
660
663
|
* @param {Object} location - react-router location object
|
|
661
664
|
*/
|
|
662
665
|
export function useAppSetup(location) {
|
|
663
666
|
useEffect(() => {
|
|
664
667
|
document.title = getConstants().appName;
|
|
665
|
-
if (!location.pathname.toLowerCase().includes('app')) {
|
|
666
|
-
document.documentElement.classList.remove('dark');
|
|
667
|
-
document.body.classList.remove('dark');
|
|
668
|
-
}
|
|
669
668
|
}, [location.pathname]);
|
|
670
669
|
}
|
|
671
670
|
// ===== API REQUEST UTILITIES =====
|
|
@@ -703,10 +702,12 @@ export async function apiRequest(endpoint, options = {}) {
|
|
|
703
702
|
});
|
|
704
703
|
}
|
|
705
704
|
catch (error) {
|
|
706
|
-
|
|
705
|
+
// fetch() aborts throw a DOMException, which does not extend Error in
|
|
706
|
+
// the browser — match either so abort detection still works.
|
|
707
|
+
if ((error instanceof DOMException || error instanceof Error) && error.name === 'AbortError') {
|
|
707
708
|
throw error; // Re-throw abort errors for useListData to handle
|
|
708
709
|
}
|
|
709
|
-
throw new Error(`Network error: ${error.message}`);
|
|
710
|
+
throw new Error(`Network error: ${error instanceof Error ? error.message : String(error)}`);
|
|
710
711
|
}
|
|
711
712
|
finally {
|
|
712
713
|
clearTimeout(timeoutId);
|
|
@@ -787,7 +788,7 @@ export async function apiRequest(endpoint, options = {}) {
|
|
|
787
788
|
return await response.json();
|
|
788
789
|
}
|
|
789
790
|
catch (error) {
|
|
790
|
-
throw new Error(`Invalid JSON response from server: ${error.message}`);
|
|
791
|
+
throw new Error(`Invalid JSON response from server: ${error instanceof Error ? error.message : String(error)}`);
|
|
791
792
|
}
|
|
792
793
|
}
|
|
793
794
|
/**
|
|
@@ -861,10 +862,10 @@ export function useListData(endpoint, sortFn = null) {
|
|
|
861
862
|
setError(null);
|
|
862
863
|
}
|
|
863
864
|
catch (err) {
|
|
864
|
-
// Ignore abort errors
|
|
865
|
-
if (err.name === 'AbortError')
|
|
865
|
+
// Ignore abort errors (DOMException in the browser, not an Error)
|
|
866
|
+
if ((err instanceof DOMException || err instanceof Error) && err.name === 'AbortError')
|
|
866
867
|
return;
|
|
867
|
-
setError(err.message);
|
|
868
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
868
869
|
}
|
|
869
870
|
finally {
|
|
870
871
|
setLoading(false);
|
|
@@ -899,7 +900,7 @@ export function useForm(initialValues, onSubmit) {
|
|
|
899
900
|
setValues(initialValues);
|
|
900
901
|
}
|
|
901
902
|
catch (err) {
|
|
902
|
-
setError(err.message);
|
|
903
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
903
904
|
}
|
|
904
905
|
finally {
|
|
905
906
|
setSubmitting(false);
|
package/dist/ui/alert-dialog.js
CHANGED
|
@@ -5,6 +5,8 @@ import { cn } from "../shadcn/lib/utils.js";
|
|
|
5
5
|
import { Button } from "./button.js";
|
|
6
6
|
import { Slot, resolveRender } from "./slot.js";
|
|
7
7
|
import { useControllableState } from "./use-controllable-state.js";
|
|
8
|
+
import { useDialogLabelling } from "./use-labelling.js";
|
|
9
|
+
import { useScrollLock } from "./use-scroll-lock.js";
|
|
8
10
|
const AlertDialogContext = React.createContext(null);
|
|
9
11
|
function useAlertDialog() {
|
|
10
12
|
const ctx = React.useContext(AlertDialogContext);
|
|
@@ -20,9 +22,17 @@ function AlertDialog({ open, defaultOpen = false, onOpenChange, children, }) {
|
|
|
20
22
|
defaultValue: defaultOpen,
|
|
21
23
|
onChange: onOpenChange,
|
|
22
24
|
});
|
|
23
|
-
const titleId =
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const { titleId, descriptionId, registerTitle, registerDescription, labelledBy, describedBy, } = useDialogLabelling();
|
|
26
|
+
return (_jsx(AlertDialogContext.Provider, { value: {
|
|
27
|
+
open: isOpen,
|
|
28
|
+
setOpen,
|
|
29
|
+
titleId,
|
|
30
|
+
descriptionId,
|
|
31
|
+
registerTitle,
|
|
32
|
+
registerDescription,
|
|
33
|
+
labelledBy,
|
|
34
|
+
describedBy,
|
|
35
|
+
}, children: children }));
|
|
26
36
|
}
|
|
27
37
|
function AlertDialogTrigger({ className, asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
|
|
28
38
|
const { open, setOpen } = useAlertDialog();
|
|
@@ -41,7 +51,7 @@ function AlertDialogOverlay() {
|
|
|
41
51
|
return null;
|
|
42
52
|
}
|
|
43
53
|
function AlertDialogContent({ className, size = "default", children, ...props }) {
|
|
44
|
-
const { open, setOpen,
|
|
54
|
+
const { open, setOpen, labelledBy, describedBy } = useAlertDialog();
|
|
45
55
|
const ref = React.useRef(null);
|
|
46
56
|
React.useEffect(() => {
|
|
47
57
|
const node = ref.current;
|
|
@@ -52,16 +62,10 @@ function AlertDialogContent({ className, size = "default", children, ...props })
|
|
|
52
62
|
else if (!open && node.open)
|
|
53
63
|
node.close();
|
|
54
64
|
}, [open]);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
document.body.style.overflow = "hidden";
|
|
60
|
-
return () => {
|
|
61
|
-
document.body.style.overflow = prev;
|
|
62
|
-
};
|
|
63
|
-
}, [open]);
|
|
64
|
-
return (_jsx("dialog", { ref: ref, role: "alertdialog", "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "alert-dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
|
|
65
|
+
// Lock background scroll while open via the shared reentrant helper so stacked
|
|
66
|
+
// overlays don't unlock each other when one closes.
|
|
67
|
+
useScrollLock(open);
|
|
68
|
+
return (_jsx("dialog", { ref: ref, role: "alertdialog", "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "alert-dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
|
|
65
69
|
e.preventDefault();
|
|
66
70
|
setOpen(false);
|
|
67
71
|
}, onClose: () => setOpen(false), className: "m-0 grid max-h-none max-w-none place-items-center bg-transparent p-4 backdrop:bg-black/10 backdrop:duration-100 supports-backdrop-filter:backdrop:backdrop-blur-xs open:fixed open:inset-0 open:size-full", children: open ? (_jsx("div", { "data-slot": "alert-dialog-content", "data-size": size, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, className: cn("group/alert-dialog-content relative grid w-full gap-6 rounded-xl bg-background p-6 text-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className), ...props, children: children })) : null }));
|
|
@@ -76,11 +80,15 @@ function AlertDialogMedia({ className, ...props }) {
|
|
|
76
80
|
return (_jsx("div", { "data-slot": "alert-dialog-media", className: cn("mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8", className), ...props }));
|
|
77
81
|
}
|
|
78
82
|
function AlertDialogTitle({ className, ...props }) {
|
|
79
|
-
const { titleId } = useAlertDialog();
|
|
83
|
+
const { titleId, registerTitle } = useAlertDialog();
|
|
84
|
+
// Signal that a title exists so the content wires `aria-labelledby` to it.
|
|
85
|
+
React.useEffect(registerTitle, [registerTitle]);
|
|
80
86
|
return (_jsx("h2", { id: titleId, "data-slot": "alert-dialog-title", className: cn("text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className), ...props }));
|
|
81
87
|
}
|
|
82
88
|
function AlertDialogDescription({ className, ...props }) {
|
|
83
|
-
const { descriptionId } = useAlertDialog();
|
|
89
|
+
const { descriptionId, registerDescription } = useAlertDialog();
|
|
90
|
+
// Signal that a description exists so the content wires `aria-describedby`.
|
|
91
|
+
React.useEffect(registerDescription, [registerDescription]);
|
|
84
92
|
return (_jsx("p", { id: descriptionId, "data-slot": "alert-dialog-description", className: cn("text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", className), ...props }));
|
|
85
93
|
}
|
|
86
94
|
function AlertDialogAction({ className, onClick, ...props }) {
|
package/dist/ui/checkbox.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/** Checked state of a checkbox: a real boolean, or the tri-state "mixed" value. */
|
|
3
|
+
type CheckedState = boolean | "indeterminate";
|
|
4
|
+
export interface CheckboxProps extends Omit<React.ComponentProps<"button">, "onChange" | "value" | "checked" | "defaultChecked"> {
|
|
5
|
+
checked?: CheckedState;
|
|
6
|
+
defaultChecked?: CheckedState;
|
|
5
7
|
onCheckedChange?: (checked: boolean) => void;
|
|
6
8
|
/** When set, a hidden checkbox of this name mirrors the state for form posts. */
|
|
7
9
|
name?: string;
|
|
@@ -9,9 +11,10 @@ export interface CheckboxProps extends Omit<React.ComponentProps<"button">, "onC
|
|
|
9
11
|
}
|
|
10
12
|
/**
|
|
11
13
|
* Self-contained checkbox: a `role="checkbox"` button with controlled/
|
|
12
|
-
* uncontrolled checked state.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* uncontrolled checked state. Supports a tri-state `"indeterminate"` (mixed)
|
|
15
|
+
* value for incoming controlled/default state. Emits `data-checked`/
|
|
16
|
+
* `data-unchecked`/`data-indeterminate`/`data-disabled` so the shared classes
|
|
17
|
+
* apply. Pass `name` to include a hidden checkbox for forms.
|
|
15
18
|
*/
|
|
16
19
|
declare function Checkbox({ className, checked, defaultChecked, onCheckedChange, disabled, name, value, onClick, ...props }: CheckboxProps): React.JSX.Element;
|
|
17
20
|
export { Checkbox };
|
package/dist/ui/checkbox.js
CHANGED
|
@@ -2,24 +2,36 @@
|
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "../shadcn/lib/utils.js";
|
|
5
|
-
import { CheckIcon } from "../icons/index.js";
|
|
5
|
+
import { CheckIcon, MinusIcon } from "../icons/index.js";
|
|
6
6
|
import { useControllableState } from "./use-controllable-state.js";
|
|
7
7
|
/**
|
|
8
8
|
* Self-contained checkbox: a `role="checkbox"` button with controlled/
|
|
9
|
-
* uncontrolled checked state.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* uncontrolled checked state. Supports a tri-state `"indeterminate"` (mixed)
|
|
10
|
+
* value for incoming controlled/default state. Emits `data-checked`/
|
|
11
|
+
* `data-unchecked`/`data-indeterminate`/`data-disabled` so the shared classes
|
|
12
|
+
* apply. Pass `name` to include a hidden checkbox for forms.
|
|
12
13
|
*/
|
|
13
14
|
function Checkbox({ className, checked, defaultChecked = false, onCheckedChange, disabled, name, value = "on", onClick, ...props }) {
|
|
15
|
+
// The hook tracks the full tri-state so an incoming "indeterminate" survives;
|
|
16
|
+
// onCheckedChange is fired separately in onClick where the value is always a
|
|
17
|
+
// boolean, keeping the public callback signature unchanged.
|
|
14
18
|
const [on, setOn] = useControllableState({
|
|
15
19
|
value: checked,
|
|
16
20
|
defaultValue: defaultChecked,
|
|
17
|
-
onChange: onCheckedChange,
|
|
18
21
|
});
|
|
19
|
-
|
|
22
|
+
const isIndeterminate = on === "indeterminate";
|
|
23
|
+
// a11y: a tri-state checkbox must report "mixed" so assistive tech announces
|
|
24
|
+
// the partial selection instead of a plain checked/unchecked state.
|
|
25
|
+
const ariaChecked = isIndeterminate ? "mixed" : on;
|
|
26
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", role: "checkbox", "aria-checked": ariaChecked, disabled: disabled, "data-slot": "checkbox", "data-state": isIndeterminate ? "indeterminate" : on ? "checked" : "unchecked", "data-checked": on === true || isIndeterminate ? "" : undefined, "data-unchecked": on === false ? "" : undefined, "data-indeterminate": isIndeterminate ? "" : undefined, "data-disabled": disabled ? "" : undefined, className: cn("cursor-pointer peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary", className), onClick: (e) => {
|
|
20
27
|
onClick?.(e);
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
if (e.defaultPrevented)
|
|
29
|
+
return;
|
|
30
|
+
// A user click always resolves to a real boolean: indeterminate and
|
|
31
|
+
// unchecked both become checked, checked becomes unchecked.
|
|
32
|
+
const next = on === true ? false : true;
|
|
33
|
+
setOn(next);
|
|
34
|
+
onCheckedChange?.(next);
|
|
35
|
+
}, ...props, children: on === true ? (_jsx("span", { "data-slot": "checkbox-indicator", "data-checked": "", className: "grid place-content-center text-current transition-none [&>svg]:size-3.5", children: _jsx(CheckIcon, {}) })) : isIndeterminate ? (_jsx("span", { "data-slot": "checkbox-indicator", "data-indeterminate": "", className: "grid place-content-center text-current transition-none [&>svg]:size-3.5", children: _jsx(MinusIcon, {}) })) : null }), name ? (_jsx("input", { type: "checkbox", "aria-hidden": true, tabIndex: -1, name: name, value: value, checked: on === true, disabled: disabled, readOnly: true, className: "sr-only" })) : null] }));
|
|
24
36
|
}
|
|
25
37
|
export { Checkbox };
|
package/dist/ui/context-menu.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Portal } from "./portal.js";
|
|
|
7
7
|
import { useFloating } from "./use-floating.js";
|
|
8
8
|
import { usePresence } from "./use-presence.js";
|
|
9
9
|
import { useTypeahead } from "./use-typeahead.js";
|
|
10
|
+
import { usePointerMoved } from "./use-pointer-moved.js";
|
|
10
11
|
import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
|
|
11
12
|
const MenuContext = React.createContext(null);
|
|
12
13
|
function useMenu() {
|
|
@@ -65,6 +66,10 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
|
|
|
65
66
|
const [mounted, presenceRef] = usePresence(open);
|
|
66
67
|
const containerRef = React.useRef(null);
|
|
67
68
|
const { onTypeaheadKeyDown } = useTypeahead();
|
|
69
|
+
// Pointer-move focus follows the cursor, but keyboard arrow nav scrolls the
|
|
70
|
+
// list and fires a synthetic pointermove at the same coordinates — guard it
|
|
71
|
+
// so scrolling doesn't yank focus off the keyboard target.
|
|
72
|
+
const hasPointerMoved = usePointerMoved();
|
|
68
73
|
// Position the menu at the cursor, clamped into the viewport once the
|
|
69
74
|
// popup has measurable dimensions. availableHeight caps the menu so long
|
|
70
75
|
// lists scroll inside the viewport instead of overflowing it.
|
|
@@ -166,6 +171,8 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
|
|
|
166
171
|
["--transform-origin"]: "top left",
|
|
167
172
|
["--available-height"]: pos ? `${pos.availableHeight}px` : undefined,
|
|
168
173
|
}, onPointerMove: (e) => {
|
|
174
|
+
if (!hasPointerMoved(e))
|
|
175
|
+
return;
|
|
169
176
|
const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
|
|
170
177
|
if (item && item !== document.activeElement)
|
|
171
178
|
item.focus();
|
|
@@ -216,7 +223,10 @@ function ContextMenuLabel({ className, inset, ...props }) {
|
|
|
216
223
|
}
|
|
217
224
|
function ContextMenuItem({ className, inset, variant = "default", disabled, onSelect, onClick, ...props }) {
|
|
218
225
|
const { setOpen } = useMenu();
|
|
219
|
-
return (_jsx("div", { role: "menuitem", tabIndex: -1, "aria-disabled": disabled || undefined, "data-slot": "context-menu-item", "data-inset": inset ? "" : undefined, "data-variant": variant, "data-disabled": disabled ? "" : undefined, className: cn(
|
|
226
|
+
return (_jsx("div", { role: "menuitem", tabIndex: -1, "aria-disabled": disabled || undefined, "data-slot": "context-menu-item", "data-inset": inset ? "" : undefined, "data-variant": variant, "data-disabled": disabled ? "" : undefined, className: cn(
|
|
227
|
+
// focus-visible ring gives keyboard roving focus a visible indicator
|
|
228
|
+
// (outline-hidden removes the native one); pointer focus stays ring-free.
|
|
229
|
+
"group/context-menu-item relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive", className), onClick: (e) => {
|
|
220
230
|
if (disabled)
|
|
221
231
|
return;
|
|
222
232
|
onClick?.(e);
|
|
@@ -228,7 +238,9 @@ function ContextMenuItem({ className, inset, variant = "default", disabled, onSe
|
|
|
228
238
|
}
|
|
229
239
|
const RadioGroupContext = React.createContext({});
|
|
230
240
|
function ContextMenuCheckboxItem({ className, children, checked, onCheckedChange, disabled, inset, onClick, ...props }) {
|
|
231
|
-
return (_jsxs("div", { role: "menuitemcheckbox", tabIndex: -1, "aria-checked": !!checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-checkbox-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn(
|
|
241
|
+
return (_jsxs("div", { role: "menuitemcheckbox", tabIndex: -1, "aria-checked": !!checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-checkbox-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn(
|
|
242
|
+
// focus-visible ring gives keyboard roving focus a visible indicator.
|
|
243
|
+
"relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
|
|
232
244
|
if (disabled)
|
|
233
245
|
return;
|
|
234
246
|
onClick?.(e);
|
|
@@ -242,7 +254,9 @@ function ContextMenuRadioGroup({ value, onValueChange, ...props }) {
|
|
|
242
254
|
function ContextMenuRadioItem({ className, children, value, disabled, inset, onClick, ...props }) {
|
|
243
255
|
const { value: groupValue, onValueChange } = React.useContext(RadioGroupContext);
|
|
244
256
|
const checked = groupValue === value;
|
|
245
|
-
return (_jsxs("div", { role: "menuitemradio", tabIndex: -1, "aria-checked": checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-radio-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn(
|
|
257
|
+
return (_jsxs("div", { role: "menuitemradio", tabIndex: -1, "aria-checked": checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-radio-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn(
|
|
258
|
+
// focus-visible ring gives keyboard roving focus a visible indicator.
|
|
259
|
+
"relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
|
|
246
260
|
if (disabled)
|
|
247
261
|
return;
|
|
248
262
|
onClick?.(e);
|
|
@@ -266,11 +280,14 @@ function useSub() {
|
|
|
266
280
|
function ContextMenuSub({ defaultOpen = false, children, }) {
|
|
267
281
|
const [open, setOpen] = React.useState(defaultOpen);
|
|
268
282
|
const triggerRef = React.useRef(null);
|
|
269
|
-
|
|
283
|
+
const subContentId = React.useId();
|
|
284
|
+
return (_jsx(SubContext.Provider, { value: { open, setOpen, triggerRef, subContentId }, children: children }));
|
|
270
285
|
}
|
|
271
286
|
function ContextMenuSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, ...props }) {
|
|
272
|
-
const { open, setOpen, triggerRef } = useSub();
|
|
273
|
-
return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "data-slot": "context-menu-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn(
|
|
287
|
+
const { open, setOpen, triggerRef, subContentId } = useSub();
|
|
288
|
+
return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? subContentId : undefined, "data-slot": "context-menu-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn(
|
|
289
|
+
// focus-visible ring gives keyboard roving focus a visible indicator.
|
|
290
|
+
"flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onMouseEnter: (e) => {
|
|
274
291
|
onMouseEnter?.(e);
|
|
275
292
|
setOpen(true);
|
|
276
293
|
}, onClick: (e) => {
|
|
@@ -288,7 +305,7 @@ function ContextMenuSubTrigger({ className, inset, children, onClick, onKeyDown,
|
|
|
288
305
|
}, ...props, children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] }));
|
|
289
306
|
}
|
|
290
307
|
function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
|
|
291
|
-
const { open, setOpen, triggerRef } = useSub();
|
|
308
|
+
const { open, setOpen, triggerRef, subContentId } = useSub();
|
|
292
309
|
const { layers } = useMenu();
|
|
293
310
|
const [mounted, presenceRef] = usePresence(open);
|
|
294
311
|
const { floatingRef, pos } = useFloating(triggerRef, mounted, {
|
|
@@ -330,7 +347,10 @@ function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
|
|
|
330
347
|
}, [open]);
|
|
331
348
|
if (!mounted)
|
|
332
349
|
return null;
|
|
333
|
-
return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef),
|
|
350
|
+
return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef),
|
|
351
|
+
// Matches the SubTrigger's aria-controls target so the trigger->submenu
|
|
352
|
+
// relationship is exposed to assistive tech.
|
|
353
|
+
id: subContentId, role: "menu", tabIndex: -1, "data-slot": "context-menu-sub-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? "right", style: {
|
|
334
354
|
position: "fixed",
|
|
335
355
|
left: pos?.x ?? 0,
|
|
336
356
|
top: pos?.y ?? 0,
|
package/dist/ui/dialog.js
CHANGED
|
@@ -6,6 +6,8 @@ import { Button } from "./button.js";
|
|
|
6
6
|
import { Slot, resolveRender } from "./slot.js";
|
|
7
7
|
import { XIcon } from "../icons/index.js";
|
|
8
8
|
import { useControllableState } from "./use-controllable-state.js";
|
|
9
|
+
import { useDialogLabelling } from "./use-labelling.js";
|
|
10
|
+
import { useScrollLock } from "./use-scroll-lock.js";
|
|
9
11
|
const DialogContext = React.createContext(null);
|
|
10
12
|
function useDialog() {
|
|
11
13
|
const ctx = React.useContext(DialogContext);
|
|
@@ -24,9 +26,10 @@ function Dialog({ open, defaultOpen = false, onOpenChange, children }) {
|
|
|
24
26
|
defaultValue: defaultOpen,
|
|
25
27
|
onChange: onOpenChange,
|
|
26
28
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
// Track which labelling parts actually render so DialogContent can reference
|
|
30
|
+
// them only when present, avoiding a dangling aria-labelledby/-describedby IDREF.
|
|
31
|
+
const labelling = useDialogLabelling();
|
|
32
|
+
return (_jsx(DialogContext.Provider, { value: { open: isOpen, setOpen, ...labelling }, children: children }));
|
|
30
33
|
}
|
|
31
34
|
function DialogTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
|
|
32
35
|
const { open, setOpen } = useDialog();
|
|
@@ -57,7 +60,7 @@ function DialogOverlay() {
|
|
|
57
60
|
return null;
|
|
58
61
|
}
|
|
59
62
|
function DialogContent({ className, children, showCloseButton = true, ...props }) {
|
|
60
|
-
const { open, setOpen,
|
|
63
|
+
const { open, setOpen, labelledBy, describedBy } = useDialog();
|
|
61
64
|
const ref = React.useRef(null);
|
|
62
65
|
const pointerDownOnBackdrop = React.useRef(false);
|
|
63
66
|
React.useEffect(() => {
|
|
@@ -69,17 +72,11 @@ function DialogContent({ className, children, showCloseButton = true, ...props }
|
|
|
69
72
|
else if (!open && node.open)
|
|
70
73
|
node.close();
|
|
71
74
|
}, [open]);
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
document.body.style.overflow = "hidden";
|
|
78
|
-
return () => {
|
|
79
|
-
document.body.style.overflow = prev;
|
|
80
|
-
};
|
|
81
|
-
}, [open]);
|
|
82
|
-
return (_jsx("dialog", { ref: ref, "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
|
|
75
|
+
// Reentrant, reference-counted scroll lock so stacked overlays don't clobber
|
|
76
|
+
// each other's overflow restore (an inner close would otherwise unlock scroll
|
|
77
|
+
// while an outer dialog is still open).
|
|
78
|
+
useScrollLock(open);
|
|
79
|
+
return (_jsx("dialog", { ref: ref, "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
|
|
83
80
|
e.preventDefault();
|
|
84
81
|
setOpen(false);
|
|
85
82
|
}, onClose: () => setOpen(false), onPointerDown: (e) => {
|
|
@@ -99,11 +96,15 @@ function DialogFooter({ className, showCloseButton = false, children, ...props }
|
|
|
99
96
|
return (_jsxs("div", { "data-slot": "dialog-footer", className: cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className), ...props, children: [children, showCloseButton && (_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "outline", children: "Close" }) }))] }));
|
|
100
97
|
}
|
|
101
98
|
function DialogTitle({ className, ...props }) {
|
|
102
|
-
const { titleId } = useDialog();
|
|
99
|
+
const { titleId, registerTitle } = useDialog();
|
|
100
|
+
// Register presence so the container wires aria-labelledby only when a title exists.
|
|
101
|
+
React.useEffect(registerTitle, [registerTitle]);
|
|
103
102
|
return (_jsx("h2", { id: titleId, "data-slot": "dialog-title", className: cn("leading-none font-medium", className), ...props }));
|
|
104
103
|
}
|
|
105
104
|
function DialogDescription({ className, ...props }) {
|
|
106
|
-
const { descriptionId } = useDialog();
|
|
105
|
+
const { descriptionId, registerDescription } = useDialog();
|
|
106
|
+
// Register presence so the container wires aria-describedby only when a description exists.
|
|
107
|
+
React.useEffect(registerDescription, [registerDescription]);
|
|
107
108
|
return (_jsx("p", { id: descriptionId, "data-slot": "dialog-description", className: cn("text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", className), ...props }));
|
|
108
109
|
}
|
|
109
110
|
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, };
|