@tangible/ui 0.0.3 → 0.0.5
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/README.md +21 -13
- package/components/Accordion/Accordion.d.ts +2 -2
- package/components/Accordion/Accordion.js +94 -23
- package/components/Accordion/index.d.ts +1 -1
- package/components/Accordion/types.d.ts +28 -4
- package/components/Avatar/Avatar.js +16 -7
- package/components/Avatar/AvatarGroup.js +7 -5
- package/components/Avatar/types.d.ts +11 -0
- package/components/Button/Button.js +10 -3
- package/components/Button/types.d.ts +9 -1
- package/components/Card/Card.js +26 -13
- package/components/Checkbox/Checkbox.d.ts +1 -1
- package/components/Chip/Chip.d.ts +37 -1
- package/components/Chip/Chip.js +10 -8
- package/components/ChipGroup/ChipGroup.js +5 -4
- package/components/ChipGroup/types.d.ts +3 -0
- package/components/Dropdown/Dropdown.d.ts +19 -1
- package/components/Dropdown/Dropdown.js +84 -28
- package/components/Dropdown/index.d.ts +2 -2
- package/components/Dropdown/index.js +1 -1
- package/components/Dropdown/types.d.ts +15 -0
- package/components/IconButton/IconButton.js +5 -4
- package/components/IconButton/index.d.ts +1 -1
- package/components/IconButton/types.d.ts +24 -4
- package/components/Modal/Modal.d.ts +16 -2
- package/components/Modal/Modal.js +45 -20
- package/components/MoveHandle/MoveHandle.js +3 -3
- package/components/MoveHandle/types.d.ts +12 -2
- package/components/Notice/Notice.js +32 -19
- package/components/Select/Select.js +6 -2
- package/components/Sidebar/Sidebar.d.ts +6 -1
- package/components/Sidebar/Sidebar.js +65 -11
- package/components/Sidebar/index.d.ts +1 -1
- package/components/Sidebar/types.d.ts +39 -14
- package/components/Tabs/Tabs.d.ts +1 -1
- package/components/Tabs/Tabs.js +12 -3
- package/components/Tabs/types.d.ts +20 -5
- package/components/TextInput/TextInput.js +10 -2
- package/components/Tooltip/Tooltip.d.ts +2 -2
- package/components/Tooltip/Tooltip.js +61 -40
- package/components/Tooltip/index.d.ts +1 -1
- package/components/Tooltip/types.d.ts +28 -1
- package/components/index.d.ts +2 -2
- package/components/index.js +1 -1
- package/package.json +1 -1
- package/styles/all.css +1 -1
- package/styles/all.expanded.css +354 -64
- package/styles/all.expanded.unlayered.css +354 -64
- package/styles/all.unlayered.css +1 -1
- package/styles/system/_tokens.scss +3 -0
- package/tui-manifest.json +291 -66
- package/utils/focus-trap.js +8 -1
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
4
|
import { cx } from '../../utils/cx.js';
|
|
5
|
-
import {
|
|
5
|
+
import { isDev } from '../../utils/is-dev.js';
|
|
6
6
|
import { getPortalRootFor } from '../../utils/portal.js';
|
|
7
7
|
import { useFocusTrap, getInitialFocus } from '../../utils/focus-trap.js';
|
|
8
8
|
import { ModalContext, useModalContext } from './context.js';
|
|
@@ -11,7 +11,16 @@ const isBrowser = typeof document !== 'undefined';
|
|
|
11
11
|
function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, initialFocusSelector, container, showCloseButton, closeLabel = 'Close', closeOnBackdropClick = true, closeOnEscape = true, children, }) {
|
|
12
12
|
const dialogRef = useRef(null);
|
|
13
13
|
const restoreRef = useRef(null);
|
|
14
|
+
const warnedRef = useRef(false);
|
|
14
15
|
const [mount, setMount] = useState(null);
|
|
16
|
+
// Dev warning: Modal should have an accessible name
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (isDev() && !warnedRef.current && open && !labelledBy) {
|
|
19
|
+
warnedRef.current = true;
|
|
20
|
+
console.warn('[TUI Modal] Missing `aria-labelledby` prop. Dialogs should reference a visible heading ' +
|
|
21
|
+
'to provide an accessible name (WCAG 4.1.2).');
|
|
22
|
+
}
|
|
23
|
+
}, [open, labelledBy]);
|
|
15
24
|
// Capture trigger element and compute portal mount point when modal opens.
|
|
16
25
|
// Uses useLayoutEffect to run before paint — both renders complete before
|
|
17
26
|
// the browser shows anything, so no visible delay.
|
|
@@ -50,6 +59,36 @@ function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-l
|
|
|
50
59
|
document.body.classList.remove('tui-modal-open');
|
|
51
60
|
};
|
|
52
61
|
}, [open]);
|
|
62
|
+
// Inert sibling trees so screen readers cannot escape the dialog.
|
|
63
|
+
// aria-modal alone is unreliable — NVDA/JAWS virtual cursor can still
|
|
64
|
+
// reach background content via arrow keys and document-structure shortcuts.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!open || !mount)
|
|
67
|
+
return;
|
|
68
|
+
const inerted = [];
|
|
69
|
+
// Walk from the portal root up to <body>, inerting siblings at each level.
|
|
70
|
+
let node = mount;
|
|
71
|
+
while (node && node !== document.body) {
|
|
72
|
+
const parent = node.parentElement;
|
|
73
|
+
if (parent) {
|
|
74
|
+
const children = Array.from(parent.children);
|
|
75
|
+
for (const sibling of children) {
|
|
76
|
+
if (sibling === node || sibling.tagName === 'SCRIPT' || sibling.tagName === 'STYLE')
|
|
77
|
+
continue;
|
|
78
|
+
if (!sibling.hasAttribute('inert')) {
|
|
79
|
+
sibling.inert = true;
|
|
80
|
+
inerted.push(sibling);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
node = parent;
|
|
85
|
+
}
|
|
86
|
+
return () => {
|
|
87
|
+
for (const el of inerted) {
|
|
88
|
+
el.inert = false;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}, [open, mount]);
|
|
53
92
|
// Focus trap (handles Tab cycling and ESC to close).
|
|
54
93
|
useFocusTrap(dialogRef, {
|
|
55
94
|
// Modal mount is two-phase (capture portal root, then render portal).
|
|
@@ -65,18 +104,6 @@ function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-l
|
|
|
65
104
|
const dialog = dialogRef.current;
|
|
66
105
|
if (!dialog)
|
|
67
106
|
return;
|
|
68
|
-
// Ensure scrollable body section is keyboard-focusable for a11y audits.
|
|
69
|
-
// WCAG 4.1.2: focusable elements need accessible names.
|
|
70
|
-
const scrollables = dialog.querySelectorAll(`.${PREFIX}-modal__body-inner, [data-scrollable="true"]`);
|
|
71
|
-
scrollables.forEach((el) => {
|
|
72
|
-
const hasOverflow = el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
|
|
73
|
-
if (hasOverflow && !el.hasAttribute('tabindex')) {
|
|
74
|
-
el.setAttribute('tabindex', '0');
|
|
75
|
-
if (!el.hasAttribute('aria-label') && !el.hasAttribute('aria-labelledby')) {
|
|
76
|
-
el.setAttribute('aria-label', 'Scrollable content');
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
107
|
// Initial focus target.
|
|
81
108
|
let target = null;
|
|
82
109
|
if (initialFocusSelector) {
|
|
@@ -110,15 +137,13 @@ function ModalClose({ label = 'Close', className }) {
|
|
|
110
137
|
return (_jsx(IconButton, { icon: "system/close", label: label, variant: "ghost", size: "sm", onClick: onClose, className: cx('tui-modal__close', className), showTooltip: true }));
|
|
111
138
|
}
|
|
112
139
|
ModalClose.displayName = 'Modal.Close';
|
|
113
|
-
function ModalHead({ className, children, ...rest }) {
|
|
114
|
-
|
|
140
|
+
function ModalHead({ className, children, subtitle, icon, ...rest }) {
|
|
141
|
+
const hasIcon = !!icon;
|
|
142
|
+
return (_jsxs("div", { className: cx('tui-modal__head', hasIcon && 'has-icon', className), ...rest, children: [hasIcon && _jsx("div", { className: "tui-modal__head-icon", children: icon }), _jsxs("div", { className: "tui-modal__head-content", children: [children, subtitle && _jsx("div", { className: "tui-modal__head-subtitle", children: subtitle })] })] }));
|
|
115
143
|
}
|
|
116
144
|
ModalHead.displayName = 'Modal.Head';
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// =============================================================================
|
|
120
|
-
function ModalBody({ className, children, ...rest }) {
|
|
121
|
-
return (_jsx("div", { className: cx('tui-modal__body', className), ...rest, children: _jsx("div", { className: "tui-modal__body-inner", children: children }) }));
|
|
145
|
+
function ModalBody({ className, children, scrollLabel, ...rest }) {
|
|
146
|
+
return (_jsx("div", { className: cx('tui-modal__body', className), ...rest, children: _jsx("div", { className: "tui-modal__body-inner", "aria-label": scrollLabel, tabIndex: scrollLabel ? 0 : undefined, role: scrollLabel ? 'region' : undefined, children: children }) }));
|
|
122
147
|
}
|
|
123
148
|
ModalBody.displayName = 'Modal.Body';
|
|
124
149
|
// =============================================================================
|
|
@@ -59,8 +59,8 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
|
|
|
59
59
|
if (active instanceof HTMLButtonElement &&
|
|
60
60
|
active.disabled &&
|
|
61
61
|
group.contains(active)) {
|
|
62
|
-
const fallback = group.querySelector('
|
|
63
|
-
group.querySelector('
|
|
62
|
+
const fallback = group.querySelector('[data-direction="up"]:not(:disabled), [data-direction="down"]:not(:disabled)') ??
|
|
63
|
+
group.querySelector('[data-role="drag-handle"]');
|
|
64
64
|
fallback?.focus();
|
|
65
65
|
}
|
|
66
66
|
}, [mode, canMoveUp, canMoveDown]);
|
|
@@ -80,5 +80,5 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
|
|
|
80
80
|
const resolvedLockedDesc = locked
|
|
81
81
|
? (labels?.locked ?? 'This item is locked and cannot be reordered')
|
|
82
82
|
: undefined;
|
|
83
|
-
return (_jsxs("div", { ref: mergedRef, role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": locked ? lockedDescId : undefined, "aria-disabled": locked || undefined, className: cx('tui-move-handle', `is-size-${size}`, locked && 'is-locked', hasIndex && 'has-index', className), children: [locked && (_jsx("span", { id: lockedDescId, className: "tui-visually-hidden", children: resolvedLockedDesc })), onMoveUp && (_jsx("button", { type: "button", className: "tui-move-handle__up", "aria-label": labels?.moveUp ?? 'Move up', disabled: locked || !canMoveUp, onClick: onMoveUp, children: _jsx(Icon, { name: "system/chevron-up" }) })), _jsx("div", { className: "tui-move-handle__center", children: locked ? (_jsx("span", { className: "tui-move-handle__lock", "aria-hidden": "true", children: _jsx(Icon, { name: "system/lock" }) })) : (_jsxs(_Fragment, { children: [hasIndex && (_jsx("span", { className: "tui-move-handle__index", "aria-hidden": "true", children: index })), _jsx("button", { type: "button", className: "tui-move-handle__handle", "aria-label": resolvedDragLabel, tabIndex: hasArrows ? -1 : 0, ...restDragProps, children: _jsx(Icon, { name: "system/handle-alt" }) })] })) }), onMoveDown && (_jsx("button", { type: "button", className: "tui-move-handle__down", "aria-label": labels?.moveDown ?? 'Move down', disabled: locked || !canMoveDown, onClick: onMoveDown, children: _jsx(Icon, { name: "system/chevron-down" }) }))] }));
|
|
83
|
+
return (_jsxs("div", { ref: mergedRef, role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": locked ? lockedDescId : undefined, "aria-disabled": locked || undefined, className: cx('tui-move-handle', `is-size-${size}`, locked && 'is-locked', hasIndex && 'has-index', className), children: [locked && (_jsx("span", { id: lockedDescId, className: "tui-visually-hidden", children: resolvedLockedDesc })), onMoveUp && (_jsx("button", { type: "button", className: "tui-move-handle__up", "data-direction": "up", "aria-label": labels?.moveUp ?? 'Move up', disabled: locked || !canMoveUp, onClick: onMoveUp, children: _jsx(Icon, { name: "system/chevron-up" }) })), _jsx("div", { className: "tui-move-handle__center", children: locked ? (_jsx("span", { className: "tui-move-handle__lock", "aria-hidden": "true", children: _jsx(Icon, { name: "system/lock" }) })) : (_jsxs(_Fragment, { children: [hasIndex && (_jsx("span", { className: "tui-move-handle__index", "aria-hidden": "true", children: index })), _jsx("button", { type: "button", className: "tui-move-handle__handle", "data-role": "drag-handle", "aria-label": resolvedDragLabel, tabIndex: hasArrows ? -1 : 0, ...restDragProps, children: _jsx(Icon, { name: "system/handle-alt" }) })] })) }), onMoveDown && (_jsx("button", { type: "button", className: "tui-move-handle__down", "data-direction": "down", "aria-label": labels?.moveDown ?? 'Move down', disabled: locked || !canMoveDown, onClick: onMoveDown, children: _jsx(Icon, { name: "system/chevron-down" }) }))] }));
|
|
84
84
|
});
|
|
@@ -23,9 +23,19 @@ export interface MoveHandleProps {
|
|
|
23
23
|
index?: number;
|
|
24
24
|
/** When true, shows lock icon and disables all interaction. */
|
|
25
25
|
locked?: boolean;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Called when the "move up" button is clicked. Button not rendered when omitted.
|
|
28
|
+
*
|
|
29
|
+
* Consumers should announce the result via a live region (e.g. "Item moved to position 2 of 5")
|
|
30
|
+
* since the DOM reorder is not conveyed to screen readers automatically.
|
|
31
|
+
*/
|
|
27
32
|
onMoveUp?: () => void;
|
|
28
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Called when the "move down" button is clicked. Button not rendered when omitted.
|
|
35
|
+
*
|
|
36
|
+
* Consumers should announce the result via a live region (e.g. "Item moved to position 4 of 5")
|
|
37
|
+
* since the DOM reorder is not conveyed to screen readers automatically.
|
|
38
|
+
*/
|
|
29
39
|
onMoveDown?: () => void;
|
|
30
40
|
/** When false, disables the move-up button without hiding it. Default: true. */
|
|
31
41
|
canMoveUp?: boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { forwardRef, useId, useMemo, useState, useRef, useImperativeHandle, useCallback, useEffect, useContext, createContext } from 'react';
|
|
2
|
+
import React, { forwardRef, useId, useMemo, useState, useRef, useImperativeHandle, useCallback, useEffect, useLayoutEffect, useContext, createContext } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { IconButton } from '../IconButton/index.js';
|
|
5
5
|
const NoticeContext = createContext(null);
|
|
@@ -15,9 +15,11 @@ export const Notice = forwardRef(function Notice({ as = 'section', inline, eleva
|
|
|
15
15
|
const [isExiting, setIsExiting] = useState(false);
|
|
16
16
|
const [exitType, setExitType] = useState(null);
|
|
17
17
|
const exitResolveRef = useRef(null);
|
|
18
|
-
//
|
|
19
|
-
const
|
|
20
|
-
|
|
18
|
+
// Pre-compute a stable title ID so aria-labelledby is wired on first render
|
|
19
|
+
const titleId = useId();
|
|
20
|
+
// Track whether Notice.Head has a title (for role="region" on non-section elements)
|
|
21
|
+
const [hasTitle, setHasTitle] = useState(false);
|
|
22
|
+
const contextValue = useMemo(() => ({ titleId, setHasTitle }), [titleId]);
|
|
21
23
|
// Cleanup on unmount - resolve any pending exit promise
|
|
22
24
|
useEffect(() => {
|
|
23
25
|
return () => {
|
|
@@ -72,6 +74,16 @@ export const Notice = forwardRef(function Notice({ as = 'section', inline, eleva
|
|
|
72
74
|
setIsExiting(true);
|
|
73
75
|
});
|
|
74
76
|
}, [exitAnimation]);
|
|
77
|
+
// Announce dismiss to screen readers via a short-lived live region
|
|
78
|
+
const announceDismiss = useCallback(() => {
|
|
79
|
+
const el = document.createElement('div');
|
|
80
|
+
el.setAttribute('role', 'status');
|
|
81
|
+
el.setAttribute('aria-live', 'polite');
|
|
82
|
+
el.className = 'tui-visually-hidden';
|
|
83
|
+
el.textContent = 'Notification dismissed';
|
|
84
|
+
document.body.appendChild(el);
|
|
85
|
+
setTimeout(() => el.remove(), 1000);
|
|
86
|
+
}, []);
|
|
75
87
|
// Handle dismiss button click
|
|
76
88
|
const handleDismiss = useCallback(async () => {
|
|
77
89
|
if (disabled)
|
|
@@ -79,8 +91,9 @@ export const Notice = forwardRef(function Notice({ as = 'section', inline, eleva
|
|
|
79
91
|
if (exitAnimation !== 'none') {
|
|
80
92
|
await exit(exitAnimation);
|
|
81
93
|
}
|
|
94
|
+
announceDismiss();
|
|
82
95
|
onDismiss?.();
|
|
83
|
-
}, [disabled, exitAnimation, exit, onDismiss]);
|
|
96
|
+
}, [disabled, exitAnimation, exit, onDismiss, announceDismiss]);
|
|
84
97
|
// Expose imperative handle as plain object (not mutating DOM element)
|
|
85
98
|
useImperativeHandle(ref, () => ({
|
|
86
99
|
element: innerRef.current,
|
|
@@ -97,15 +110,15 @@ export const Notice = forwardRef(function Notice({ as = 'section', inline, eleva
|
|
|
97
110
|
}, [interactive, onClick, disabled]);
|
|
98
111
|
// A11y role mapping
|
|
99
112
|
const liveRole = announce === 'assertive' ? 'alert' : announce === 'polite' ? 'status' : undefined;
|
|
100
|
-
|
|
101
|
-
const isNamed = Boolean(titleId) || Boolean(ariaLabel);
|
|
113
|
+
const isNamed = hasTitle || Boolean(ariaLabel);
|
|
102
114
|
const regionRole = !liveRole && isNamed ? 'region' : undefined;
|
|
103
115
|
// If interactive + onClick, behave as button
|
|
104
116
|
const isClickable = interactive && onClick && !disabled;
|
|
105
117
|
const computedRole = isClickable ? 'button' : (liveRole ?? regionRole);
|
|
106
|
-
// aria-labelledby
|
|
107
|
-
|
|
108
|
-
const
|
|
118
|
+
// aria-labelledby uses pre-computed ID, gated on hasTitle to prevent landmark pollution.
|
|
119
|
+
// useLayoutEffect in NoticeHead ensures hasTitle is set before first paint.
|
|
120
|
+
const computedLabelledBy = hasTitle ? titleId : undefined;
|
|
121
|
+
const computedAriaLabel = hasTitle ? undefined : (ariaLabel || undefined);
|
|
109
122
|
const classes = cx('tui-notice', `is-theme-${theme}`, inline && 'is-layout-inline', elevated && 'is-style-elevated', interactive && 'has-interaction', disabled && 'is-disabled', stripe && 'has-stripe', dismissible && 'is-dismissible', focusable && 'is-focusable', isExiting && exitType && `is-exiting-${exitType}`, className);
|
|
110
123
|
// Dismiss button component
|
|
111
124
|
const dismissButton = dismissible && (_jsx(IconButton, { icon: "system/close", label: dismissLabel, size: "sm", variant: "ghost", className: "tui-notice__dismiss", onClick: (e) => {
|
|
@@ -119,16 +132,16 @@ export const Notice = forwardRef(function Notice({ as = 'section', inline, eleva
|
|
|
119
132
|
// Notice.Head renders icon and title (consumers can add custom actions via children)
|
|
120
133
|
function NoticeHead({ className, icon, title, titleAs = 'h3', children, ...rest }) {
|
|
121
134
|
const context = useContext(NoticeContext);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (context &&
|
|
127
|
-
context.
|
|
128
|
-
return () => context.
|
|
135
|
+
// Tell parent whether a title is present — useLayoutEffect ensures
|
|
136
|
+
// aria-labelledby and role="region" are set before the first paint.
|
|
137
|
+
const hasTitle = Boolean(title);
|
|
138
|
+
useLayoutEffect(() => {
|
|
139
|
+
if (context && hasTitle) {
|
|
140
|
+
context.setHasTitle(true);
|
|
141
|
+
return () => context.setHasTitle(false);
|
|
129
142
|
}
|
|
130
|
-
}, [context,
|
|
131
|
-
return (_jsxs("div", { className: cx('tui-notice__head', className), ...rest, children: [icon && _jsx("div", { className: "tui-notice__icon", children: icon }), title && React.createElement(titleAs, { id: titleId, className: 'tui-notice__title' }, title), children] }));
|
|
143
|
+
}, [context, hasTitle]);
|
|
144
|
+
return (_jsxs("div", { className: cx('tui-notice__head', className), ...rest, children: [icon && _jsx("div", { className: "tui-notice__icon", children: icon }), title && React.createElement(titleAs, { id: context?.titleId, className: 'tui-notice__title' }, title), children] }));
|
|
132
145
|
}
|
|
133
146
|
NoticeHead.displayName = 'Notice.Head';
|
|
134
147
|
function NoticeBody({ className, children, ...rest }) {
|
|
@@ -306,13 +306,17 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
|
|
|
306
306
|
refs.reference.current.focus();
|
|
307
307
|
}
|
|
308
308
|
}, [open, refs.reference]);
|
|
309
|
-
// Handle Enter/Space for selection when open
|
|
309
|
+
// Handle Enter/Space for selection when open, Delete/Backspace to clear when closed
|
|
310
310
|
const handleKeyDown = useCallback((e) => {
|
|
311
311
|
if (open && (e.key === 'Enter' || e.key === ' ')) {
|
|
312
312
|
e.preventDefault();
|
|
313
313
|
handleSelect(activeIndex);
|
|
314
314
|
}
|
|
315
|
-
|
|
315
|
+
if (!open && clearable && hasValue && (e.key === 'Delete' || e.key === 'Backspace')) {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
setValue(undefined);
|
|
318
|
+
}
|
|
319
|
+
}, [open, activeIndex, handleSelect, clearable, hasValue, setValue]);
|
|
316
320
|
// Close dropdown when focus leaves trigger
|
|
317
321
|
// Uses blur guard pattern: only close if focus moved outside controlled elements
|
|
318
322
|
const handleBlur = useCallback((e) => {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import type { SidebarProps, SidebarHeaderProps, SidebarNavProps } from './types';
|
|
1
|
+
import type { SidebarProps, SidebarHeaderProps, SidebarFooterProps, SidebarNavProps } from './types';
|
|
2
2
|
declare function SidebarHeader(props: SidebarHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
3
3
|
declare namespace SidebarHeader {
|
|
4
4
|
var displayName: string;
|
|
5
5
|
}
|
|
6
|
+
declare function SidebarFooter(props: SidebarFooterProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
declare namespace SidebarFooter {
|
|
8
|
+
var displayName: string;
|
|
9
|
+
}
|
|
6
10
|
declare function SidebarNav(props: SidebarNavProps): import("react/jsx-runtime").JSX.Element;
|
|
7
11
|
declare namespace SidebarNav {
|
|
8
12
|
var displayName: string;
|
|
@@ -11,6 +15,7 @@ type SidebarCompound = {
|
|
|
11
15
|
(props: SidebarProps): React.JSX.Element | null;
|
|
12
16
|
displayName?: string;
|
|
13
17
|
Header: typeof SidebarHeader;
|
|
18
|
+
Footer: typeof SidebarFooter;
|
|
14
19
|
Nav: typeof SidebarNav;
|
|
15
20
|
};
|
|
16
21
|
export declare const Sidebar: SidebarCompound;
|
|
@@ -1,14 +1,50 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
3
|
import { useFocusTrap, getInitialFocus } from '../../utils/focus-trap.js';
|
|
4
|
+
import { isDev } from '../../utils/is-dev.js';
|
|
4
5
|
const isBrowser = typeof document !== 'undefined';
|
|
5
6
|
// =============================================================================
|
|
6
7
|
// Sidebar (Root)
|
|
7
8
|
// =============================================================================
|
|
8
9
|
function SidebarRoot(props) {
|
|
9
|
-
const { position = 'left',
|
|
10
|
+
const { position = 'left', 'aria-label': ariaLabel, children, className, } = props;
|
|
11
|
+
// Drawer-specific props — narrowed via discriminant property access
|
|
12
|
+
const drawer = props.drawer === true;
|
|
13
|
+
const open = props.drawer ? (props.open ?? false) : false;
|
|
14
|
+
const onClose = props.drawer ? props.onClose : undefined;
|
|
10
15
|
const sidebarRef = useRef(null);
|
|
11
16
|
const restoreRef = useRef(null);
|
|
17
|
+
// Exit animation: keep DOM mounted until slide-out animation completes.
|
|
18
|
+
// `visible` stays true after `open` goes false, until animationend fires.
|
|
19
|
+
const [visible, setVisible] = useState(open);
|
|
20
|
+
const panelRef = useRef(null);
|
|
21
|
+
// When open becomes true, immediately make visible
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (open)
|
|
24
|
+
setVisible(true);
|
|
25
|
+
}, [open]);
|
|
26
|
+
const isClosing = drawer && !open && visible;
|
|
27
|
+
const shouldRender = drawer && (open || visible);
|
|
28
|
+
// When closing, check if animation will actually play (reduced motion).
|
|
29
|
+
// If not, unmount immediately to avoid getting stuck.
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
if (!isClosing)
|
|
32
|
+
return;
|
|
33
|
+
const panel = panelRef.current;
|
|
34
|
+
if (!panel) {
|
|
35
|
+
setVisible(false);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const style = getComputedStyle(panel);
|
|
39
|
+
if (!style.animationName || style.animationName === 'none') {
|
|
40
|
+
setVisible(false);
|
|
41
|
+
}
|
|
42
|
+
}, [isClosing]);
|
|
43
|
+
const handleAnimationEnd = useCallback((e) => {
|
|
44
|
+
if (e.target === panelRef.current) {
|
|
45
|
+
setVisible(false);
|
|
46
|
+
}
|
|
47
|
+
}, []);
|
|
12
48
|
const rootClassName = ['tui-sidebar', className].filter(Boolean).join(' ');
|
|
13
49
|
// ---------------------------------------------------------------------------
|
|
14
50
|
// Drawer mode: capture trigger for focus restoration
|
|
@@ -21,9 +57,9 @@ function SidebarRoot(props) {
|
|
|
21
57
|
restoreRef.current = document.activeElement;
|
|
22
58
|
}
|
|
23
59
|
else {
|
|
24
|
-
// Restore focus when closing
|
|
60
|
+
// Restore focus when closing (with DOM containment guard)
|
|
25
61
|
const el = restoreRef.current;
|
|
26
|
-
if (el && typeof el.focus === 'function') {
|
|
62
|
+
if (el && typeof el.focus === 'function' && document.contains(el)) {
|
|
27
63
|
el.focus();
|
|
28
64
|
}
|
|
29
65
|
restoreRef.current = null;
|
|
@@ -48,9 +84,9 @@ function SidebarRoot(props) {
|
|
|
48
84
|
onEscape: onClose,
|
|
49
85
|
});
|
|
50
86
|
// ---------------------------------------------------------------------------
|
|
51
|
-
// Drawer mode: initial focus
|
|
87
|
+
// Drawer mode: initial focus (useLayoutEffect to avoid flash/race)
|
|
52
88
|
// ---------------------------------------------------------------------------
|
|
53
|
-
|
|
89
|
+
useLayoutEffect(() => {
|
|
54
90
|
if (!drawer || !open)
|
|
55
91
|
return;
|
|
56
92
|
const sidebar = sidebarRef.current;
|
|
@@ -60,28 +96,36 @@ function SidebarRoot(props) {
|
|
|
60
96
|
target.focus({ preventScroll: true });
|
|
61
97
|
}, [drawer, open]);
|
|
62
98
|
// ---------------------------------------------------------------------------
|
|
63
|
-
//
|
|
99
|
+
// Dev warning: drawer without accessible name
|
|
64
100
|
// ---------------------------------------------------------------------------
|
|
65
|
-
const
|
|
101
|
+
const hasWarnedRef = useRef(false);
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (isDev() && drawer && open && !ariaLabel && !hasWarnedRef.current) {
|
|
104
|
+
console.warn('[TUI Sidebar] Drawer mode is missing aria-label. ' +
|
|
105
|
+
'The dialog role requires an accessible name (WCAG 4.1.2).');
|
|
106
|
+
hasWarnedRef.current = true;
|
|
107
|
+
}
|
|
108
|
+
}, [drawer, open, ariaLabel]);
|
|
66
109
|
// ---------------------------------------------------------------------------
|
|
67
110
|
// Static mode: render directly
|
|
68
111
|
// ---------------------------------------------------------------------------
|
|
69
112
|
if (!drawer) {
|
|
70
|
-
return
|
|
113
|
+
return (_jsx("aside", { ref: sidebarRef, className: rootClassName, "data-position": position, "aria-label": ariaLabel, children: children }));
|
|
71
114
|
}
|
|
72
115
|
// ---------------------------------------------------------------------------
|
|
73
116
|
// Drawer mode: render inline with fixed positioning (no portal needed)
|
|
74
117
|
// ---------------------------------------------------------------------------
|
|
75
|
-
if (!
|
|
118
|
+
if (!shouldRender) {
|
|
76
119
|
return null;
|
|
77
120
|
}
|
|
121
|
+
const drawerState = isClosing ? 'closing' : 'open';
|
|
78
122
|
const drawerClassName = [
|
|
79
123
|
'tui-sidebar-drawer',
|
|
80
124
|
position === 'right' && 'is-position-right',
|
|
81
125
|
]
|
|
82
126
|
.filter(Boolean)
|
|
83
127
|
.join(' ');
|
|
84
|
-
return (_jsxs("div", { className: drawerClassName, "data-position": position, "data-state":
|
|
128
|
+
return (_jsxs("div", { className: drawerClassName, "data-position": position, "data-state": drawerState, children: [_jsx("div", { className: "tui-sidebar-drawer__backdrop", onClick: onClose, "aria-hidden": "true" }), _jsx("div", { ref: panelRef, className: "tui-sidebar-drawer__panel", role: "dialog", "aria-modal": "true", "aria-label": ariaLabel, onAnimationEnd: handleAnimationEnd, children: _jsx("aside", { ref: sidebarRef, className: rootClassName, "data-position": position, tabIndex: -1, children: children }) })] }));
|
|
85
129
|
}
|
|
86
130
|
// =============================================================================
|
|
87
131
|
// Sidebar.Header
|
|
@@ -92,6 +136,14 @@ function SidebarHeader(props) {
|
|
|
92
136
|
return _jsx("header", { className: headerClassName, children: children });
|
|
93
137
|
}
|
|
94
138
|
// =============================================================================
|
|
139
|
+
// Sidebar.Footer
|
|
140
|
+
// =============================================================================
|
|
141
|
+
function SidebarFooter(props) {
|
|
142
|
+
const { children, className } = props;
|
|
143
|
+
const footerClassName = ['tui-sidebar__footer', className].filter(Boolean).join(' ');
|
|
144
|
+
return _jsx("footer", { className: footerClassName, children: children });
|
|
145
|
+
}
|
|
146
|
+
// =============================================================================
|
|
95
147
|
// Sidebar.Nav
|
|
96
148
|
// =============================================================================
|
|
97
149
|
function SidebarNav(props) {
|
|
@@ -100,8 +152,10 @@ function SidebarNav(props) {
|
|
|
100
152
|
return (_jsx("nav", { className: navClassName, "aria-label": navAriaLabel, "aria-labelledby": navAriaLabelledBy, children: children }));
|
|
101
153
|
}
|
|
102
154
|
SidebarHeader.displayName = 'Sidebar.Header';
|
|
155
|
+
SidebarFooter.displayName = 'Sidebar.Footer';
|
|
103
156
|
SidebarNav.displayName = 'Sidebar.Nav';
|
|
104
157
|
export const Sidebar = SidebarRoot;
|
|
105
158
|
Sidebar.displayName = 'Sidebar';
|
|
106
159
|
Sidebar.Header = SidebarHeader;
|
|
160
|
+
Sidebar.Footer = SidebarFooter;
|
|
107
161
|
Sidebar.Nav = SidebarNav;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { Sidebar } from './Sidebar';
|
|
2
|
-
export type { SidebarProps, SidebarHeaderProps, SidebarNavProps, } from './types';
|
|
2
|
+
export type { SidebarProps, SidebarHeaderProps, SidebarFooterProps, SidebarNavProps, } from './types';
|
|
@@ -1,32 +1,55 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
|
|
2
|
+
type SidebarBaseProps = {
|
|
3
3
|
/**
|
|
4
4
|
* Sidebar position. Affects border placement and drawer slide direction.
|
|
5
5
|
* @default 'left'
|
|
6
6
|
*/
|
|
7
7
|
position?: 'left' | 'right';
|
|
8
|
+
/**
|
|
9
|
+
* Accessible label for the sidebar landmark (static) or dialog (drawer).
|
|
10
|
+
* When a page has multiple sidebars, each must have a unique label so
|
|
11
|
+
* screen reader users can distinguish between landmarks
|
|
12
|
+
* (e.g. "Course navigation", "Lesson settings").
|
|
13
|
+
*/
|
|
14
|
+
'aria-label'?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Sidebar content (Header, Nav, etc.).
|
|
17
|
+
*/
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
/**
|
|
20
|
+
* Additional CSS class names.
|
|
21
|
+
*/
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
type SidebarStaticProps = SidebarBaseProps & {
|
|
8
25
|
/**
|
|
9
26
|
* Enable drawer mode (mobile overlay).
|
|
10
|
-
* When true, sidebar renders via portal with backdrop and focus trap.
|
|
11
27
|
* @default false
|
|
12
28
|
*/
|
|
13
|
-
drawer?:
|
|
29
|
+
drawer?: false;
|
|
30
|
+
};
|
|
31
|
+
type SidebarDrawerProps = SidebarBaseProps & {
|
|
14
32
|
/**
|
|
15
|
-
*
|
|
33
|
+
* Enable drawer mode (mobile overlay).
|
|
34
|
+
* When true, sidebar renders with backdrop, focus trap, and dialog semantics.
|
|
16
35
|
*/
|
|
17
|
-
|
|
36
|
+
drawer: true;
|
|
18
37
|
/**
|
|
19
|
-
*
|
|
20
|
-
* Called when ESC is pressed or backdrop is clicked.
|
|
38
|
+
* Drawer open state.
|
|
21
39
|
*/
|
|
22
|
-
|
|
40
|
+
open?: boolean;
|
|
23
41
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
42
|
+
* Callback to close the drawer. Required when `drawer={true}`.
|
|
43
|
+
* Called when ESC is pressed or backdrop is clicked.
|
|
44
|
+
* Omitting this in drawer mode means the user cannot dismiss the overlay,
|
|
45
|
+
* which is a WCAG 2.1.1 (Keyboard) failure.
|
|
26
46
|
*/
|
|
27
|
-
|
|
47
|
+
onClose: () => void;
|
|
48
|
+
};
|
|
49
|
+
export type SidebarProps = SidebarStaticProps | SidebarDrawerProps;
|
|
50
|
+
export type SidebarHeaderProps = {
|
|
28
51
|
/**
|
|
29
|
-
*
|
|
52
|
+
* Header content (back button, title, progress, CTAs).
|
|
30
53
|
*/
|
|
31
54
|
children: ReactNode;
|
|
32
55
|
/**
|
|
@@ -34,9 +57,10 @@ export type SidebarProps = {
|
|
|
34
57
|
*/
|
|
35
58
|
className?: string;
|
|
36
59
|
};
|
|
37
|
-
export type
|
|
60
|
+
export type SidebarFooterProps = {
|
|
38
61
|
/**
|
|
39
|
-
*
|
|
62
|
+
* Footer content (CTAs, user info, etc.).
|
|
63
|
+
* Sticks to the bottom of the sidebar.
|
|
40
64
|
*/
|
|
41
65
|
children: ReactNode;
|
|
42
66
|
/**
|
|
@@ -63,3 +87,4 @@ export type SidebarNavProps = {
|
|
|
63
87
|
*/
|
|
64
88
|
className?: string;
|
|
65
89
|
};
|
|
90
|
+
export {};
|
|
@@ -11,7 +11,7 @@ declare function TabsList({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabe
|
|
|
11
11
|
declare namespace TabsList {
|
|
12
12
|
var displayName: string;
|
|
13
13
|
}
|
|
14
|
-
declare function Tab({ value, icon, disabled, className, children }: TabProps): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
declare function Tab({ value, icon, 'aria-label': ariaLabel, disabled, className, children }: TabProps): import("react/jsx-runtime").JSX.Element;
|
|
15
15
|
declare namespace Tab {
|
|
16
16
|
var displayName: string;
|
|
17
17
|
}
|
package/components/Tabs/Tabs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
|
+
import { isDev } from '../../utils/is-dev.js';
|
|
4
5
|
import { Icon } from '../Icon/index.js';
|
|
5
6
|
import { TabsContext, useTabsContext } from './TabsContext.js';
|
|
6
7
|
// =============================================================================
|
|
@@ -243,11 +244,19 @@ function TabsList({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy,
|
|
|
243
244
|
// =============================================================================
|
|
244
245
|
// Tabs.Tab
|
|
245
246
|
// =============================================================================
|
|
246
|
-
function Tab({ value, icon, disabled = false, className, children }) {
|
|
247
|
+
function Tab({ value, icon, 'aria-label': ariaLabel, disabled = false, className, children }) {
|
|
247
248
|
const { activationMode, activeValue, focusedValue, registerTab, unregisterTab, onSelect, getTabId, getPanelId, } = useTabsContext();
|
|
248
249
|
const tabId = getTabId(value);
|
|
249
250
|
const panelId = getPanelId(value);
|
|
250
251
|
const isActive = activeValue === value;
|
|
252
|
+
// Dev-only: Warn if icon-only tab has no accessible name
|
|
253
|
+
const warnedRef = useRef(false);
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (isDev() && !warnedRef.current && icon && !children && !ariaLabel) {
|
|
256
|
+
console.warn(`Tabs.Tab "${value}": Icon-only tab without aria-label. Provide aria-label for an accessible name.`);
|
|
257
|
+
warnedRef.current = true;
|
|
258
|
+
}
|
|
259
|
+
}, [icon, children, ariaLabel, value]);
|
|
251
260
|
// Determine which tab gets tabIndex={0}
|
|
252
261
|
const getFocusableValue = () => {
|
|
253
262
|
if (activationMode === 'auto') {
|
|
@@ -275,7 +284,7 @@ function Tab({ value, icon, disabled = false, className, children }) {
|
|
|
275
284
|
onSelect(value);
|
|
276
285
|
}
|
|
277
286
|
};
|
|
278
|
-
return (_jsxs("button", { ref: callbackRef, type: "button", role: "tab", id: tabId, className: cx('tui-tabs__tab', className), "data-tui-tab-value": value, "data-state": isActive ? 'active' : 'inactive', "aria-selected": isActive, "aria-controls": panelId, tabIndex: disabled ? -1 : isFocusable ? 0 : -1, disabled: disabled, onClick: handleClick, children: [icon && _jsx(Icon, { name: icon, size: "
|
|
287
|
+
return (_jsxs("button", { ref: callbackRef, type: "button", role: "tab", id: tabId, className: cx('tui-tabs__tab', className), "data-tui-tab-value": value, "data-state": isActive ? 'active' : 'inactive', "aria-selected": isActive, "aria-controls": panelId, "aria-label": ariaLabel, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : isFocusable ? 0 : -1, "data-disabled": disabled || undefined, onClick: handleClick, children: [icon && _jsx(Icon, { name: icon, size: "md", "aria-hidden": "true" }), children && _jsx("span", { className: "tui-tabs__tab-label", children: children })] }));
|
|
279
288
|
}
|
|
280
289
|
// =============================================================================
|
|
281
290
|
// Tabs.Panel
|
|
@@ -290,7 +299,7 @@ function TabPanel({ value, className, children }) {
|
|
|
290
299
|
registerPanel(value);
|
|
291
300
|
return () => unregisterPanel(value);
|
|
292
301
|
}, [value, registerPanel, unregisterPanel]);
|
|
293
|
-
return (_jsx("div", { role: "tabpanel", id: panelId, className: cx('tui-tabs__panel', className), "data-state": isActive ? 'active' : 'inactive', "aria-labelledby": tabId, "aria-hidden":
|
|
302
|
+
return (_jsx("div", { role: "tabpanel", id: panelId, className: cx('tui-tabs__panel', className), "data-state": isActive ? 'active' : 'inactive', "aria-labelledby": tabId, "aria-hidden": isActive ? undefined : true,
|
|
294
303
|
// tabIndex={0} allows direct focus on panel for screen reader navigation
|
|
295
304
|
tabIndex: isActive ? 0 : undefined,
|
|
296
305
|
// inert prevents Tab navigation into hidden panel content
|
|
@@ -20,20 +20,34 @@ export type TabsProps = {
|
|
|
20
20
|
className?: string;
|
|
21
21
|
children: ReactNode;
|
|
22
22
|
};
|
|
23
|
-
|
|
24
|
-
/** Accessible label (required) */
|
|
25
|
-
'aria-label'?: string;
|
|
26
|
-
/** Alternative to aria-label */
|
|
27
|
-
'aria-labelledby'?: string;
|
|
23
|
+
type TabsListBaseProps = {
|
|
28
24
|
/** Additional classes */
|
|
29
25
|
className?: string;
|
|
30
26
|
children: ReactNode;
|
|
31
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* Tabs.List requires an accessible name via either `aria-label` or `aria-labelledby`.
|
|
30
|
+
* Omitting both leaves the tablist unnamed, which makes it difficult for screen reader
|
|
31
|
+
* users to identify the purpose of the tab group.
|
|
32
|
+
*/
|
|
33
|
+
export type TabsListProps = TabsListBaseProps & ({
|
|
34
|
+
'aria-label': string;
|
|
35
|
+
'aria-labelledby'?: string;
|
|
36
|
+
} | {
|
|
37
|
+
'aria-label'?: string; /** ID of a visible heading that labels the tablist */
|
|
38
|
+
'aria-labelledby': string;
|
|
39
|
+
});
|
|
32
40
|
export type TabProps = {
|
|
33
41
|
/** Unique identifier, must match a Panel */
|
|
34
42
|
value: string;
|
|
35
43
|
/** Icon name from registry */
|
|
36
44
|
icon?: IconName;
|
|
45
|
+
/**
|
|
46
|
+
* Accessible label for the tab button. Required for icon-only tabs
|
|
47
|
+
* (no `children`). Omitting this on an icon-only tab leaves the button
|
|
48
|
+
* without an accessible name, making it invisible to screen readers.
|
|
49
|
+
*/
|
|
50
|
+
'aria-label'?: string;
|
|
37
51
|
/** Disable tab (skipped in keyboard nav) */
|
|
38
52
|
disabled?: boolean;
|
|
39
53
|
/** Additional classes */
|
|
@@ -73,3 +87,4 @@ export type TabsContextValue = {
|
|
|
73
87
|
getPanelId: (value: string) => string;
|
|
74
88
|
tabsRef: React.MutableRefObject<Map<string, TabRecord>>;
|
|
75
89
|
};
|
|
90
|
+
export {};
|