@tangible/ui 0.0.9 → 0.0.10

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.
@@ -152,8 +152,18 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
152
152
  role,
153
153
  listNavigation,
154
154
  ]);
155
+ // Flag: first registerOption call after open clears stale entries.
156
+ const prevOpenForFlush = useRef(open);
157
+ const needsFlushRef = useRef(false);
158
+ if (open && !prevOpenForFlush.current)
159
+ needsFlushRef.current = true;
160
+ prevOpenForFlush.current = open;
155
161
  // Register option
156
162
  const registerOption = useCallback((option) => {
163
+ if (needsFlushRef.current) {
164
+ optionsRef.current.clear();
165
+ needsFlushRef.current = false;
166
+ }
157
167
  optionsRef.current.set(toKey(option.value), option);
158
168
  setRegistryVersion((v) => v + 1);
159
169
  }, []);
@@ -166,14 +176,6 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
166
176
  optionsRef.current.delete(toKey(optionValue));
167
177
  setRegistryVersion((v) => v + 1);
168
178
  }, []);
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]);
177
179
  // Active option ID for aria-activedescendant (hash-based for stability during filtering)
178
180
  const activeOptionId = activeIndex >= 0 && orderedOptions[activeIndex]
179
181
  ? `${listboxId}-opt-${hashForId(toKey(orderedOptions[activeIndex].value))}`
@@ -384,7 +386,7 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
384
386
  return (_jsx(ComboboxActionsContext.Provider, { value: actionsValue, children: _jsx(ComboboxStateContext.Provider, { value: stateValue, children: _jsxs("div", { className: "tui-combobox", children: [_jsxs("div", { className: "tui-combobox__input-wrapper", children: [_jsx("input", { ref: (node) => {
385
387
  inputRef.current = node;
386
388
  refs.setReference(node);
387
- }, type: "text", id: inputId, className: cx('tui-combobox__input', size !== 'md' && `is-size-${size}`, inputClassName), role: "combobox", "aria-expanded": open, "aria-controls": listboxId, "aria-autocomplete": "list", "aria-activedescendant": activeOptionId, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, disabled: disabled, placeholder: placeholder, value: inputValue, autoComplete: "off", ...referenceProps, onChange: handleInputChange }), clearable && inputValue && !disabled && (_jsx("button", { type: "button", className: "tui-combobox__clear", onClick: handleClear, onMouseDown: (e) => e.preventDefault(), "aria-label": labels.clear, tabIndex: -1, children: _jsx(Icon, { name: "system/close", size: "sm" }) })), _jsx("span", { className: "tui-combobox__icon", "aria-hidden": "true", children: _jsx(Icon, { name: "system/chevron-down", size: "sm" }) })] }), children] }) }) }));
389
+ }, type: "text", id: inputId, className: cx('tui-combobox__input', size !== 'md' && `is-size-${size}`, inputClassName), role: "combobox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-autocomplete": "list", "aria-activedescendant": activeOptionId, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, disabled: disabled, placeholder: placeholder, value: inputValue, autoComplete: "off", ...referenceProps, onChange: handleInputChange }), clearable && inputValue && !disabled && (_jsx("button", { type: "button", className: "tui-combobox__clear", onClick: handleClear, onMouseDown: (e) => e.preventDefault(), "aria-label": labels.clear, tabIndex: -1, children: _jsx(Icon, { name: "system/close", size: "sm" }) })), _jsx("span", { className: "tui-combobox__icon", "aria-hidden": "true", children: _jsx(Icon, { name: "system/chevron-down", size: "sm" }) })] }), children] }) }) }));
388
390
  }
389
391
  ComboboxRoot.displayName = 'Combobox';
390
392
  // =============================================================================
@@ -213,7 +213,17 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
213
213
  typeahead,
214
214
  ]);
215
215
  // Register option
216
+ // Flag: first registerOption call after open clears stale entries.
217
+ const prevOpenForFlush = useRef(open);
218
+ const needsFlushRef = useRef(false);
219
+ if (open && !prevOpenForFlush.current)
220
+ needsFlushRef.current = true;
221
+ prevOpenForFlush.current = open;
216
222
  const registerOption = useCallback((option) => {
223
+ if (needsFlushRef.current) {
224
+ optionsRef.current.clear();
225
+ needsFlushRef.current = false;
226
+ }
217
227
  optionsRef.current.set(toKey(option.value), option);
218
228
  setRegistryVersion((v) => v + 1);
219
229
  }, []);
@@ -225,14 +235,6 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
225
235
  optionsRef.current.delete(toKey(optionValue));
226
236
  setRegistryVersion((v) => v + 1);
227
237
  }, []);
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]);
236
238
  // Get selected options for trigger display
