@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
|
@@ -5,6 +5,15 @@ type Size = SizeCompact;
|
|
|
5
5
|
type Theme = ThemeFull;
|
|
6
6
|
type Variant = 'default' | 'outline' | 'ghost' | 'solid' | 'flush';
|
|
7
7
|
export type ChipProps = {
|
|
8
|
+
/**
|
|
9
|
+
* HTML element to render.
|
|
10
|
+
* - `'span'` (default): Inline element. Use for chips within text flow,
|
|
11
|
+
* inline lists, or inside `<p>`/`<li>` elements.
|
|
12
|
+
* - `'div'`: Block element. Use when the chip contains block-level content
|
|
13
|
+
* or needs to be a flex/grid child without inline constraints.
|
|
14
|
+
* - `'a'`: Anchor element. Use for navigational chips with an `href`.
|
|
15
|
+
* @default 'span'
|
|
16
|
+
*/
|
|
8
17
|
as?: 'span' | 'div' | 'a';
|
|
9
18
|
href?: string;
|
|
10
19
|
target?: React.HTMLAttributeAnchorTarget;
|
|
@@ -23,5 +32,32 @@ export type ChipProps = {
|
|
|
23
32
|
/** When inside ChipGroup, identifies this chip for selection tracking */
|
|
24
33
|
value?: OptionValue;
|
|
25
34
|
} & Omit<React.HTMLAttributes<HTMLSpanElement>, 'onClick'>;
|
|
26
|
-
export declare
|
|
35
|
+
export declare const Chip: React.ForwardRefExoticComponent<{
|
|
36
|
+
/**
|
|
37
|
+
* HTML element to render.
|
|
38
|
+
* - `'span'` (default): Inline element. Use for chips within text flow,
|
|
39
|
+
* inline lists, or inside `<p>`/`<li>` elements.
|
|
40
|
+
* - `'div'`: Block element. Use when the chip contains block-level content
|
|
41
|
+
* or needs to be a flex/grid child without inline constraints.
|
|
42
|
+
* - `'a'`: Anchor element. Use for navigational chips with an `href`.
|
|
43
|
+
* @default 'span'
|
|
44
|
+
*/
|
|
45
|
+
as?: "span" | "div" | "a";
|
|
46
|
+
href?: string;
|
|
47
|
+
target?: React.HTMLAttributeAnchorTarget;
|
|
48
|
+
rel?: string;
|
|
49
|
+
children: React.ReactNode;
|
|
50
|
+
size?: Size;
|
|
51
|
+
theme?: Theme;
|
|
52
|
+
variant?: Variant;
|
|
53
|
+
selected?: boolean;
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
interactive?: boolean;
|
|
56
|
+
className?: string;
|
|
57
|
+
leftIcon?: React.ReactNode;
|
|
58
|
+
rightIcon?: React.ReactNode;
|
|
59
|
+
onClick?: React.MouseEventHandler<HTMLElement>;
|
|
60
|
+
/** When inside ChipGroup, identifies this chip for selection tracking */
|
|
61
|
+
value?: OptionValue;
|
|
62
|
+
} & Omit<React.HTMLAttributes<HTMLSpanElement>, "onClick"> & React.RefAttributes<HTMLElement>>;
|
|
27
63
|
export {};
|
package/components/Chip/Chip.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import * as React from 'react';
|
|
3
|
-
import { useCallback, useEffect } from 'react';
|
|
3
|
+
import { useCallback, useEffect, forwardRef } from 'react';
|
|
4
4
|
import { cx } from '../../utils/cx.js';
|
|
5
5
|
import { toKey } from '../../utils/value-key.js';
|
|
6
6
|
import { isDev } from '../../utils/is-dev.js';
|
|
7
7
|
import { useChipGroupContext } from '../ChipGroup/ChipGroupContext.js';
|
|
8
|
-
export function Chip({ as = 'span', href, target, rel, children, size = 'md', theme = 'secondary', variant = 'default', selected: selectedProp, disabled: disabledProp, interactive, className, leftIcon, rightIcon, onClick: onClickProp, value, ...rest }) {
|
|
8
|
+
export const Chip = forwardRef(function Chip({ as = 'span', href, target, rel, children, size = 'md', theme = 'secondary', variant = 'default', selected: selectedProp, disabled: disabledProp, interactive, className, leftIcon, rightIcon, onClick: onClickProp, value, ...rest }, ref) {
|
|
9
9
|
const groupContext = useChipGroupContext();
|
|
10
10
|
// Dev warning: inside ChipGroup without value
|
|
11
11
|
useEffect(() => {
|
|
@@ -25,7 +25,7 @@ export function Chip({ as = 'span', href, target, rel, children, size = 'md', th
|
|
|
25
25
|
const onClick = isManaged ? managedClick : onClickProp;
|
|
26
26
|
const Tag = (as === 'a' ? 'a' : as);
|
|
27
27
|
// Determine if this chip is clickable (needs button semantics)
|
|
28
|
-
const isClickable = (
|
|
28
|
+
const isClickable = (!!onClick || isManaged) && !disabled;
|
|
29
29
|
// Keyboard handler for interactive chips
|
|
30
30
|
const handleKeyDown = useCallback((e) => {
|
|
31
31
|
if (!isClickable || !onClick)
|
|
@@ -38,7 +38,7 @@ export function Chip({ as = 'span', href, target, rel, children, size = 'md', th
|
|
|
38
38
|
const classes = cx('tui-chip', size && `is-size-${size}`, theme && `is-theme-${theme}`, variant !== 'default' && `is-style-${variant}`, selected && 'is-selected', (interactive || isManaged) && 'is-interactive', className);
|
|
39
39
|
const anchorProps = as === 'a'
|
|
40
40
|
? {
|
|
41
|
-
href: disabled ? undefined : href ?? '#',
|
|
41
|
+
href: disabled || isManaged ? undefined : href ?? '#',
|
|
42
42
|
target,
|
|
43
43
|
rel,
|
|
44
44
|
'aria-disabled': disabled || undefined,
|
|
@@ -49,14 +49,16 @@ export function Chip({ as = 'span', href, target, rel, children, size = 'md', th
|
|
|
49
49
|
// Non-anchor clickable chips always need role="button".
|
|
50
50
|
// Managed anchor chips also get role="button" — toggle semantics
|
|
51
51
|
// take priority over link semantics inside a ChipGroup.
|
|
52
|
-
|
|
52
|
+
// Disabled managed chips keep role="button" so they remain visible to AT.
|
|
53
|
+
const needsButtonRole = (isClickable || (isManaged && disabled)) && (as !== 'a' || isManaged);
|
|
53
54
|
const buttonProps = needsButtonRole
|
|
54
55
|
? {
|
|
55
56
|
role: 'button',
|
|
56
|
-
tabIndex: as !== 'a' ? 0 : undefined,
|
|
57
|
+
tabIndex: disabled ? -1 : (as !== 'a' ? 0 : undefined),
|
|
57
58
|
onKeyDown: handleKeyDown,
|
|
58
59
|
'aria-pressed': isManaged ? (selected ?? false) : undefined,
|
|
60
|
+
'aria-disabled': disabled || undefined,
|
|
59
61
|
}
|
|
60
62
|
: {};
|
|
61
|
-
return (_jsxs(Tag, { className: classes, ...anchorProps, ...buttonProps, onClick: disabled ? undefined : onClick, ...rest, children: [leftIcon && leftIcon, _jsx("span", { className: "tui-chip__text", children: children }), rightIcon && rightIcon] }));
|
|
62
|
-
}
|
|
63
|
+
return (_jsxs(Tag, { ref: ref, className: classes, ...anchorProps, ...buttonProps, onClick: disabled ? undefined : onClick, ...rest, children: [leftIcon && leftIcon, _jsx("span", { className: "tui-chip__text", children: children }), rightIcon && rightIcon] }));
|
|
64
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useMemo } from 'react';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useId, useMemo } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { useControllableState } from '../../utils/use-controllable-state.js';
|
|
5
5
|
import { toKey } from '../../utils/value-key.js';
|
|
@@ -17,7 +17,7 @@ import { isDev } from '../../utils/is-dev.js';
|
|
|
17
17
|
//
|
|
18
18
|
// =============================================================================
|
|
19
19
|
export function ChipGroup(props) {
|
|
20
|
-
const { multiple = false, disabled = false, density = 'sm', direction = 'inline', alignment, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, } = props;
|
|
20
|
+
const { multiple = false, disabled = false, density = 'sm', direction = 'inline', alignment, multipleLabel = 'Multiple selections allowed', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, } = props;
|
|
21
21
|
// --- Single mode ---
|
|
22
22
|
// isControlled override: 'value' in props detects explicit value={undefined}
|
|
23
23
|
// (deselection) vs prop not passed at all (uncontrolled).
|
|
@@ -57,12 +57,13 @@ export function ChipGroup(props) {
|
|
|
57
57
|
}
|
|
58
58
|
}, [multiple, setSingleValue, setMultiValue]);
|
|
59
59
|
const contextValue = useMemo(() => ({ selectedValues, multiple, disabled, onSelect }), [selectedValues, multiple, disabled, onSelect]);
|
|
60
|
+
const descriptionId = useId();
|
|
60
61
|
// Dev-only: warn if group has no accessible name
|
|
61
62
|
useEffect(() => {
|
|
62
63
|
if (isDev() && !ariaLabel && !ariaLabelledBy) {
|
|
63
64
|
console.warn('ChipGroup: Missing accessible name. Provide aria-label or aria-labelledby.');
|
|
64
65
|
}
|
|
65
66
|
}, [ariaLabel, ariaLabelledBy]);
|
|
66
|
-
return (_jsx(ChipGroupContext.Provider, { value: contextValue, children:
|
|
67
|
+
return (_jsx(ChipGroupContext.Provider, { value: contextValue, children: _jsxs("div", { role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-disabled": disabled || undefined, "aria-describedby": multiple ? descriptionId : undefined, className: cx('tui-chip-group', `is-density-${density}`, direction === 'stack' && 'is-direction-stack', alignment && `is-align-${alignment}`, className), children: [children, multiple && (_jsx("span", { id: descriptionId, className: "tui-visually-hidden", children: multipleLabel }))] }) }));
|
|
67
68
|
}
|
|
68
69
|
ChipGroup.displayName = 'ChipGroup';
|
|
@@ -8,6 +8,9 @@ type ChipGroupBaseProps = {
|
|
|
8
8
|
direction?: 'inline' | 'stack';
|
|
9
9
|
/** Alignment along main axis. */
|
|
10
10
|
alignment?: 'start' | 'center' | 'end';
|
|
11
|
+
/** Visually hidden label describing multi-select behaviour. For i18n support.
|
|
12
|
+
* @default 'Multiple selections allowed' */
|
|
13
|
+
multipleLabel?: string;
|
|
11
14
|
'aria-label'?: string;
|
|
12
15
|
'aria-labelledby'?: string;
|
|
13
16
|
className?: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DropdownProps, DropdownTriggerProps, DropdownContentProps, DropdownItemProps } from './types';
|
|
1
|
+
import type { DropdownProps, DropdownTriggerProps, DropdownContentProps, DropdownItemProps, DropdownSeparatorProps, DropdownHeaderProps } from './types';
|
|
2
2
|
declare function DropdownRoot({ open: controlledOpen, onOpenChange, defaultOpen, children, }: DropdownProps): import("react/jsx-runtime").JSX.Element;
|
|
3
3
|
declare namespace DropdownRoot {
|
|
4
4
|
var displayName: string;
|
|
@@ -19,13 +19,31 @@ declare function DropdownItemComponent({ onSelect, href, target, disabled, keepO
|
|
|
19
19
|
declare namespace DropdownItemComponent {
|
|
20
20
|
var displayName: string;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Non-interactive separator for visually grouping menu items.
|
|
24
|
+
*/
|
|
25
|
+
declare function DropdownSeparatorComponent({ className }: DropdownSeparatorProps): import("react/jsx-runtime").JSX.Element;
|
|
26
|
+
declare namespace DropdownSeparatorComponent {
|
|
27
|
+
var displayName: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Non-interactive section label for grouping menu items.
|
|
31
|
+
*/
|
|
32
|
+
declare function DropdownHeaderComponent({ className, children }: DropdownHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
33
|
+
declare namespace DropdownHeaderComponent {
|
|
34
|
+
var displayName: string;
|
|
35
|
+
}
|
|
22
36
|
type DropdownCompound = typeof DropdownRoot & {
|
|
23
37
|
Trigger: typeof DropdownTriggerComponent;
|
|
24
38
|
Content: typeof DropdownContentComponent;
|
|
25
39
|
Item: typeof DropdownItemComponent;
|
|
40
|
+
Separator: typeof DropdownSeparatorComponent;
|
|
41
|
+
Header: typeof DropdownHeaderComponent;
|
|
26
42
|
};
|
|
27
43
|
export declare const Dropdown: DropdownCompound;
|
|
28
44
|
export declare const DropdownTrigger: typeof DropdownTriggerComponent;
|
|
29
45
|
export declare const DropdownContent: typeof DropdownContentComponent;
|
|
30
46
|
export declare const DropdownItem: typeof DropdownItemComponent;
|
|
47
|
+
export declare const DropdownSeparator: typeof DropdownSeparatorComponent;
|
|
48
|
+
export declare const DropdownHeader: typeof DropdownHeaderComponent;
|
|
31
49
|
export { useDropdownContext as useDropdown } from './DropdownContext';
|
|
@@ -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
|
}
|