@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.
@@ -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.ForwardRefExoticComponent<AvatarProps & React.RefAttributes<HTMLSpanElement>>;
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({ side, align, sideOffset, className, style, children, }: DropdownContentProps): import("react/jsx-runtime").JSX.Element | null;
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
- function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset = 4, className, style, children, }) {
116
- const { open, setOpen, triggerRef, contentId, activeIndex, setActiveIndex, openedVia } = useDropdownContext();
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 (open && openedVia.current === 'ArrowUp') {
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
- }, [open, openedVia, totalItemCount, disabledIndices, setActiveIndex]);
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.ForwardRefExoticComponent<IconProps & React.RefAttributes<HTMLSpanElement>>;
22
+ export declare const Icon: React.NamedExoticComponent<IconProps & React.RefAttributes<HTMLSpanElement>>;
@@ -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").ForwardRefExoticComponent<MoveHandleProps & import("react").RefAttributes<HTMLElement>>;
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,2 +1,3 @@
1
+ import React from 'react';
1
2
  import type { ProgressProps } from './types';
2
- export declare function Progress(props: ProgressProps): import("react/jsx-runtime").JSX.Element;
3
+ export declare const Progress: React.NamedExoticComponent<ProgressProps>;
@@ -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 function StepIndicator(props: StepIndicatorProps): import("react/jsx-runtime").JSX.Element;
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({ side, align, sideOffset, theme, className, children, }: TooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
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
- function TooltipContentComponent({ side = 'top', align = 'center', sideOffset = 8, theme = 'dark', className, children, }) {
124
- const { open, setOpen, triggerRef, contentId, cancelClose, handleClose } = useTooltipContext();
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
- }, [open, setOpen, triggerRef]);
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() && open && refs.floating.current) {
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
- }, [open, refs.floating]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangible/ui",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Tangible Design System",
5
5
  "type": "module",
6
6
  "main": "./components/index.js",
package/tui-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.0.8",
3
- "generated": "2026-03-17T20:49:24.034Z",
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": {