@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 } from "react/jsx-runtime";
|
|
2
|
-
import React, { useCallback, useEffect, useId, useMemo, useRef, useState, cloneElement, isValidElement, Children, } from 'react';
|
|
2
|
+
import React, { useCallback, useEffect, useLayoutEffect, useId, useMemo, useRef, useState, cloneElement, isValidElement, Children, } from 'react';
|
|
3
3
|
import { useFloating, offset, flip, shift, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useRole, FloatingFocusManager, } from '@floating-ui/react';
|
|
4
4
|
import { cx } from '../../utils/cx.js';
|
|
5
5
|
import { getPortalRootFor } from '../../utils/portal.js';
|
|
@@ -25,6 +25,7 @@ function DropdownRoot({ open: controlledOpen, onOpenChange, defaultOpen = false,
|
|
|
25
25
|
}, [isControlled, onOpenChange]);
|
|
26
26
|
const triggerRef = useRef(null);
|
|
27
27
|
const contentId = useId();
|
|
28
|
+
const openedVia = useRef(null);
|
|
28
29
|
// Focus restoration: track if we need to restore focus on close
|
|
29
30
|
const shouldRestoreFocus = useRef(false);
|
|
30
31
|
const prevOpen = useRef(open);
|
|
@@ -47,6 +48,7 @@ function DropdownRoot({ open: controlledOpen, onOpenChange, defaultOpen = false,
|
|
|
47
48
|
contentId,
|
|
48
49
|
activeIndex,
|
|
49
50
|
setActiveIndex,
|
|
51
|
+
openedVia,
|
|
50
52
|
}), [open, setOpen, contentId, activeIndex]);
|
|
51
53
|
return (_jsx(DropdownContext.Provider, { value: contextValue, children: children }));
|
|
52
54
|
}
|
|
@@ -55,24 +57,28 @@ DropdownRoot.displayName = 'Dropdown';
|
|
|
55
57
|
// DropdownTrigger
|
|
56
58
|
// =============================================================================
|
|
57
59
|
function DropdownTriggerComponent({ asChild = false, children }) {
|
|
58
|
-
const { open, setOpen, triggerRef,
|
|
60
|
+
const { open, setOpen, triggerRef, openedVia } = useDropdownContext();
|
|
59
61
|
const handleClick = useCallback(() => {
|
|
62
|
+
openedVia.current = 'click';
|
|
60
63
|
setOpen(!open);
|
|
61
|
-
}, [open, setOpen]);
|
|
64
|
+
}, [open, setOpen, openedVia]);
|
|
62
65
|
const handleKeyDown = useCallback((e) => {
|
|
63
66
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
64
67
|
e.preventDefault();
|
|
68
|
+
openedVia.current = 'click';
|
|
65
69
|
setOpen(!open);
|
|
66
70
|
}
|
|
67
71
|
else if (e.key === 'ArrowDown') {
|
|
68
72
|
e.preventDefault();
|
|
73
|
+
openedVia.current = 'ArrowDown';
|
|
69
74
|
setOpen(true);
|
|
70
75
|
}
|
|
71
76
|
else if (e.key === 'ArrowUp') {
|
|
72
77
|
e.preventDefault();
|
|
78
|
+
openedVia.current = 'ArrowUp';
|
|
73
79
|
setOpen(true);
|
|
74
80
|
}
|
|
75
|
-
}, [open, setOpen]);
|
|
81
|
+
}, [open, setOpen, openedVia]);
|
|
76
82
|
// Merge child ref with our triggerRef
|
|
77
83
|
const setRefs = useCallback((node) => {
|
|
78
84
|
triggerRef.current = node;
|
|
@@ -91,7 +97,6 @@ function DropdownTriggerComponent({ asChild = false, children }) {
|
|
|
91
97
|
onKeyDown: handleKeyDown,
|
|
92
98
|
'aria-haspopup': 'menu',
|
|
93
99
|
'aria-expanded': open,
|
|
94
|
-
'aria-controls': open ? contentId : undefined,
|
|
95
100
|
'data-dropdown-open': open || undefined,
|
|
96
101
|
};
|
|
97
102
|
if (asChild && isValidElement(children)) {
|
|
@@ -108,7 +113,7 @@ DropdownTriggerComponent.displayName = 'Dropdown.Trigger';
|
|
|
108
113
|
// DropdownContent
|
|
109
114
|
// =============================================================================
|
|
110
115
|
function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset = 4, className, style, children, }) {
|
|
111
|
-
const { open, setOpen, triggerRef, contentId, activeIndex, setActiveIndex } = useDropdownContext();
|
|
116
|
+
const { open, setOpen, triggerRef, contentId, activeIndex, setActiveIndex, openedVia } = useDropdownContext();
|
|
112
117
|
const listRef = useRef([]);
|
|
113
118
|
const { refs, floatingStyles, context } = useFloating({
|
|
114
119
|
placement: toPlacement(side, align),
|
|
@@ -123,19 +128,41 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
123
128
|
refs.setReference(triggerRef.current);
|
|
124
129
|
}
|
|
125
130
|
}, [triggerRef, refs]);
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
// Classify children: count navigable items and collect disabled indices.
|
|
132
|
+
// Separator and Header sub-components are non-navigable.
|
|
133
|
+
const { disabledIndices, totalItemCount } = useMemo(() => {
|
|
134
|
+
const disabled = [];
|
|
135
|
+
let itemIdx = 0;
|
|
136
|
+
Children.forEach(children, (child) => {
|
|
137
|
+
if (!isValidElement(child))
|
|
138
|
+
return;
|
|
139
|
+
// Skip non-navigable sub-components
|
|
140
|
+
const childType = child.type;
|
|
141
|
+
if (childType === DropdownSeparatorComponent ||
|
|
142
|
+
childType === DropdownHeaderComponent) {
|
|
143
|
+
return;
|
|
135
144
|
}
|
|
145
|
+
const props = child.props;
|
|
146
|
+
if (props.disabled) {
|
|
147
|
+
disabled.push(itemIdx);
|
|
148
|
+
}
|
|
149
|
+
itemIdx++;
|
|
136
150
|
});
|
|
137
|
-
return
|
|
151
|
+
return { disabledIndices: disabled, totalItemCount: itemIdx };
|
|
138
152
|
}, [children]);
|
|
153
|
+
// ArrowUp focus-last: set activeIndex to last valid item before paint
|
|
154
|
+
useLayoutEffect(() => {
|
|
155
|
+
if (open && openedVia.current === 'ArrowUp') {
|
|
156
|
+
let lastValid = totalItemCount - 1;
|
|
157
|
+
while (lastValid >= 0 && disabledIndices.includes(lastValid)) {
|
|
158
|
+
lastValid--;
|
|
159
|
+
}
|
|
160
|
+
if (lastValid >= 0) {
|
|
161
|
+
setActiveIndex(lastValid);
|
|
162
|
+
}
|
|
163
|
+
openedVia.current = null;
|
|
164
|
+
}
|
|
165
|
+
}, [open, openedVia, totalItemCount, disabledIndices, setActiveIndex]);
|
|
139
166
|
const dismiss = useDismiss(context);
|
|
140
167
|
const role = useRole(context, { role: 'menu' });
|
|
141
168
|
const listNavigation = useListNavigation(context, {
|
|
@@ -155,25 +182,34 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
155
182
|
const portalRoot = getPortalRootFor(triggerRef.current);
|
|
156
183
|
if (!open)
|
|
157
184
|
return null;
|
|
158
|
-
// Clone children to inject item props and role
|
|
159
|
-
|
|
185
|
+
// Clone children to inject item props and role.
|
|
186
|
+
// Non-navigable children (Separator, Header) are rendered as-is.
|
|
187
|
+
let itemIndex = 0;
|
|
188
|
+
const items = Children.map(children, (child) => {
|
|
160
189
|
if (!isValidElement(child))
|
|
161
190
|
return child;
|
|
191
|
+
// Non-navigable sub-components — render without list navigation props
|
|
192
|
+
const childType = child.type;
|
|
193
|
+
if (childType === DropdownSeparatorComponent ||
|
|
194
|
+
childType === DropdownHeaderComponent) {
|
|
195
|
+
return child;
|
|
196
|
+
}
|
|
197
|
+
const currentIndex = itemIndex++;
|
|
162
198
|
const childProps = child.props;
|
|
163
199
|
const isDisabled = childProps.disabled === true;
|
|
164
200
|
const existingRole = childProps.role;
|
|
165
201
|
return cloneElement(child, {
|
|
166
202
|
...getItemProps({
|
|
167
203
|
ref: (node) => {
|
|
168
|
-
listRef.current[
|
|
204
|
+
listRef.current[currentIndex] = node;
|
|
169
205
|
},
|
|
170
206
|
// Disabled items get tabIndex -1 always
|
|
171
|
-
tabIndex: isDisabled ? -1 : (activeIndex ===
|
|
207
|
+
tabIndex: isDisabled ? -1 : (activeIndex === currentIndex ? 0 : -1),
|
|
172
208
|
}),
|
|
173
209
|
// Add menuitem role if not already specified
|
|
174
210
|
role: existingRole || 'menuitem',
|
|
175
211
|
'aria-disabled': isDisabled || undefined,
|
|
176
|
-
'data-active': activeIndex ===
|
|
212
|
+
'data-active': activeIndex === currentIndex || undefined,
|
|
177
213
|
});
|
|
178
214
|
});
|
|
179
215
|
return (_jsx(FloatingPortal, { root: portalRoot, children: _jsx(FloatingFocusManager, { context: context, modal: false, initialFocus: -1, children: _jsx("div", { ref: refs.setFloating, id: contentId, className: cx('tui-dropdown', className), style: {
|
|
@@ -190,31 +226,51 @@ DropdownContentComponent.displayName = 'Dropdown.Content';
|
|
|
190
226
|
* When href is provided, renders as an anchor.
|
|
191
227
|
*/
|
|
192
228
|
function DropdownItemComponent({ onSelect, href, target = '_self', disabled = false, keepOpen = false, className, children, ...props }) {
|
|
193
|
-
const { setOpen
|
|
229
|
+
const { setOpen } = useDropdownContext();
|
|
194
230
|
const handleClick = useCallback(() => {
|
|
195
231
|
if (disabled)
|
|
196
232
|
return;
|
|
197
233
|
onSelect?.();
|
|
198
234
|
if (!keepOpen) {
|
|
235
|
+
// Focus restoration is handled by DropdownRoot's useEffect on `open`
|
|
199
236
|
setOpen(false);
|
|
200
|
-
// Explicitly restore focus after programmatic close
|
|
201
|
-
// Small delay to let the dropdown unmount first
|
|
202
|
-
requestAnimationFrame(() => {
|
|
203
|
-
triggerRef.current?.focus();
|
|
204
|
-
});
|
|
205
237
|
}
|
|
206
|
-
}, [disabled, onSelect, keepOpen, setOpen
|
|
238
|
+
}, [disabled, onSelect, keepOpen, setOpen]);
|
|
207
239
|
return (_jsx(Button, { ...props, variant: "ghost", href: disabled ? undefined : href, target: href ? target : undefined, disabled: disabled, onClick: handleClick, className: cx('tui-dropdown__item', className), children: children }));
|
|
208
240
|
}
|
|
209
241
|
DropdownItemComponent.displayName = 'Dropdown.Item';
|
|
242
|
+
// =============================================================================
|
|
243
|
+
// DropdownSeparator
|
|
244
|
+
// =============================================================================
|
|
245
|
+
/**
|
|
246
|
+
* Non-interactive separator for visually grouping menu items.
|
|
247
|
+
*/
|
|
248
|
+
function DropdownSeparatorComponent({ className }) {
|
|
249
|
+
return (_jsx("hr", { role: "separator", className: cx('tui-dropdown__separator', className) }));
|
|
250
|
+
}
|
|
251
|
+
DropdownSeparatorComponent.displayName = 'Dropdown.Separator';
|
|
252
|
+
// =============================================================================
|
|
253
|
+
// DropdownHeader
|
|
254
|
+
// =============================================================================
|
|
255
|
+
/**
|
|
256
|
+
* Non-interactive section label for grouping menu items.
|
|
257
|
+
*/
|
|
258
|
+
function DropdownHeaderComponent({ className, children }) {
|
|
259
|
+
return (_jsx("div", { role: "presentation", className: cx('tui-dropdown__header', className), children: children }));
|
|
260
|
+
}
|
|
261
|
+
DropdownHeaderComponent.displayName = 'Dropdown.Header';
|
|
210
262
|
export const Dropdown = DropdownRoot;
|
|
211
263
|
Dropdown.Trigger = DropdownTriggerComponent;
|
|
212
264
|
Dropdown.Content = DropdownContentComponent;
|
|
213
265
|
Dropdown.Item = DropdownItemComponent;
|
|
266
|
+
Dropdown.Separator = DropdownSeparatorComponent;
|
|
267
|
+
Dropdown.Header = DropdownHeaderComponent;
|
|
214
268
|
// Named exports for direct imports
|
|
215
269
|
export const DropdownTrigger = DropdownTriggerComponent;
|
|
216
270
|
export const DropdownContent = DropdownContentComponent;
|
|
217
271
|
export const DropdownItem = DropdownItemComponent;
|
|
272
|
+
export const DropdownSeparator = DropdownSeparatorComponent;
|
|
273
|
+
export const DropdownHeader = DropdownHeaderComponent;
|
|
218
274
|
// Hook for advanced use cases (custom items that need to close the dropdown)
|
|
219
275
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
220
276
|
export { useDropdownContext as useDropdown } from './DropdownContext.js';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { Dropdown, DropdownTrigger, DropdownContent, DropdownItem, useDropdown, } from './Dropdown';
|
|
2
|
-
export type { DropdownProps, DropdownTriggerProps, DropdownContentProps, DropdownItemProps, } from './types';
|
|
1
|
+
export { Dropdown, DropdownTrigger, DropdownContent, DropdownItem, DropdownSeparator, DropdownHeader, useDropdown, } from './Dropdown';
|
|
2
|
+
export type { DropdownProps, DropdownTriggerProps, DropdownContentProps, DropdownItemProps, DropdownSeparatorProps, DropdownHeaderProps, } from './types';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { Dropdown, DropdownTrigger, DropdownContent, DropdownItem, useDropdown, } from './Dropdown.js';
|
|
1
|
+
export { Dropdown, DropdownTrigger, DropdownContent, DropdownItem, DropdownSeparator, DropdownHeader, useDropdown, } from './Dropdown.js';
|
|
@@ -88,6 +88,19 @@ export type DropdownItemProps = {
|
|
|
88
88
|
className?: string;
|
|
89
89
|
children: React.ReactNode;
|
|
90
90
|
};
|
|
91
|
+
export type DropdownSeparatorProps = {
|
|
92
|
+
/**
|
|
93
|
+
* Additional CSS class names.
|
|
94
|
+
*/
|
|
95
|
+
className?: string;
|
|
96
|
+
};
|
|
97
|
+
export type DropdownHeaderProps = {
|
|
98
|
+
/**
|
|
99
|
+
* Additional CSS class names.
|
|
100
|
+
*/
|
|
101
|
+
className?: string;
|
|
102
|
+
children: React.ReactNode;
|
|
103
|
+
};
|
|
91
104
|
export type DropdownContextValue = {
|
|
92
105
|
open: boolean;
|
|
93
106
|
setOpen: (open: boolean) => void;
|
|
@@ -95,6 +108,8 @@ export type DropdownContextValue = {
|
|
|
95
108
|
contentId: string;
|
|
96
109
|
activeIndex: number | null;
|
|
97
110
|
setActiveIndex: (index: number | null) => void;
|
|
111
|
+
/** Tracks how the dropdown was opened (e.g. 'ArrowUp') for initial focus. */
|
|
112
|
+
openedVia: React.MutableRefObject<string | null>;
|
|
98
113
|
};
|
|
99
114
|
/**
|
|
100
115
|
* Convert side + align to Floating UI placement.
|
|
@@ -17,10 +17,11 @@ import { getSafeRel } from '../../utils/polymorphic.js';
|
|
|
17
17
|
//
|
|
18
18
|
// =============================================================================
|
|
19
19
|
export const IconButton = forwardRef((props, ref) => {
|
|
20
|
-
const { icon, label, size = 'sm', theme = 'secondary', variant = 'ghost', disabled = false, loading = false, pressed, showTooltip = false, tooltipSide = 'top', className, ...rest } = props;
|
|
20
|
+
const { icon, label, size = 'sm', theme = 'secondary', variant = 'ghost', disabled = false, loading = false, loadingLabel: loadingLabelProp, pressed, shape = 'square', showTooltip = false, tooltipSide = 'top', className, ...rest } = props;
|
|
21
21
|
const isLink = typeof rest.href === 'string';
|
|
22
22
|
const isDisabled = disabled || loading;
|
|
23
|
-
const
|
|
23
|
+
const resolvedLabel = loading ? (loadingLabelProp ?? `${label}, loading`) : label;
|
|
24
|
+
const classes = cx('tui-icon-button', `is-size-${size}`, `is-theme-${theme}`, `is-style-${variant}`, isDisabled && 'is-disabled', shape === 'circle' && 'is-shape-circle', className);
|
|
24
25
|
const iconContent = (_jsxs(_Fragment, { children: [_jsx(Icon, { name: icon }), loading && _jsx("span", { className: "tui-icon-button__spinner", "aria-hidden": "true" })] }));
|
|
25
26
|
// Render the base element (button or anchor)
|
|
26
27
|
let element;
|
|
@@ -35,11 +36,11 @@ export const IconButton = forwardRef((props, ref) => {
|
|
|
35
36
|
}
|
|
36
37
|
onClick?.(e);
|
|
37
38
|
};
|
|
38
|
-
element = (_jsx("a", { ref: ref, href: isDisabled ? undefined : href, className: classes, "aria-label":
|
|
39
|
+
element = (_jsx("a", { ref: ref, href: isDisabled ? undefined : href, className: classes, "aria-label": resolvedLabel, "aria-disabled": isDisabled || undefined, "aria-busy": loading || undefined, tabIndex: isDisabled ? -1 : tabIndex, onClick: handleClick, target: target, rel: safeRel, ...anchorRest, children: iconContent }));
|
|
39
40
|
}
|
|
40
41
|
else {
|
|
41
42
|
const buttonRest = rest;
|
|
42
|
-
element = (_jsx("button", { ref: ref, type: buttonRest.type ?? 'button', className: classes, "aria-label":
|
|
43
|
+
element = (_jsx("button", { ref: ref, type: buttonRest.type ?? 'button', className: classes, "aria-label": resolvedLabel, disabled: isDisabled, "aria-busy": loading || undefined, "aria-pressed": pressed, ...buttonRest, children: iconContent }));
|
|
43
44
|
}
|
|
44
45
|
// Wrap in tooltip if requested
|
|
45
46
|
if (showTooltip) {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { IconButton } from './IconButton';
|
|
2
|
-
export type { IconButtonProps, Size, Theme, Variant } from './types';
|
|
2
|
+
export type { IconButtonProps, Shape, Size, Theme, Variant } from './types';
|
|
@@ -3,6 +3,7 @@ import type { Size, ThemeIntent } from '../../types';
|
|
|
3
3
|
export type { Size };
|
|
4
4
|
export type Theme = ThemeIntent;
|
|
5
5
|
export type Variant = 'solid' | 'outline' | 'ghost';
|
|
6
|
+
export type Shape = 'square' | 'circle';
|
|
6
7
|
type IconButtonBaseProps = {
|
|
7
8
|
/**
|
|
8
9
|
* Icon to display. Required — this is the button's visual content.
|
|
@@ -17,7 +18,10 @@ type IconButtonBaseProps = {
|
|
|
17
18
|
label: string;
|
|
18
19
|
/**
|
|
19
20
|
* Size of the button.
|
|
20
|
-
* - `'xs'`: 24px
|
|
21
|
+
* - `'xs'`: 24px — below WCAG 2.5.8 minimum target size (24×24 CSS px).
|
|
22
|
+
* Use only in dense, mouse-first UIs (toolbars, table rows) where touch
|
|
23
|
+
* is not expected. Consider pairing with adequate spacing or a larger
|
|
24
|
+
* touch target via padding/margin.
|
|
21
25
|
* - `'sm'`: 32px (default)
|
|
22
26
|
* - `'md'`: 40px
|
|
23
27
|
* - `'lg'`: 48px
|
|
@@ -35,7 +39,9 @@ type IconButtonBaseProps = {
|
|
|
35
39
|
* Visual style variant.
|
|
36
40
|
* - `'solid'`: Filled background
|
|
37
41
|
* - `'outline'`: Border only, transparent background
|
|
38
|
-
* - `'ghost'`: No border or background, icon only
|
|
42
|
+
* - `'ghost'`: No border or background, icon only — has no at-rest visual
|
|
43
|
+
* affordance. Best suited for toolbar groups, icon bars, or other contexts
|
|
44
|
+
* where surrounding UI makes the interactive nature obvious.
|
|
39
45
|
* @default 'ghost'
|
|
40
46
|
*/
|
|
41
47
|
variant?: Variant;
|
|
@@ -47,6 +53,11 @@ type IconButtonBaseProps = {
|
|
|
47
53
|
* Loading state — shows spinner, disables interaction.
|
|
48
54
|
*/
|
|
49
55
|
loading?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Accessible label override during loading state. For i18n support.
|
|
58
|
+
* @default `${label}, loading` (English)
|
|
59
|
+
*/
|
|
60
|
+
loadingLabel?: string;
|
|
50
61
|
/**
|
|
51
62
|
* Pressed state for toggle buttons.
|
|
52
63
|
* When true, applies aria-pressed="true" and active styling.
|
|
@@ -55,8 +66,11 @@ type IconButtonBaseProps = {
|
|
|
55
66
|
pressed?: boolean;
|
|
56
67
|
/**
|
|
57
68
|
* Show the label as a tooltip on hover.
|
|
58
|
-
* Since icon-only buttons have no visible text, this helps
|
|
59
|
-
* users discover what the button does.
|
|
69
|
+
* Since icon-only buttons have no visible text, enabling this helps
|
|
70
|
+
* sighted users discover what the button does. Recommended for most
|
|
71
|
+
* standalone icon buttons. Can be omitted in toolbars where a shared
|
|
72
|
+
* Tooltip provider handles it, or where adjacent visible text already
|
|
73
|
+
* communicates the action.
|
|
60
74
|
* @default false
|
|
61
75
|
*/
|
|
62
76
|
showTooltip?: boolean;
|
|
@@ -65,6 +79,12 @@ type IconButtonBaseProps = {
|
|
|
65
79
|
* @default 'top'
|
|
66
80
|
*/
|
|
67
81
|
tooltipSide?: 'top' | 'right' | 'bottom' | 'left';
|
|
82
|
+
/**
|
|
83
|
+
* Button shape. Use `'circle'` for avatar-adjacent actions, FABs,
|
|
84
|
+
* or anywhere a round affordance is expected.
|
|
85
|
+
* @default 'square'
|
|
86
|
+
*/
|
|
87
|
+
shape?: Shape;
|
|
68
88
|
/**
|
|
69
89
|
* Additional CSS class names.
|
|
70
90
|
*/
|
|
@@ -7,6 +7,8 @@ export type ModalProps = {
|
|
|
7
7
|
size?: Size;
|
|
8
8
|
stickyHead?: boolean;
|
|
9
9
|
stickyFoot?: boolean;
|
|
10
|
+
/** ID of the element that labels this dialog. Omitting this leaves the dialog
|
|
11
|
+
* without an accessible name, which violates WCAG 4.1.2 (Name, Role, Value). */
|
|
10
12
|
'aria-labelledby'?: string;
|
|
11
13
|
'aria-describedby'?: string;
|
|
12
14
|
initialFocusSelector?: string;
|
|
@@ -30,11 +32,23 @@ type ModalSlotProps = {
|
|
|
30
32
|
className?: string;
|
|
31
33
|
children?: React.ReactNode;
|
|
32
34
|
} & React.HTMLAttributes<HTMLDivElement>;
|
|
33
|
-
|
|
35
|
+
type ModalHeadProps = ModalSlotProps & {
|
|
36
|
+
/** Optional subtitle displayed below the title in smaller, muted text. */
|
|
37
|
+
subtitle?: React.ReactNode;
|
|
38
|
+
/** Optional icon displayed to the left of the title/subtitle block. */
|
|
39
|
+
icon?: React.ReactNode;
|
|
40
|
+
};
|
|
41
|
+
declare function ModalHead({ className, children, subtitle, icon, ...rest }: ModalHeadProps): import("react/jsx-runtime").JSX.Element;
|
|
34
42
|
declare namespace ModalHead {
|
|
35
43
|
var displayName: string;
|
|
36
44
|
}
|
|
37
|
-
|
|
45
|
+
type ModalBodyProps = ModalSlotProps & {
|
|
46
|
+
/** Accessible label for the scrollable region. Passed to the inner scroll container.
|
|
47
|
+
* When the body content overflows, screen readers announce this as the region name.
|
|
48
|
+
* Omitting this on a scrollable modal body leaves the scroll region unlabelled. */
|
|
49
|
+
scrollLabel?: string;
|
|
50
|
+
};
|
|
51
|
+
declare function ModalBody({ className, children, scrollLabel, ...rest }: ModalBodyProps): import("react/jsx-runtime").JSX.Element;
|
|
38
52
|
declare namespace ModalBody {
|
|
39
53
|
var displayName: string;
|
|
40
54
|
}
|
|
@@ -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;
|