@tangible/ui 0.0.8 → 0.0.9
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/components/Avatar/Avatar.d.ts +1 -1
- package/components/Avatar/Avatar.js +2 -2
- package/components/Combobox/Combobox.js +23 -1
- package/components/Dropdown/Dropdown.d.ts +1 -1
- package/components/Dropdown/Dropdown.js +16 -8
- package/components/Icon/Icon.d.ts +1 -1
- package/components/Icon/Icon.js +2 -2
- package/components/Modal/Modal.d.ts +5 -1
- package/components/Modal/Modal.js +2 -2
- package/components/MoveHandle/MoveHandle.d.ts +1 -1
- package/components/MoveHandle/MoveHandle.js +3 -3
- package/components/MultiSelect/MultiSelect.js +22 -1
- package/components/Progress/Progress.d.ts +2 -1
- package/components/Progress/Progress.js +3 -3
- package/components/Select/Select.js +23 -1
- package/components/StepIndicator/StepIndicator.d.ts +1 -1
- package/components/StepIndicator/StepIndicator.js +3 -2
- package/components/Tooltip/Tooltip.d.ts +1 -1
- package/components/Tooltip/Tooltip.js +16 -10
- package/package.json +1 -1
- package/tui-manifest.json +7 -10
|
@@ -8,4 +8,4 @@ import type { AvatarProps } from './types';
|
|
|
8
8
|
* - Shows placeholder icon if neither `src` nor `name` provided
|
|
9
9
|
* - Colors for initials are derived from the name hash for consistency
|
|
10
10
|
*/
|
|
11
|
-
export declare const Avatar: React.
|
|
11
|
+
export declare const Avatar: React.NamedExoticComponent<AvatarProps & React.RefAttributes<HTMLSpanElement>>;
|
|
@@ -46,7 +46,7 @@ function getColorFromName(name, colors) {
|
|
|
46
46
|
* - Shows placeholder icon if neither `src` nor `name` provided
|
|
47
47
|
* - Colors for initials are derived from the name hash for consistency
|
|
48
48
|
*/
|
|
49
|
-
export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circle', color, indicator, indicatorLabel, indicatorPosition = 'bottom-right', tooltip, labels: labelsProp, className, }, ref) => {
|
|
49
|
+
export const Avatar = React.memo(React.forwardRef(({ src, name, size = 'md', shape = 'circle', color, indicator, indicatorLabel, indicatorPosition = 'bottom-right', tooltip, labels: labelsProp, className, }, ref) => {
|
|
50
50
|
const [imgError, setImgError] = useState(false);
|
|
51
51
|
// Reset error state when src changes
|
|
52
52
|
React.useEffect(() => {
|
|
@@ -73,5 +73,5 @@ export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circl
|
|
|
73
73
|
return (_jsxs(Tooltip, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: avatarElement }), _jsx(Tooltip.Content, { "aria-hidden": "true", children: name })] }));
|
|
74
74
|
}
|
|
75
75
|
return avatarElement;
|
|
76
|
-
});
|
|
76
|
+
}));
|
|
77
77
|
Avatar.displayName = 'Avatar';
|
|
@@ -43,6 +43,9 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
|
|
|
43
43
|
// Option registration
|
|
44
44
|
const optionsRef = useRef(new Map());
|
|
45
45
|
const [registryVersion, setRegistryVersion] = useState(0);
|
|
46
|
+
// Track open state via ref so unregisterOption can check synchronously
|
|
47
|
+
const openRef = useRef(false);
|
|
48
|
+
openRef.current = open;
|
|
46
49
|
// IDs
|
|
47
50
|
const baseId = useId();
|
|
48
51
|
const inputId = `${baseId}-input`;
|
|
@@ -155,9 +158,22 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
|
|
|
155
158
|
setRegistryVersion((v) => v + 1);
|
|
156
159
|
}, []);
|
|
157
160
|
const unregisterOption = useCallback((optionValue) => {
|
|
161
|
+
// When the dropdown closes, options unmount and call unregister in cleanup.
|
|
162
|
+
// Skip the delete to preserve the registry — filtering and input display
|
|
163
|
+
// depend on it while closed. Options re-register on next open (same keys).
|
|
164
|
+
if (!openRef.current)
|
|
165
|
+
return;
|
|
158
166
|
optionsRef.current.delete(toKey(optionValue));
|
|
159
167
|
setRegistryVersion((v) => v + 1);
|
|
160
168
|
}, []);
|
|
169
|
+
// Flush stale registry on open. Options that were registered before close
|
|
170
|
+
// may no longer exist (parent changed children while closed). Clearing
|
|
171
|
+
// before the new options mount ensures no orphaned entries accumulate.
|
|
172
|
+
useLayoutEffect(() => {
|
|
173
|
+
if (open) {
|
|
174
|
+
optionsRef.current.clear();
|
|
175
|
+
}
|
|
176
|
+
}, [open]);
|
|
161
177
|
// Active option ID for aria-activedescendant (hash-based for stability during filtering)
|
|
162
178
|
const activeOptionId = activeIndex >= 0 && orderedOptions[activeIndex]
|
|
163
179
|
? `${listboxId}-opt-${hashForId(toKey(orderedOptions[activeIndex].value))}`
|
|
@@ -376,6 +392,12 @@ ComboboxRoot.displayName = 'Combobox';
|
|
|
376
392
|
// =============================================================================
|
|
377
393
|
function ComboboxContentComponent({ className, children }) {
|
|
378
394
|
const { open, listboxId, inputId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, orderedOptions, } = useComboboxContext();
|
|
395
|
+
// Track whether dropdown has ever been opened. Before first open, mount
|
|
396
|
+
// children in a hidden div for option registration (defaultValue resolution).
|
|
397
|
+
// After first open, only mount children when open (in portal).
|
|
398
|
+
const hasEverOpened = useRef(false);
|
|
399
|
+
if (open)
|
|
400
|
+
hasEverOpened.current = true;
|
|
379
401
|
const portalRoot = getPortalRootFor(refs.reference.current);
|
|
380
402
|
const contentContext = useMemo(() => ({
|
|
381
403
|
listRef,
|
|
@@ -383,7 +405,7 @@ function ComboboxContentComponent({ className, children }) {
|
|
|
383
405
|
orderedOptions,
|
|
384
406
|
}), [listRef, activeIndex, orderedOptions]);
|
|
385
407
|
// Always render for option registration
|
|
386
|
-
return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { id: listboxId, role: "listbox", style: { display: 'none' }, "aria-hidden": "true", children: _jsx(ComboboxContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": inputId, className: cx('tui-combobox__content', className), style: {
|
|
408
|
+
return (_jsxs(_Fragment, { children: [!open && !hasEverOpened.current && (_jsx("div", { id: listboxId, role: "listbox", style: { display: 'none' }, "aria-hidden": "true", children: _jsx(ComboboxContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": inputId, className: cx('tui-combobox__content', className), style: {
|
|
387
409
|
...floatingStyles,
|
|
388
410
|
minWidth: refs.reference.current?.offsetWidth,
|
|
389
411
|
pointerEvents: 'auto',
|
|
@@ -7,7 +7,7 @@ declare function DropdownTriggerComponent({ asChild, children }: DropdownTrigger
|
|
|
7
7
|
declare namespace DropdownTriggerComponent {
|
|
8
8
|
var displayName: string;
|
|
9
9
|
}
|
|
10
|
-
declare function DropdownContentComponent(
|
|
10
|
+
declare function DropdownContentComponent(props: DropdownContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
11
11
|
declare namespace DropdownContentComponent {
|
|
12
12
|
var displayName: string;
|
|
13
13
|
}
|
|
@@ -112,12 +112,23 @@ DropdownTriggerComponent.displayName = 'Dropdown.Trigger';
|
|
|
112
112
|
// =============================================================================
|
|
113
113
|
// DropdownContent
|
|
114
114
|
// =============================================================================
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
// Gate component: reads context to decide whether to mount the real content.
|
|
116
|
+
// This ensures useFloating and all Floating UI hooks in DropdownContentInner
|
|
117
|
+
// only run when the dropdown is actually open — not on every render cycle.
|
|
118
|
+
function DropdownContentComponent(props) {
|
|
119
|
+
const { open } = useDropdownContext();
|
|
120
|
+
if (!open)
|
|
121
|
+
return null;
|
|
122
|
+
return _jsx(DropdownContentInner, { ...props });
|
|
123
|
+
}
|
|
124
|
+
DropdownContentComponent.displayName = 'Dropdown.Content';
|
|
125
|
+
// Inner component: only mounted when open. All Floating UI hooks live here.
|
|
126
|
+
function DropdownContentInner({ side = 'bottom', align = 'start', sideOffset = 4, className, style, children, }) {
|
|
127
|
+
const { setOpen, triggerRef, contentId, activeIndex, setActiveIndex, openedVia } = useDropdownContext();
|
|
117
128
|
const listRef = useRef([]);
|
|
118
129
|
const { refs, floatingStyles, context } = useFloating({
|
|
119
130
|
placement: toPlacement(side, align),
|
|
120
|
-
open,
|
|
131
|
+
open: true, // Always true when mounted (gate handles the conditional)
|
|
121
132
|
onOpenChange: setOpen,
|
|
122
133
|
middleware: [offset(sideOffset), flip(), shift({ padding: 8 })],
|
|
123
134
|
whileElementsMounted: autoUpdate,
|
|
@@ -156,7 +167,7 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
156
167
|
}, [children]);
|
|
157
168
|
// ArrowUp focus-last: set activeIndex to last valid item before paint
|
|
158
169
|
useLayoutEffect(() => {
|
|
159
|
-
if (
|
|
170
|
+
if (openedVia.current === 'ArrowUp') {
|
|
160
171
|
let lastValid = totalItemCount - 1;
|
|
161
172
|
while (lastValid >= 0 && disabledIndices.includes(lastValid)) {
|
|
162
173
|
lastValid--;
|
|
@@ -166,7 +177,7 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
166
177
|
}
|
|
167
178
|
openedVia.current = null;
|
|
168
179
|
}
|
|
169
|
-
}, [
|
|
180
|
+
}, [openedVia, totalItemCount, disabledIndices, setActiveIndex]);
|
|
170
181
|
const dismiss = useDismiss(context);
|
|
171
182
|
const role = useRole(context, { role: 'menu' });
|
|
172
183
|
const listNavigation = useListNavigation(context, {
|
|
@@ -184,8 +195,6 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
184
195
|
]);
|
|
185
196
|
// Get portal root inside .tui-interface
|
|
186
197
|
const portalRoot = getPortalRootFor(triggerRef.current);
|
|
187
|
-
if (!open)
|
|
188
|
-
return null;
|
|
189
198
|
// Clone children to inject item props and role.
|
|
190
199
|
// Non-navigable children (Separator, Header) are rendered as-is.
|
|
191
200
|
let itemIndex = 0;
|
|
@@ -229,7 +238,6 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
229
238
|
...style,
|
|
230
239
|
}, ...getFloatingProps(), children: items }) }) }));
|
|
231
240
|
}
|
|
232
|
-
DropdownContentComponent.displayName = 'Dropdown.Content';
|
|
233
241
|
// =============================================================================
|
|
234
242
|
// DropdownItem
|
|
235
243
|
// =============================================================================
|
|
@@ -19,4 +19,4 @@ export interface IconProps {
|
|
|
19
19
|
* - Decorative icons (no label): automatically hidden from screen readers
|
|
20
20
|
* - Informative icons: provide a `label` prop for screen reader announcement
|
|
21
21
|
*/
|
|
22
|
-
export declare const Icon: React.
|
|
22
|
+
export declare const Icon: React.NamedExoticComponent<IconProps & React.RefAttributes<HTMLSpanElement>>;
|
package/components/Icon/Icon.js
CHANGED
|
@@ -10,7 +10,7 @@ import { iconRegistry } from '../../icons/registry.js';
|
|
|
10
10
|
* - Decorative icons (no label): automatically hidden from screen readers
|
|
11
11
|
* - Informative icons: provide a `label` prop for screen reader announcement
|
|
12
12
|
*/
|
|
13
|
-
export const Icon = React.forwardRef(({ name, emoji, label, size, className }, ref) => {
|
|
13
|
+
export const Icon = React.memo(React.forwardRef(({ name, emoji, label, size, className }, ref) => {
|
|
14
14
|
const SvgIcon = name ? iconRegistry[name] : null;
|
|
15
15
|
// Dev warning for invalid icon name
|
|
16
16
|
if (isDev() && name && !SvgIcon) {
|
|
@@ -21,5 +21,5 @@ export const Icon = React.forwardRef(({ name, emoji, label, size, className }, r
|
|
|
21
21
|
return (_jsxs("span", { ref: ref, className: cx('tui-icon', size && `is-size-${size}`, className), ...(isDecorative
|
|
22
22
|
? { 'aria-hidden': true }
|
|
23
23
|
: { role: 'img', 'aria-label': label }), children: [SvgIcon && _jsx(SvgIcon, { "aria-hidden": "true", focusable: "false" }), !SvgIcon && emoji] }));
|
|
24
|
-
});
|
|
24
|
+
}));
|
|
25
25
|
Icon.displayName = 'Icon';
|
|
@@ -17,9 +17,13 @@ export type ModalProps = {
|
|
|
17
17
|
closeLabel?: string;
|
|
18
18
|
closeOnBackdropClick?: boolean;
|
|
19
19
|
closeOnEscape?: boolean;
|
|
20
|
+
/** When true, prevents the browser from scrolling to the trigger element
|
|
21
|
+
* when focus is restored on close. Useful when the trigger may be off-screen
|
|
22
|
+
* inside a scrollable container. Default: false. */
|
|
23
|
+
preventScrollOnRestore?: boolean;
|
|
20
24
|
children?: React.ReactNode;
|
|
21
25
|
};
|
|
22
|
-
declare function ModalRoot({ open, onClose, size, stickyHead, stickyFoot, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, initialFocusSelector, container, showCloseButton, closeLabel, closeOnBackdropClick, closeOnEscape, children, }: ModalProps): React.ReactPortal | null;
|
|
26
|
+
declare function ModalRoot({ open, onClose, size, stickyHead, stickyFoot, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, initialFocusSelector, container, showCloseButton, closeLabel, closeOnBackdropClick, closeOnEscape, preventScrollOnRestore, children, }: ModalProps): React.ReactPortal | null;
|
|
23
27
|
type ModalCloseProps = {
|
|
24
28
|
label?: string;
|
|
25
29
|
className?: string;
|
|
@@ -8,7 +8,7 @@ import { useFocusTrap, getInitialFocus } from '../../utils/focus-trap.js';
|
|
|
8
8
|
import { ModalContext, useModalContext } from './context.js';
|
|
9
9
|
import { IconButton } from '../IconButton/index.js';
|
|
10
10
|
const isBrowser = typeof document !== 'undefined';
|
|
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, }) {
|
|
11
|
+
function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, initialFocusSelector, container, showCloseButton, closeLabel = 'Close', closeOnBackdropClick = true, closeOnEscape = true, preventScrollOnRestore = false, children, }) {
|
|
12
12
|
const dialogRef = useRef(null);
|
|
13
13
|
const restoreRef = useRef(null);
|
|
14
14
|
const warnedRef = useRef(false);
|
|
@@ -45,7 +45,7 @@ function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-l
|
|
|
45
45
|
return;
|
|
46
46
|
const el = restoreRef.current;
|
|
47
47
|
if (el && typeof el.focus === 'function') {
|
|
48
|
-
el.focus();
|
|
48
|
+
el.focus({ preventScroll: preventScrollOnRestore });
|
|
49
49
|
}
|
|
50
50
|
restoreRef.current = null;
|
|
51
51
|
setMount(null);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { MoveHandleProps } from './types';
|
|
2
|
-
export declare const MoveHandle: import("react").
|
|
2
|
+
export declare const MoveHandle: import("react").NamedExoticComponent<MoveHandleProps & import("react").RefAttributes<HTMLElement>>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { forwardRef, useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import { forwardRef, memo, useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { isDev } from '../../utils/is-dev.js';
|
|
5
5
|
import { Icon } from '../Icon/index.js';
|
|
@@ -24,7 +24,7 @@ import { Icon } from '../Icon/index.js';
|
|
|
24
24
|
// --tui-move-handle-icon-size Override icon size
|
|
25
25
|
//
|
|
26
26
|
// =============================================================================
|
|
27
|
-
export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size = 'md', index, locked = false, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true, labels, dragHandleProps, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, }, ref) {
|
|
27
|
+
export const MoveHandle = memo(forwardRef(function MoveHandle({ mode = 'full', size = 'md', index, locked = false, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true, labels, dragHandleProps, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, }, ref) {
|
|
28
28
|
// All hooks must be called unconditionally (rules of hooks)
|
|
29
29
|
const innerRef = useRef(null);
|
|
30
30
|
const mergedRef = useCallback((node) => {
|
|
@@ -92,4 +92,4 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
|
|
|
92
92
|
? (labels?.locked ?? 'This item is locked and cannot be reordered')
|
|
93
93
|
: undefined;
|
|
94
94
|
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: showLockIcon ? (_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" }) }))] }));
|
|
95
|
-
});
|
|
95
|
+
}));
|
|
@@ -22,6 +22,8 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
|
|
|
22
22
|
// Option registration
|
|
23
23
|
const optionsRef = useRef(new Map());
|
|
24
24
|
const [registryVersion, setRegistryVersion] = useState(0);
|
|
25
|
+
// Track open state via ref so unregisterOption can check synchronously
|
|
26
|
+
const openRef = useRef(false);
|
|
25
27
|
// Is selected helper
|
|
26
28
|
const isSelected = useCallback((optionValue) => {
|
|
27
29
|
const key = toKey(optionValue);
|
|
@@ -106,6 +108,7 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
|
|
|
106
108
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false);
|
|
107
109
|
const isOpenControlled = controlledOpen !== undefined;
|
|
108
110
|
const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
|
|
111
|
+
openRef.current = open;
|
|
109
112
|
const setOpen = useCallback((nextOpen) => {
|
|
110
113
|
if (disabled)
|
|
111
114
|
return;
|
|
@@ -215,9 +218,21 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
|
|
|
215
218
|
setRegistryVersion((v) => v + 1);
|
|
216
219
|
}, []);
|
|
217
220
|
const unregisterOption = useCallback((optionValue) => {
|
|
221
|
+
// Skip when closing — preserve registry for chip display text.
|
|
222
|
+
// Options re-register on next open (same keys).
|
|
223
|
+
if (!openRef.current)
|
|
224
|
+
return;
|
|
218
225
|
optionsRef.current.delete(toKey(optionValue));
|
|
219
226
|
setRegistryVersion((v) => v + 1);
|
|
220
227
|
}, []);
|
|
228
|
+
// Flush stale registry on open. Options that were registered before close
|
|
229
|
+
// may no longer exist (parent changed children while closed). Clearing
|
|
230
|
+
// before the new options mount ensures no orphaned entries accumulate.
|
|
231
|
+
useLayoutEffect(() => {
|
|
232
|
+
if (open) {
|
|
233
|
+
optionsRef.current.clear();
|
|
234
|
+
}
|
|
235
|
+
}, [open]);
|
|
221
236
|
// Get selected options for trigger display
|
|
222
237
|
const getSelectedOptions = useCallback(() => {
|
|
223
238
|
return value
|
|
@@ -528,13 +543,19 @@ MultiSelectTriggerComponent.displayName = 'MultiSelect.Trigger';
|
|
|
528
543
|
function MultiSelectContentComponent({ className, children }) {
|
|
529
544
|
const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, orderedOptions, } = useMultiSelectContext();
|
|
530
545
|
const portalRoot = getPortalRootFor(refs.reference.current);
|
|
546
|
+
// Track whether dropdown has ever been opened. Before first open, mount
|
|
547
|
+
// children in a hidden div for option registration. After first open,
|
|
548
|
+
// only mount children when open (in portal).
|
|
549
|
+
const hasEverOpened = useRef(false);
|
|
550
|
+
if (open)
|
|
551
|
+
hasEverOpened.current = true;
|
|
531
552
|
// Memoized context for options
|
|
532
553
|
const contentContext = useMemo(() => ({
|
|
533
554
|
listRef,
|
|
534
555
|
activeIndex,
|
|
535
556
|
orderedOptions,
|
|
536
557
|
}), [listRef, activeIndex, orderedOptions]);
|
|
537
|
-
return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { id: listboxId, role: "listbox", style: { display: 'none' }, "aria-hidden": "true", children: _jsx(MultiSelectContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": triggerId, "aria-multiselectable": "true", className: cx('tui-multiselect__content', className), style: {
|
|
558
|
+
return (_jsxs(_Fragment, { children: [!open && !hasEverOpened.current && (_jsx("div", { id: listboxId, role: "listbox", style: { display: 'none' }, "aria-hidden": "true", children: _jsx(MultiSelectContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": triggerId, "aria-multiselectable": "true", className: cx('tui-multiselect__content', className), style: {
|
|
538
559
|
...floatingStyles,
|
|
539
560
|
minWidth: refs.reference.current?.offsetWidth,
|
|
540
561
|
pointerEvents: 'auto',
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import React from 'react';
|
|
2
|
+
import React, { memo } from 'react';
|
|
3
3
|
import { useProgressSegments } from './useProgressSegments.js';
|
|
4
4
|
import { cx } from '../../utils/cx.js';
|
|
5
5
|
import { isDev } from '../../utils/is-dev.js';
|
|
6
6
|
// =============================================================================
|
|
7
7
|
// COMPONENT
|
|
8
8
|
// =============================================================================
|
|
9
|
-
export function Progress(props) {
|
|
9
|
+
export const Progress = memo(function Progress(props) {
|
|
10
10
|
const { children, mode = 'line', size = 'md', max = 100, showLabels = true, 'aria-labelledby': labelledBy, 'aria-label': ariaLabel, defaultLabel, className, } = props;
|
|
11
11
|
// Determine mode
|
|
12
12
|
const isSegmented = 'segments' in props && Array.isArray(props.segments);
|
|
@@ -98,4 +98,4 @@ export function Progress(props) {
|
|
|
98
98
|
// Above/below/inline positions: render labels outside the track
|
|
99
99
|
const labelRow = (_jsxs("div", { className: "tui-progress__labels", children: [labelStart && _jsx("span", { className: "tui-progress__label is-start", children: labelStart }), labelEnd && _jsx("span", { className: "tui-progress__label is-end", children: labelEnd })] }));
|
|
100
100
|
return (_jsxs("div", { ...rootProps, children: [labelPosition === 'above' && labelRow, labelPosition === 'inline' ? (_jsxs("div", { className: "tui-progress__inline", children: [labelStart && _jsx("span", { className: "tui-progress__label is-start", children: labelStart }), trackContent, labelEnd && _jsx("span", { className: "tui-progress__label is-end", children: labelEnd })] })) : (trackContent), labelPosition === 'below' && labelRow, !labelledBy && !ariaLabel && defaultLabel && (_jsx("span", { className: "visually-hidden", children: defaultLabel }))] }));
|
|
101
|
-
}
|
|
101
|
+
});
|
|
@@ -22,6 +22,8 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
22
22
|
// Option registration
|
|
23
23
|
const optionsRef = useRef(new Map());
|
|
24
24
|
const [registryVersion, setRegistryVersion] = useState(0);
|
|
25
|
+
// Track open state via ref so unregisterOption can check synchronously
|
|
26
|
+
const openRef = useRef(false);
|
|
25
27
|
const setValue = useCallback((newValue, textValue) => {
|
|
26
28
|
if (!isValueControlled) {
|
|
27
29
|
setUncontrolledValue(newValue);
|
|
@@ -55,6 +57,7 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
55
57
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false);
|
|
56
58
|
const isOpenControlled = controlledOpen !== undefined;
|
|
57
59
|
const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
|
|
60
|
+
openRef.current = open;
|
|
58
61
|
// Guard flag: when Backspace/Delete clears a closed Select, Floating UI's
|
|
59
62
|
// composed handlers (useTypeahead/useListNavigation) also fire and call
|
|
60
63
|
// setOpen(true). This ref rejects that open request within the same microtask.
|
|
@@ -188,11 +191,24 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
188
191
|
setRegistryVersion((v) => v + 1);
|
|
189
192
|
}, []);
|
|
190
193
|
const unregisterOption = useCallback((optionValue) => {
|
|
194
|
+
// When the dropdown closes, options unmount and call unregister in cleanup.
|
|
195
|
+
// Skip the delete to preserve the registry — displayText and typeahead
|
|
196
|
+
// depend on it while closed. Options re-register on next open (same keys).
|
|
197
|
+
if (!openRef.current)
|
|
198
|
+
return;
|
|
191
199
|
optionsRef.current.delete(toKey(optionValue));
|
|
192
200
|
setRegistryVersion((v) => v + 1);
|
|
193
201
|
}, []);
|
|
194
202
|
// Highlighted value for keyboard navigation
|
|
195
203
|
const highlightedValue = activeIndex !== null ? orderedOptions[activeIndex]?.value ?? null : null;
|
|
204
|
+
// Flush stale registry on open. Options that were registered before close
|
|
205
|
+
// may no longer exist (parent changed children while closed). Clearing
|
|
206
|
+
// before the new options mount ensures no orphaned entries accumulate.
|
|
207
|
+
useLayoutEffect(() => {
|
|
208
|
+
if (open) {
|
|
209
|
+
optionsRef.current.clear();
|
|
210
|
+
}
|
|
211
|
+
}, [open]);
|
|
196
212
|
// Reset active index when closing
|
|
197
213
|
useEffect(() => {
|
|
198
214
|
if (!open) {
|
|
@@ -411,6 +427,12 @@ SelectTriggerComponent.displayName = 'Select.Trigger';
|
|
|
411
427
|
function SelectContentComponent({ className, children, }) {
|
|
412
428
|
const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, handleSelect, orderedOptions, optionIndexMap, } = useSelectContext();
|
|
413
429
|
const portalRoot = getPortalRootFor(refs.reference.current);
|
|
430
|
+
// Track whether dropdown has ever been opened. Before first open, mount
|
|
431
|
+
// children in a hidden div for option registration (defaultValue resolution).
|
|
432
|
+
// After first open, only mount children when open (in portal).
|
|
433
|
+
const hasEverOpened = useRef(false);
|
|
434
|
+
if (open)
|
|
435
|
+
hasEverOpened.current = true;
|
|
414
436
|
// Memoized context for options
|
|
415
437
|
const contentContext = useMemo(() => ({
|
|
416
438
|
listRef,
|
|
@@ -419,7 +441,7 @@ function SelectContentComponent({ className, children, }) {
|
|
|
419
441
|
orderedOptions,
|
|
420
442
|
optionIndexMap,
|
|
421
443
|
}), [listRef, activeIndex, handleSelect, orderedOptions, optionIndexMap]);
|
|
422
|
-
return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { id: listboxId, role: "listbox", style: { display: 'none' }, "aria-hidden": "true", children: _jsx(SelectContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": triggerId, className: cx('tui-select__content', className), style: {
|
|
444
|
+
return (_jsxs(_Fragment, { children: [!open && !hasEverOpened.current && (_jsx("div", { id: listboxId, role: "listbox", style: { display: 'none' }, "aria-hidden": "true", children: _jsx(SelectContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": triggerId, className: cx('tui-select__content', className), style: {
|
|
423
445
|
...floatingStyles,
|
|
424
446
|
minWidth: refs.reference.current?.offsetWidth,
|
|
425
447
|
pointerEvents: 'auto',
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { StepIndicatorProps } from './types';
|
|
2
|
-
export declare
|
|
2
|
+
export declare const StepIndicator: import("react").NamedExoticComponent<StepIndicatorProps>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { memo } from 'react';
|
|
2
3
|
import { defaultStepIndicatorLabels } from './types.js';
|
|
3
4
|
import { Progress } from '../Progress/index.js';
|
|
4
5
|
import { Icon } from '../Icon/index.js';
|
|
@@ -22,7 +23,7 @@ function inferStatus(value) {
|
|
|
22
23
|
// =============================================================================
|
|
23
24
|
// Component
|
|
24
25
|
// =============================================================================
|
|
25
|
-
export function StepIndicator(props) {
|
|
26
|
+
export const StepIndicator = memo(function StepIndicator(props) {
|
|
26
27
|
const { value = 0, status: statusOverride, icon: customIcon, size = 'sm', showValue = false, label, className, labels: labelsProp, } = props;
|
|
27
28
|
const labels = { ...defaultStepIndicatorLabels, ...labelsProp };
|
|
28
29
|
// Infer status from value, allow override
|
|
@@ -64,4 +65,4 @@ export function StepIndicator(props) {
|
|
|
64
65
|
.filter(Boolean)
|
|
65
66
|
.join(' ');
|
|
66
67
|
return (_jsx("div", { className: rootClassName, children: _jsxs(Progress, { mode: "circle", variant: variant, size: size, value: displayValue, showLabels: showValue || hasIcon, labelPosition: "inside", "aria-label": ariaLabel, children: [hasIcon && iconName && (_jsx(Icon, { name: iconName })), showValue && status === 'in-progress' && (_jsxs("span", { className: "tui-step-indicator__value", children: [Math.round(displayValue), "%"] }))] }) }));
|
|
67
|
-
}
|
|
68
|
+
});
|
|
@@ -2,7 +2,7 @@ import type { TooltipProviderProps, TooltipProps, TooltipTriggerProps, TooltipCo
|
|
|
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
4
|
declare function TooltipTriggerComponent({ asChild, 'aria-label': ariaLabel, children, }: TooltipTriggerProps): import("react/jsx-runtime").JSX.Element;
|
|
5
|
-
declare function TooltipContentComponent(
|
|
5
|
+
declare function TooltipContentComponent(props: TooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
6
6
|
type TooltipCompound = typeof TooltipRoot & {
|
|
7
7
|
Provider: typeof TooltipProviderComponent;
|
|
8
8
|
Trigger: typeof TooltipTriggerComponent;
|
|
@@ -120,12 +120,22 @@ function TooltipTriggerComponent({ asChild = false, 'aria-label': ariaLabel, chi
|
|
|
120
120
|
// =============================================================================
|
|
121
121
|
// TooltipContent
|
|
122
122
|
// =============================================================================
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
// Gate component: reads context to decide whether to mount the real content.
|
|
124
|
+
// This ensures useFloating and all other hooks in TooltipContentInner only
|
|
125
|
+
// run when the tooltip is actually open — not on every render cycle.
|
|
126
|
+
function TooltipContentComponent(props) {
|
|
127
|
+
const { open } = useTooltipContext();
|
|
128
|
+
if (!open)
|
|
129
|
+
return null;
|
|
130
|
+
return _jsx(TooltipContentInner, { ...props });
|
|
131
|
+
}
|
|
132
|
+
// Inner component: only mounted when open. All Floating UI hooks live here.
|
|
133
|
+
function TooltipContentInner({ side = 'top', align = 'center', sideOffset = 8, theme = 'dark', className, children, }) {
|
|
134
|
+
const { setOpen, triggerRef, contentId, cancelClose, handleClose } = useTooltipContext();
|
|
125
135
|
const arrowRef = useRef(null);
|
|
126
136
|
const { refs, floatingStyles, context } = useFloating({
|
|
127
137
|
placement: toPlacement(side, align),
|
|
128
|
-
open,
|
|
138
|
+
open: true, // Always true when mounted (gate handles the conditional)
|
|
129
139
|
middleware: [
|
|
130
140
|
offset(sideOffset),
|
|
131
141
|
flip(),
|
|
@@ -145,8 +155,6 @@ function TooltipContentComponent({ side = 'top', align = 'center', sideOffset =
|
|
|
145
155
|
// - Focus on trigger: close + stopPropagation (avoid closing parent modal)
|
|
146
156
|
// - Hover-only (focus elsewhere): close without stopPropagation
|
|
147
157
|
useEffect(() => {
|
|
148
|
-
if (!open)
|
|
149
|
-
return;
|
|
150
158
|
const handleKeyDown = (e) => {
|
|
151
159
|
if (e.key === 'Escape') {
|
|
152
160
|
const activeEl = document.activeElement;
|
|
@@ -160,22 +168,20 @@ function TooltipContentComponent({ side = 'top', align = 'center', sideOffset =
|
|
|
160
168
|
};
|
|
161
169
|
document.addEventListener('keydown', handleKeyDown);
|
|
162
170
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
163
|
-
}, [
|
|
171
|
+
}, [setOpen, triggerRef]);
|
|
164
172
|
// Get portal root inside .tui-interface
|
|
165
173
|
const portalRoot = getPortalRootFor(triggerRef.current);
|
|
166
174
|
// Dev warning: tooltips should not contain interactive content (WCAG 1.4.13)
|
|
167
175
|
// Use Popover for interactive overlays instead
|
|
168
176
|
useEffect(() => {
|
|
169
|
-
if (isDev() &&
|
|
177
|
+
if (isDev() && refs.floating.current) {
|
|
170
178
|
const interactive = refs.floating.current.querySelectorAll('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
171
179
|
if (interactive.length > 0) {
|
|
172
180
|
console.warn('[Tooltip] Contains interactive elements which violates WCAG 1.4.13. ' +
|
|
173
181
|
'Tooltips should only contain plain text. Use Popover for interactive content.');
|
|
174
182
|
}
|
|
175
183
|
}
|
|
176
|
-
}, [
|
|
177
|
-
if (!open)
|
|
178
|
-
return null;
|
|
184
|
+
}, [refs.floating]);
|
|
179
185
|
return (_jsx(FloatingPortal, { root: portalRoot, children: _jsxs("div", { ref: refs.setFloating, id: contentId, role: "tooltip", className: cx('tui-tooltip', theme === 'light' && 'is-theme-light', className), style: floatingStyles, onMouseEnter: cancelClose, onMouseLeave: handleClose, children: [children, _jsx(FloatingArrow, { ref: arrowRef, context: context, className: "tui-tooltip__arrow" })] }) }));
|
|
180
186
|
}
|
|
181
187
|
export const Tooltip = TooltipRoot;
|
package/package.json
CHANGED
package/tui-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.0.
|
|
3
|
-
"generated": "2026-03-
|
|
2
|
+
"version": "0.0.9",
|
|
3
|
+
"generated": "2026-03-19T22:46:07.954Z",
|
|
4
4
|
"components": {
|
|
5
5
|
"Accordion": {
|
|
6
6
|
"props": {
|
|
@@ -110,13 +110,11 @@
|
|
|
110
110
|
"size": {
|
|
111
111
|
"type": "\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"xxl\"",
|
|
112
112
|
"required": false,
|
|
113
|
-
"defaultValue": "md",
|
|
114
113
|
"description": "Size of the avatar"
|
|
115
114
|
},
|
|
116
115
|
"shape": {
|
|
117
116
|
"type": "\"circle\" | \"square\"",
|
|
118
117
|
"required": false,
|
|
119
|
-
"defaultValue": "circle",
|
|
120
118
|
"description": "Shape of the avatar"
|
|
121
119
|
},
|
|
122
120
|
"indicator": {
|
|
@@ -132,7 +130,6 @@
|
|
|
132
130
|
"indicatorPosition": {
|
|
133
131
|
"type": "\"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\"",
|
|
134
132
|
"required": false,
|
|
135
|
-
"defaultValue": "bottom-right",
|
|
136
133
|
"description": "Position of the indicator"
|
|
137
134
|
},
|
|
138
135
|
"tooltip": {
|
|
@@ -1208,6 +1205,11 @@
|
|
|
1208
1205
|
"size": {
|
|
1209
1206
|
"type": "\"sm\" | \"md\" | \"lg\"",
|
|
1210
1207
|
"required": false
|
|
1208
|
+
},
|
|
1209
|
+
"preventScrollOnRestore": {
|
|
1210
|
+
"type": "boolean",
|
|
1211
|
+
"required": false,
|
|
1212
|
+
"description": "When true, prevents the browser from scrolling to the trigger element\nwhen focus is restored on close. Useful when the trigger may be off-screen\ninside a scrollable container. Default: false."
|
|
1211
1213
|
}
|
|
1212
1214
|
},
|
|
1213
1215
|
"cssTokens": [
|
|
@@ -1277,13 +1279,11 @@
|
|
|
1277
1279
|
"mode": {
|
|
1278
1280
|
"type": "\"full\" | \"handle\"",
|
|
1279
1281
|
"required": false,
|
|
1280
|
-
"defaultValue": "full",
|
|
1281
1282
|
"description": "Structural mode. 'full' (default) shows background panel with arrows/index. 'handle' shows only the bare drag icon button."
|
|
1282
1283
|
},
|
|
1283
1284
|
"size": {
|
|
1284
1285
|
"type": "\"sm\" | \"md\"",
|
|
1285
1286
|
"required": false,
|
|
1286
|
-
"defaultValue": "md",
|
|
1287
1287
|
"description": "Component scale. Full mode: sm = 32px, md = 40px. Handle mode: sm = 24px, md = 32px."
|
|
1288
1288
|
},
|
|
1289
1289
|
"index": {
|
|
@@ -1294,19 +1294,16 @@
|
|
|
1294
1294
|
"locked": {
|
|
1295
1295
|
"type": "boolean",
|
|
1296
1296
|
"required": false,
|
|
1297
|
-
"defaultValue": "false",
|
|
1298
1297
|
"description": "When true, shows lock icon and disables all interaction."
|
|
1299
1298
|
},
|
|
1300
1299
|
"canMoveUp": {
|
|
1301
1300
|
"type": "boolean",
|
|
1302
1301
|
"required": false,
|
|
1303
|
-
"defaultValue": "true",
|
|
1304
1302
|
"description": "When false, disables the move-up button without hiding it. Default: true."
|
|
1305
1303
|
},
|
|
1306
1304
|
"canMoveDown": {
|
|
1307
1305
|
"type": "boolean",
|
|
1308
1306
|
"required": false,
|
|
1309
|
-
"defaultValue": "true",
|
|
1310
1307
|
"description": "When false, disables the move-down button without hiding it. Default: true."
|
|
1311
1308
|
},
|
|
1312
1309
|
"labels": {
|