@tangible/ui 0.0.3 → 0.0.4
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 +1 -1
- package/components/Accordion/Accordion.js +3 -3
- package/components/Accordion/types.d.ts +8 -1
- 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 +310 -57
- package/styles/all.expanded.unlayered.css +310 -57
- package/styles/all.unlayered.css +1 -1
- package/styles/system/_tokens.scss +3 -0
- package/tui-manifest.json +278 -64
- package/utils/focus-trap.js +8 -1
|
@@ -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 {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { forwardRef } from 'react';
|
|
2
|
+
import { forwardRef, useCallback } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
// =============================================================================
|
|
5
5
|
// TextInput Component
|
|
@@ -17,9 +17,17 @@ import { cx } from '../../utils/cx.js';
|
|
|
17
17
|
// --tui-input-radius Border radius
|
|
18
18
|
//
|
|
19
19
|
// =============================================================================
|
|
20
|
+
/** String or number content gets the `.is-text` visual treatment. */
|
|
21
|
+
const isTextContent = (node) => typeof node === 'string' || typeof node === 'number';
|
|
20
22
|
export const TextInput = forwardRef(function TextInput({ type = 'text', size = 'md', prefix, suffix, className, inputClassName, disabled, ...rest }, ref) {
|
|
21
23
|
const sizeClass = size !== 'md' ? `is-size-${size}` : undefined;
|
|
24
|
+
const handleGroupClick = useCallback((e) => {
|
|
25
|
+
const target = e.target;
|
|
26
|
+
if (target.closest('button, a, [role="button"], input'))
|
|
27
|
+
return;
|
|
28
|
+
e.currentTarget.querySelector('input')?.focus();
|
|
29
|
+
}, []);
|
|
22
30
|
// Always wrap in tui-input-group so className consistently targets the root.
|
|
23
31
|
// ARIA/form props from Field.Control reach the <input> via ...rest.
|
|
24
|
-
return (_jsxs("div", { className: cx('tui-input-group', sizeClass, disabled && 'is-disabled', className), children: [prefix && _jsx("span", { className:
|
|
32
|
+
return (_jsxs("div", { className: cx('tui-input-group', sizeClass, disabled && 'is-disabled', className), onClick: handleGroupClick, children: [prefix && (_jsx("span", { className: cx('tui-input-group__prefix', isTextContent(prefix) && 'is-text'), children: prefix })), _jsx("input", { ref: ref, type: type, disabled: disabled, className: cx('tui-input', inputClassName), ...rest }), suffix && (_jsx("span", { className: cx('tui-input-group__suffix', isTextContent(suffix) && 'is-text'), children: suffix }))] }));
|
|
25
33
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { TooltipProviderProps, TooltipProps, TooltipTriggerProps, TooltipContentProps } from './types';
|
|
2
2
|
declare function TooltipProviderComponent({ delayDuration, closeDelayDuration, children, }: TooltipProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
3
3
|
declare function TooltipRoot({ open: controlledOpen, onOpenChange, defaultOpen, delayDuration: localDelay, children, }: TooltipProps): import("react/jsx-runtime").JSX.Element;
|
|
4
|
-
declare function TooltipTriggerComponent({ asChild, children }: TooltipTriggerProps): import("react/jsx-runtime").JSX.Element;
|
|
5
|
-
declare function TooltipContentComponent({ side, align, sideOffset, className, children, }: TooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
4
|
+
declare function TooltipTriggerComponent({ asChild, 'aria-label': ariaLabel, children, }: TooltipTriggerProps): import("react/jsx-runtime").JSX.Element;
|
|
5
|
+
declare function TooltipContentComponent({ side, align, sideOffset, theme, className, children, }: TooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
6
6
|
type TooltipCompound = typeof TooltipRoot & {
|
|
7
7
|
Provider: typeof TooltipProviderComponent;
|
|
8
8
|
Trigger: typeof TooltipTriggerComponent;
|