237
239
  const getSelectedOptions = useCallback(() => {
238
240
  return value
@@ -462,7 +464,7 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
462
464
  'aria-describedby': ariaDescribedBy,
463
465
  'aria-haspopup': 'listbox',
464
466
  'aria-expanded': open,
465
- 'aria-controls': listboxId,
467
+ 'aria-controls': open ? listboxId : undefined,
466
468
  'aria-keyshortcuts': hasSelection && !open ? 'Delete' : undefined,
467
469
  'data-state': open ? 'open' : 'closed',
468
470
  ...floatingProps,
@@ -494,7 +496,7 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
494
496
  ref: refs.setReference,
495
497
  'aria-haspopup': 'listbox',
496
498
  'aria-expanded': open,
497
- 'aria-controls': listboxId,
499
+ 'aria-controls': open ? listboxId : undefined,
498
500
  'aria-activedescendant': floatingProps['aria-activedescendant'],
499
501
  'aria-describedby': ariaDescribedBy,
500
502
  // asChild: use aria-disabled + data-disabled since element may not support native disabled
@@ -522,7 +524,7 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
522
524
  }
523
525
  // Default: render button with optional custom content
524
526
  const triggerContent = children ?? defaultTriggerContent;
525
- return (_jsxs(_Fragment, { children: [_jsx("button", { ref: refs.setReference, type: "button", id: triggerId, className: cx('tui-multiselect__trigger', sizeClass, className), disabled: disabled, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": listboxId, "data-state": open ? 'open' : 'closed', ...floatingProps,
527
+ return (_jsxs(_Fragment, { children: [_jsx("button", { ref: refs.setReference, type: "button", id: triggerId, className: cx('tui-multiselect__trigger', sizeClass, className), disabled: disabled, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "data-state": open ? 'open' : 'closed', ...floatingProps,
526
528
  // Handle Backspace/Delete AFTER floatingProps to ensure we catch it
527
529
  // (typeahead may intercept these for its buffer)
528
530
  onKeyDown: (e) => {
@@ -185,8 +185,21 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
185
185
  listNavigation,
186
186
  typeahead,
187
187
  ]);
188
+ // Flag: when the dropdown opens, the first registerOption call clears the
189
+ // registry before adding. This ensures stale entries from options that changed
190
+ // while closed don't persist — and avoids a useLayoutEffect ordering issue
191
+ // where a parent clear would run after children register.
192
+ const prevOpenForFlush = useRef(open);
193
+ const needsFlushRef = useRef(false);
194
+ if (open && !prevOpenForFlush.current)
195
+ needsFlushRef.current = true;
196
+ prevOpenForFlush.current = open;
188
197
  // Register option (stable callback - no value/displayText deps)
189
198
  const registerOption = useCallback((option) => {
199
+ if (needsFlushRef.current) {
200
+ optionsRef.current.clear();
201
+ needsFlushRef.current = false;
202
+ }
190
203
  optionsRef.current.set(toKey(option.value), option);
191
204
  setRegistryVersion((v) => v + 1);
192
205
  }, []);
@@ -201,14 +214,6 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
201
214
  }, []);
202
215
  // Highlighted value for keyboard navigation
203
216
  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]);
212
217
  // Reset active index when closing
213
218
  useEffect(() => {
214
219
  if (!open) {
@@ -371,6 +376,10 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
371
376
  'aria-keyshortcuts': clearable && hasValue ? 'Delete' : undefined,
372
377
  'data-state': open ? 'open' : 'closed',
373
378
  ...floatingProps,
379
+ // Override Floating UI's aria-controls — only reference the listbox when
380
+ // it's actually in the DOM. After first open, the listbox unmounts when
381
+ // closed to save hooks. A phantom aria-controls violates ARIA 1.2.
382
+ 'aria-controls': open ? floatingProps['aria-controls'] : undefined,
374
383
  };
375
384
  if (asChild && isValidElement(children)) {
376
385
  const childProps = children.props;
@@ -398,7 +407,7 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
398
407
  ref: refs.setReference,
399
408
  'aria-haspopup': floatingProps['aria-haspopup'],
400
409
  'aria-expanded': floatingProps['aria-expanded'],
401
- 'aria-controls': floatingProps['aria-controls'],
410
+ 'aria-controls': open ? floatingProps['aria-controls'] : undefined,
402
411
  'aria-activedescendant': floatingProps['aria-activedescendant'],
403
412
  'aria-describedby': ariaDescribedBy,
404
413
  // asChild: use aria-disabled + data-disabled since element may not support native disabled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangible/ui",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
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.9",
3
- "generated": "2026-03-19T22:46:07.954Z",
2
+ "version": "0.0.10",
3
+ "generated": "2026-03-19T23:08:17.377Z",
4
4
  "components": {
5
5
  "Accordion": {
6
6
  "props": {