@tangible/ui 0.0.7 → 0.0.8

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.
Files changed (91) hide show
  1. package/components/Accordion/Accordion.js +11 -3
  2. package/components/Avatar/Avatar.js +4 -3
  3. package/components/Avatar/AvatarGroup.js +7 -5
  4. package/components/Avatar/index.d.ts +2 -2
  5. package/components/Avatar/index.js +1 -1
  6. package/components/Avatar/types.d.ts +27 -0
  7. package/components/Avatar/types.js +8 -0
  8. package/components/Button/Button.js +4 -2
  9. package/components/Button/index.d.ts +2 -1
  10. package/components/Button/index.js +1 -0
  11. package/components/Button/types.d.ts +10 -0
  12. package/components/Button/types.js +3 -1
  13. package/components/Checkbox/Checkbox.js +46 -11
  14. package/components/Checkbox/types.d.ts +9 -0
  15. package/components/Combobox/Combobox.d.ts +1 -1
  16. package/components/Combobox/Combobox.js +28 -7
  17. package/components/Combobox/index.d.ts +2 -1
  18. package/components/Combobox/index.js +1 -0
  19. package/components/Combobox/types.d.ts +9 -0
  20. package/components/Combobox/types.js +3 -1
  21. package/components/Dropdown/Dropdown.js +16 -4
  22. package/components/Field/Field.d.ts +4 -1
  23. package/components/Field/Field.js +35 -14
  24. package/components/Field/FieldContext.d.ts +16 -0
  25. package/components/Field/FieldContext.js +3 -0
  26. package/components/Field/index.d.ts +2 -1
  27. package/components/Field/index.js +1 -0
  28. package/components/MoveHandle/MoveHandle.js +1 -1
  29. package/components/MoveHandle/types.d.ts +1 -1
  30. package/components/MultiSelect/MultiSelect.d.ts +1 -1
  31. package/components/MultiSelect/MultiSelect.js +37 -19
  32. package/components/MultiSelect/index.d.ts +2 -1
  33. package/components/MultiSelect/index.js +1 -0
  34. package/components/MultiSelect/types.d.ts +34 -0
  35. package/components/MultiSelect/types.js +10 -0
  36. package/components/Pager/Pager.d.ts +7 -1
  37. package/components/Pager/Pager.js +7 -5
  38. package/components/Pager/index.d.ts +2 -0
  39. package/components/Pager/index.js +1 -0
  40. package/components/Pager/types.d.ts +37 -0
  41. package/components/Pager/types.js +12 -0
  42. package/components/Rating/Rating.d.ts +2 -32
  43. package/components/Rating/Rating.js +5 -3
  44. package/components/Rating/index.d.ts +2 -1
  45. package/components/Rating/index.js +1 -0
  46. package/components/Rating/types.d.ts +41 -0
  47. package/components/Rating/types.js +4 -0
  48. package/components/SegmentedControl/SegmentedControl.js +6 -5
  49. package/components/SegmentedControl/types.d.ts +17 -5
  50. package/components/Select/Select.d.ts +1 -0
  51. package/components/Select/Select.js +109 -77
  52. package/components/Select/SelectContext.d.ts +4 -16
  53. package/components/Select/SelectContext.js +5 -35
  54. package/components/Select/types.d.ts +19 -19
  55. package/components/Sidebar/Sidebar.js +25 -20
  56. package/components/StepIndicator/StepIndicator.js +11 -8
  57. package/components/StepIndicator/index.d.ts +2 -1
  58. package/components/StepIndicator/index.js +1 -0
  59. package/components/StepIndicator/types.d.ts +18 -0
  60. package/components/StepIndicator/types.js +7 -1
  61. package/components/Table/BulkActionsBar.d.ts +4 -1
  62. package/components/Table/BulkActionsBar.js +5 -4
  63. package/components/Table/DataTable.d.ts +4 -1
  64. package/components/Table/DataTable.js +10 -8
  65. package/components/Table/index.d.ts +3 -0
  66. package/components/Table/index.js +2 -0
  67. package/components/Table/types.d.ts +20 -0
  68. package/components/Table/types.js +11 -0
  69. package/components/Tabs/Tabs.js +11 -4
  70. package/components/TextInput/TextInput.js +2 -1
  71. package/components/TextInput/types.d.ts +7 -1
  72. package/components/Textarea/Textarea.js +3 -2
  73. package/components/Textarea/types.d.ts +6 -1
  74. package/icons/icons.svg +29 -15
  75. package/icons/lms/index.d.ts +8 -0
  76. package/icons/lms/index.js +48 -4
  77. package/icons/manifest.json +112 -0
  78. package/icons/player/index.js +9 -9
  79. package/icons/registry.d.ts +28 -0
  80. package/icons/registry.js +14 -0
  81. package/icons/system/index.d.ts +20 -0
  82. package/icons/system/index.js +112 -2
  83. package/package.json +1 -1
  84. package/styles/all.css +1 -1
  85. package/styles/all.expanded.css +266 -59
  86. package/styles/all.expanded.unlayered.css +266 -59
  87. package/styles/all.unlayered.css +1 -1
  88. package/styles/components/input/index.scss +29 -7
  89. package/styles/system/_constants.scss +1 -1
  90. package/styles/system/_tokens.scss +1 -0
  91. package/tui-manifest.json +73 -44
@@ -1,13 +1,18 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { forwardRef, useId, useMemo, cloneElement, isValidElement, Children, } from 'react';
2
+ import React, { forwardRef, useEffect, useId, useMemo, useRef, cloneElement, isValidElement, Children, } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
- import { FieldContext, useFieldContext } from './FieldContext.js';
5
- export const Field = forwardRef(function Field({ error = false, required = false, disabled = false, inline = false, className, children, }, ref) {
4
+ import { isDev } from '../../utils/is-dev.js';
5
+ import { FieldContext, useFieldContext, defaultFieldLabels } from './FieldContext.js';
6
+ export const Field = forwardRef(function Field({ error = false, required = false, disabled = false, inline = false, labels: labelsProp, className, children, }, ref) {
6
7
  const baseId = useId();
7
8
  const controlId = `${baseId}-control`;
8
9
  const labelId = `${baseId}-label`;
9
10
  const helperTextId = `${baseId}-helper`;
10
11
  const errorId = `${baseId}-error`;
12
+ const labelRendered = useRef(false);
13
+ // Reset on each render — Label will set it back to true during its render
14
+ labelRendered.current = false;
15
+ const labels = useMemo(() => ({ ...defaultFieldLabels, ...labelsProp }), [labelsProp]);
11
16
  const contextValue = useMemo(() => ({
12
17
  controlId,
13
18
  labelId,
@@ -16,7 +21,9 @@ export const Field = forwardRef(function Field({ error = false, required = false
16
21
  hasError: error,
17
22
  required,
18
23
  disabled,
19
- }), [controlId, labelId, helperTextId, errorId, error, required, disabled]);
24
+ labelRendered,
25
+ labels,
26
+ }), [controlId, labelId, helperTextId, errorId, error, required, disabled, labels]);
20
27
  const classes = cx('tui-field', error && 'is-error', disabled && 'is-disabled', inline && 'is-layout-inline', className);
21
28
  return (_jsx(FieldContext.Provider, { value: contextValue, children: _jsx("div", { ref: ref, className: classes, children: children }) }));
22
29
  });
@@ -24,16 +31,20 @@ export const Field = forwardRef(function Field({ error = false, required = false
24
31
  // Field.Label
25
32
  // =============================================================================
26
33
  function FieldLabel({ hidden = false, className, children, ...rest }) {
27
- const { controlId, labelId, required } = useFieldContext();
34
+ const { controlId, labelId, required, labelRendered, labels } = useFieldContext();
35
+ // Signal to Field.Control that a label element exists in the tree.
36
+ // This is read synchronously during the same render pass (Label renders
37
+ // before Control in JSX order) to decide whether aria-labelledby is safe.
38
+ labelRendered.current = true;
28
39
  const classes = cx('tui-field__label', hidden && 'tui-visually-hidden', className);
29
- return (_jsxs("label", { id: labelId, htmlFor: controlId, className: classes, ...rest, children: [children, required && (_jsxs(_Fragment, { children: [_jsx("span", { className: "tui-field__required", "aria-hidden": "true", children: "*" }), _jsx("span", { className: "tui-visually-hidden", children: "required" })] }))] }));
40
+ return (_jsxs("label", { id: labelId, htmlFor: controlId, className: classes, ...rest, children: [children, required && (_jsxs(_Fragment, { children: [_jsx("span", { className: "tui-field__required", "aria-hidden": "true", children: "*" }), _jsx("span", { className: "tui-visually-hidden", children: labels.required })] }))] }));
30
41
  }
31
42
  FieldLabel.displayName = 'Field.Label';
32
43
  // =============================================================================
33
44
  // Field.Control
34
45
  // =============================================================================
35
46
  function FieldControl({ children }) {
36
- const { controlId, labelId, helperTextId, errorId, hasError, required, disabled, } = useFieldContext();
47
+ const { controlId, labelId, helperTextId, errorId, hasError, required, disabled, labelRendered, } = useFieldContext();
37
48
  const child = Children.only(children);
38
49
  if (!isValidElement(child)) {
39
50
  throw new Error('Field.Control expects a single React element as its child');
@@ -50,13 +61,23 @@ function FieldControl({ children }) {
50
61
  describedByParts.push(errorId);
51
62
  }
52
63
  const describedBy = describedByParts.join(' ');
53
- // Build aria-labelledby for non-labelable elements (<button>, <div>, etc.)
54
- // <label htmlFor> only works with labelable elements (input, textarea, select,
55
- // meter, output, progress). For everything else (Switch, future Slider, etc.)
56
- // aria-labelledby provides the accessible name. For native inputs this is
57
- // redundant with htmlFor but harmless — aria-labelledby takes priority in the
58
- // accessible name algorithm and points at the same label text.
59
- const labelledBy = childProps['aria-labelledby'] ?? labelId;
64
+ // Only inject aria-labelledby when Field.Label is present in the tree.
65
+ // Without a rendered label, the ID points at nothing and per AccName 1.2
66
+ // step 2B, aria-labelledby overrides ALL other name sources (including
67
+ // implicit <label> wrapping). A phantom ID produces an empty name, which
68
+ // strips the accessible name from self-labelling controls like Checkbox.
69
+ const childLabelledBy = childProps['aria-labelledby'];
70
+ const injectedLabelledBy = labelRendered.current;
71
+ const labelledBy = childLabelledBy ?? (injectedLabelledBy ? labelId : undefined);
72
+ // DEV: warn if Field.Label appears after Field.Control in JSX order —
73
+ // labelRendered will be false at render time but true after mount.
74
+ // eslint-disable-next-line react-hooks/exhaustive-deps
75
+ useEffect(() => {
76
+ if (isDev() && !injectedLabelledBy && !childLabelledBy && labelRendered.current) {
77
+ console.warn('Field: Field.Label appears after Field.Control in the JSX tree. ' +
78
+ 'Move Field.Label before Field.Control so aria-labelledby is wired correctly.');
79
+ }
80
+ }, []);
60
81
  // Clone child with a11y props
61
82
  // Note: aria-invalid and aria-required must be string "true", not boolean
62
83
  return cloneElement(child, {
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Overridable strings for Field i18n.
3
+ */
4
+ export type FieldLabels = {
5
+ /** Visually-hidden text appended to required field labels. @default "required" */
6
+ required?: string;
7
+ };
8
+ export declare const defaultFieldLabels: Required<FieldLabels>;
1
9
  export type FieldContextValue = {
2
10
  /** ID for the form control element */
3
11
  controlId: string;
@@ -13,6 +21,14 @@ export type FieldContextValue = {
13
21
  required: boolean;
14
22
  /** Whether the field is disabled */
15
23
  disabled: boolean;
24
+ /** Mutable flag — set by Field.Label during render so Field.Control knows
25
+ * whether to inject aria-labelledby. Avoids injecting a phantom ID that
26
+ * overrides implicit <label> associations in the accessible name algorithm. */
27
+ labelRendered: {
28
+ current: boolean;
29
+ };
30
+ /** Resolved i18n labels for Field sub-components */
31
+ labels: Required<FieldLabels>;
16
32
  };
17
33
  export declare const FieldContext: import("react").Context<FieldContextValue | null>;
18
34
  export declare function useFieldContext(): FieldContextValue;
@@ -1,4 +1,7 @@
1
1
  import { createContext, useContext } from 'react';
2
+ export const defaultFieldLabels = {
3
+ required: 'required',
4
+ };
2
5
  export const FieldContext = createContext(null);
3
6
  export function useFieldContext() {
4
7
  const context = useContext(FieldContext);
@@ -1,2 +1,3 @@
1
1
  export { Field } from './Field';
2
- export type { FieldProps } from './Field';
2
+ export type { FieldProps, FieldLabels } from './Field';
3
+ export { defaultFieldLabels } from './FieldContext';
@@ -1 +1,2 @@
1
1
  export { Field } from './Field.js';
2
+ export { defaultFieldLabels } from './FieldContext.js';
@@ -83,7 +83,7 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
83
83
  // Uses ref directly (not mergedRef) — innerRef is unused for this path.
84
84
  // Focus recovery and dev warning effects early-return for handle mode.
85
85
  if (mode === 'handle') {
86
- return (_jsx("button", { ref: ref, type: "button", className: cx('tui-move-handle', 'is-handle', className), "aria-label": resolvedDragLabel, ...restDragProps, children: _jsx(Icon, { name: "system/drag" }) }));
86
+ return (_jsx("button", { ref: ref, type: "button", className: cx('tui-move-handle', 'is-handle', `is-size-${size}`, className), "aria-label": resolvedDragLabel, ...restDragProps, children: _jsx(Icon, { name: "system/drag" }) }));
87
87
  }
88
88
  // ----- Full mode -----
89
89
  const hasIndex = index != null;
@@ -17,7 +17,7 @@ export interface MoveHandleLabels {
17
17
  export interface MoveHandleProps {
18
18
  /** Structural mode. 'full' (default) shows background panel with arrows/index. 'handle' shows only the bare drag icon button. */
19
19
  mode?: MoveHandleMode;
20
- /** Component scale. sm = 32px, md = 40px. Ignored when mode is 'handle'. */
20
+ /** Component scale. Full mode: sm = 32px, md = 40px. Handle mode: sm = 24px, md = 32px. */
21
21
  size?: MoveHandleSize;
22
22
  /** Position index. When provided, shows number at rest, drag handle on hover. */
23
23
  index?: number;
@@ -1,5 +1,5 @@
1
1
  import { type MultiSelectProps, type MultiSelectTriggerProps, type MultiSelectContentProps, type MultiSelectOptionProps, type MultiSelectGroupProps, type MultiSelectLabelProps } from './types';
2
- declare function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled, placeholder, size, display, maxChips, max, onMaxReached, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }: MultiSelectProps): import("react/jsx-runtime").JSX.Element;
2
+ declare function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled, placeholder, size, display, maxChips, max, onMaxReached, labels: labelsProp, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }: MultiSelectProps): import("react/jsx-runtime").JSX.Element;
3
3
  declare namespace MultiSelectRoot {
4
4
  var displayName: string;
5
5
  }
@@ -6,15 +6,17 @@ import { cx } from '../../utils/cx.js';
6
6
  import { getPortalRootFor } from '../../utils/portal.js';
7
7
  import { Icon } from '../Icon/index.js';
8
8
  import { MultiSelectActionsContext, MultiSelectStateContext, MultiSelectContentContext, useMultiSelectContext, useMultiSelectContentContext, } from './MultiSelectContext.js';
9
- import { toKey, } from './types.js';
9
+ import { toKey, defaultMultiSelectLabels, } from './types.js';
10
10
  // =============================================================================
11
11
  // MultiSelect Root
12
12
  // =============================================================================
13
- function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', display = 'count', maxChips = 3, max, onMaxReached, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }) {
13
+ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', display = 'count', maxChips = 3, max, onMaxReached, labels: labelsProp, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }) {
14
+ // Merge caller labels with defaults
15
+ const labels = useMemo(() => ({ ...defaultMultiSelectLabels, ...labelsProp }), [labelsProp]);
14
16
  // Controlled/uncontrolled value
15
17
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? []);
16
- const isValueControlled = controlledValue !== undefined;
17
- const value = isValueControlled ? controlledValue : uncontrolledValue;
18
+ const isValueControlled = useRef(controlledValue !== undefined).current;
19
+ const value = (isValueControlled ? controlledValue : uncontrolledValue);
18
20
  // Track selected text values for display when closed
19
21
  const selectedTextMapRef = useRef(new Map());
20
22
  // Option registration
@@ -273,6 +275,7 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
273
275
  maxChips,
274
276
  max,
275
277
  size,
278
+ labels,
276
279
  // ARIA IDs (stable)
277
280
  triggerId,
278
281
  listboxId,
@@ -300,6 +303,7 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
300
303
  maxChips,
301
304
  max,
302
305
  size,
306
+ labels,
303
307
  // IDs are stable (from useId)
304
308
  triggerId,
305
309
  listboxId,
@@ -356,7 +360,7 @@ MultiSelectRoot.displayName = 'MultiSelect';
356
360
  // MultiSelect.Trigger
357
361
  // =============================================================================
358
362
  function MultiSelectTriggerComponent({ asChild = false, className, children, }) {
359
- const { open, setOpen, disabled, placeholder, size, display, maxChips, max, maxReached, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy, getSelectedOptions, clearAll, refs, getReferenceProps, activeIndex, orderedOptions, toggleOption, isSelected, } = useMultiSelectContext();
363
+ const { open, setOpen, disabled, placeholder, size, display, maxChips, max, maxReached, labels, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy, getSelectedOptions, clearAll, refs, getReferenceProps, activeIndex, orderedOptions, toggleOption, isSelected, } = useMultiSelectContext();
360
364
  const sizeClass = size !== 'md' ? `is-size-${size}` : undefined;
361
365
  const selectedOptions = getSelectedOptions();
362
366
  const hasSelection = selectedOptions.length > 0;
@@ -422,19 +426,17 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
422
426
  return _jsx("span", { className: "tui-multiselect__placeholder", children: placeholder });
423
427
  }
424
428
  if (display === 'count') {
425
- return (_jsxs("span", { className: "tui-multiselect__count", children: [selectedOptions.length, " selected"] }));
429
+ return (_jsx("span", { className: "tui-multiselect__count", children: labels.selected(selectedOptions.length) }));
426
430
  }
427
431
  // chips mode
428
432
  const visibleChips = selectedOptions.slice(0, maxChips);
429
433
  const overflow = selectedOptions.length - maxChips;
430
- return (_jsxs("span", { className: "tui-multiselect__chips", children: [visibleChips.map((opt) => (_jsx("span", { className: "tui-multiselect__chip", children: opt.textValue }, toKey(opt.value)))), overflow > 0 && (_jsxs("span", { className: "tui-multiselect__more", children: ["+", overflow, " more"] }))] }));
434
+ return (_jsxs("span", { className: "tui-multiselect__chips", children: [visibleChips.map((opt) => (_jsx("span", { className: "tui-multiselect__chip", children: opt.textValue }, toKey(opt.value)))), overflow > 0 && (_jsx("span", { className: "tui-multiselect__more", children: labels.more(overflow) }))] }));
431
435
  };
432
436
  // Default trigger content (when not using asChild or custom children)
433
437
  const defaultTriggerContent = (_jsxs(_Fragment, { children: [_jsx("span", { className: "tui-multiselect__value", children: renderTriggerContent() }), hasSelection && (_jsx("span", { className: "tui-multiselect__clear", onClick: handleClearClick, "aria-hidden": "true", children: _jsx(Icon, { name: "system/close", size: "sm" }) })), _jsx(Icon, { name: "system/chevron-down", size: "sm", className: "tui-multiselect__icon", "aria-hidden": "true" })] }));
434
438
  // Generate status message for screen readers
435
- const statusMessage = hasSelection
436
- ? `${selectedOptions.length} item${selectedOptions.length === 1 ? '' : 's'} selected${maxReached && max ? `. Maximum of ${max} reached` : ''}`
437
- : '';
439
+ const statusMessage = labels.status(selectedOptions.length, max);
438
440
  // Live region (rendered outside button, sibling to trigger)
439
441
  const liveRegion = (_jsx("span", { className: "tui-visually-hidden", role: "status", "aria-live": "polite", "aria-atomic": "true", children: statusMessage }));
440
442
  // Base trigger props
@@ -446,9 +448,8 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
446
448
  'aria-haspopup': 'listbox',
447
449
  'aria-expanded': open,
448
450
  'aria-controls': listboxId,
449
- 'aria-disabled': disabled || undefined,
451
+ 'aria-keyshortcuts': hasSelection && !open ? 'Delete' : undefined,
450
452
  'data-state': open ? 'open' : 'closed',
451
- 'data-disabled': disabled || undefined,
452
453
  ...floatingProps,
453
454
  };
454
455
  // asChild: merge props onto child element
@@ -481,6 +482,7 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
481
482
  'aria-controls': listboxId,
482
483
  'aria-activedescendant': floatingProps['aria-activedescendant'],
483
484
  'aria-describedby': ariaDescribedBy,
485
+ // asChild: use aria-disabled + data-disabled since element may not support native disabled
484
486
  'aria-disabled': disabled || undefined,
485
487
  'data-state': open ? 'open' : 'closed',
486
488
  'data-disabled': disabled || undefined,
@@ -505,7 +507,7 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
505
507
  }
506
508
  // Default: render button with optional custom content
507
509
  const triggerContent = children ?? defaultTriggerContent;
508
- 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, "aria-disabled": disabled || undefined, "data-state": open ? 'open' : 'closed', "data-disabled": disabled || undefined, ...floatingProps,
510
+ 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,
509
511
  // Handle Backspace/Delete AFTER floatingProps to ensure we catch it
510
512
  // (typeahead may intercept these for its buffer)
511
513
  onKeyDown: (e) => {
@@ -532,7 +534,7 @@ function MultiSelectContentComponent({ className, children }) {
532
534
  activeIndex,
533
535
  orderedOptions,
534
536
  }), [listRef, activeIndex, orderedOptions]);
535
- return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { 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: {
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: {
536
538
  ...floatingStyles,
537
539
  minWidth: refs.reference.current?.offsetWidth,
538
540
  pointerEvents: 'auto',
@@ -548,9 +550,11 @@ function MultiSelectOptionComponent({ value: optionValue, disabled = false, text
548
550
  const ref = useRef(null);
549
551
  // Derive textValue from children if not explicitly provided
550
552
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
551
- // Warn in dev if textValue couldn't be derived
553
+ // Warn in dev if textValue couldn't be derived (fire once per mount)
554
+ const warnedTextValueRef = useRef(false);
552
555
  useEffect(() => {
553
- if (isDev() && !textValue) {
556
+ if (isDev() && !textValue && !warnedTextValueRef.current) {
557
+ warnedTextValueRef.current = true;
554
558
  console.warn(`MultiSelect.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
555
559
  }
556
560
  }, [textValue, optionValue]);
@@ -593,7 +597,16 @@ MultiSelectOptionComponent.displayName = 'MultiSelect.Option';
593
597
  // =============================================================================
594
598
  function MultiSelectGroupComponent({ className, children }) {
595
599
  const groupId = useId();
596
- return (_jsx("div", { role: "group", "aria-labelledby": `${groupId}-label`, className: cx('tui-multiselect__group', className), children: _jsx(MultiSelectGroupContext.Provider, { value: { groupId }, children: children }) }));
600
+ const groupRef = useRef(null);
601
+ const [hasLabel, setHasLabel] = useState(false);
602
+ // Check if a Label child rendered — guard aria-labelledby to prevent dangling reference
603
+ useLayoutEffect(() => {
604
+ if (groupRef.current) {
605
+ const labelEl = groupRef.current.querySelector(`#${CSS.escape(`${groupId}-label`)}`);
606
+ setHasLabel(!!labelEl);
607
+ }
608
+ }, [groupId, children]);
609
+ return (_jsx("div", { ref: groupRef, role: "group", "aria-labelledby": hasLabel ? `${groupId}-label` : undefined, className: cx('tui-multiselect__group', className), children: _jsx(MultiSelectGroupContext.Provider, { value: { groupId }, children: children }) }));
597
610
  }
598
611
  MultiSelectGroupComponent.displayName = 'MultiSelect.Group';
599
612
  const MultiSelectGroupContext = React.createContext(null);
@@ -602,8 +615,13 @@ const MultiSelectGroupContext = React.createContext(null);
602
615
  // =============================================================================
603
616
  function MultiSelectLabelComponent({ className, children }) {
604
617
  const groupContext = React.useContext(MultiSelectGroupContext);
605
- // No aria-hidden or role="presentation" — aria-labelledby on Group references this element,
606
- // so screen readers need access to read the label text.
618
+ const warnedRef = useRef(false);
619
+ useEffect(() => {
620
+ if (isDev() && !groupContext && !warnedRef.current) {
621
+ warnedRef.current = true;
622
+ console.warn('MultiSelect.Label should be used inside MultiSelect.Group for accessibility.');
623
+ }
624
+ }, [groupContext]);
607
625
  return (_jsx("div", { id: groupContext ? `${groupContext.groupId}-label` : undefined, className: cx('tui-multiselect__label', className), children: children }));
608
626
  }
609
627
  MultiSelectLabelComponent.displayName = 'MultiSelect.Label';
@@ -1,2 +1,3 @@
1
1
  export { MultiSelect, MultiSelectTrigger, MultiSelectContent, MultiSelectOption, MultiSelectGroup, MultiSelectLabel, useMultiSelect, } from './MultiSelect';
2
- export type { MultiSelectProps, MultiSelectTriggerProps, MultiSelectContentProps, MultiSelectOptionProps, MultiSelectGroupProps, MultiSelectLabelProps, MultiSelectValue, OptionValue, DisplayMode, RegisteredOption as MultiSelectRegisteredOption, } from './types';
2
+ export { defaultMultiSelectLabels } from './types';
3
+ export type { MultiSelectProps, MultiSelectTriggerProps, MultiSelectContentProps, MultiSelectOptionProps, MultiSelectGroupProps, MultiSelectLabelProps, MultiSelectLabels, MultiSelectValue, OptionValue, DisplayMode, RegisteredOption as MultiSelectRegisteredOption, } from './types';
@@ -1 +1,2 @@
1
1
  export { MultiSelect, MultiSelectTrigger, MultiSelectContent, MultiSelectOption, MultiSelectGroup, MultiSelectLabel, useMultiSelect, } from './MultiSelect.js';
2
+ export { defaultMultiSelectLabels } from './types.js';
@@ -11,6 +11,34 @@ export type MultiSelectValue = Array<string | number>;
11
11
  * Display mode for the trigger.
12
12
  */
13
13
  export type DisplayMode = 'count' | 'chips';
14
+ /**
15
+ * Overridable label strings for i18n.
16
+ *
17
+ * Static strings use plain `string`, dynamic strings use function signatures.
18
+ * All keys are optional — defaults are English.
19
+ */
20
+ export type MultiSelectLabels = {
21
+ /**
22
+ * Trigger text in count display mode.
23
+ * @default (count) => `${count} selected`
24
+ */
25
+ selected?: (count: number) => string;
26
+ /**
27
+ * Overflow badge text in chips display mode.
28
+ * @default (count) => `+${count} more`
29
+ */
30
+ more?: (count: number) => string;
31
+ /**
32
+ * Screen reader status announcement. Called on every selection change.
33
+ * When `max` is defined and `count >= max`, include a "maximum reached" note.
34
+ * @default (count, max) => count === 0 ? '0 items selected' : `${count} item${count === 1 ? '' : 's'} selected${max !== undefined && count >= max ? `. Maximum of ${max} reached` : ''}`
35
+ */
36
+ status?: (count: number, max?: number) => string;
37
+ };
38
+ /**
39
+ * Default English labels. Exported for reference or spread-override patterns.
40
+ */
41
+ export declare const defaultMultiSelectLabels: Required<MultiSelectLabels>;
14
42
  export type MultiSelectProps = {
15
43
  /**
16
44
  * Control size.
@@ -73,6 +101,11 @@ export type MultiSelectProps = {
73
101
  * Callback when max selections is reached.
74
102
  */
75
103
  onMaxReached?: () => void;
104
+ /**
105
+ * Override internal display and screen reader strings for i18n.
106
+ * All keys are optional — omitted keys use English defaults.
107
+ */
108
+ labels?: MultiSelectLabels;
76
109
  /**
77
110
  * Accessible name for the select.
78
111
  */
@@ -169,6 +202,7 @@ export type MultiSelectActionsContextValue = {
169
202
  maxChips: number;
170
203
  max: number | undefined;
171
204
  size: SizeStandard;
205
+ labels: Required<MultiSelectLabels>;
172
206
  triggerId: string;
173
207
  listboxId: string;
174
208
  ariaLabel?: string;
@@ -1,3 +1,13 @@
1
1
  import { toKey } from '../../utils/value-key.js';
2
2
  // Re-export shared value-key types so existing consumers don't break
3
3
  export { toKey };
4
+ /**
5
+ * Default English labels. Exported for reference or spread-override patterns.
6
+ */
7
+ export const defaultMultiSelectLabels = {
8
+ selected: (count) => `${count} selected`,
9
+ more: (count) => `+${count} more`,
10
+ status: (count, max) => count === 0
11
+ ? '0 items selected'
12
+ : `${count} item${count === 1 ? '' : 's'} selected${max !== undefined && count >= max ? `. Maximum of ${max} reached` : ''}`,
13
+ };
@@ -1,3 +1,4 @@
1
+ import { type PagerLabels } from './types';
1
2
  export type PagerMode = 'simple' | 'ends' | 'full' | 'smart';
2
3
  export type PagerProps = {
3
4
  /** Current page (1-indexed) */
@@ -22,5 +23,10 @@ export type PagerProps = {
22
23
  hidden?: boolean;
23
24
  /** Additional class name */
24
25
  className?: string;
26
+ /**
27
+ * Overridable label strings for i18n.
28
+ * All keys are optional — defaults are English.
29
+ */
30
+ labels?: PagerLabels;
25
31
  };
26
- export declare function Pager({ currentPage, totalPages, onPageChange, pageSize, pageSizeOptions, onPageSizeChange, mode, maxNumbers, navStyle, hidden, className, }: PagerProps): import("react/jsx-runtime").JSX.Element | null;
32
+ export declare function Pager({ currentPage, totalPages, onPageChange, pageSize, pageSizeOptions, onPageSizeChange, mode, maxNumbers, navStyle, hidden, className, labels: labelsProp, }: PagerProps): import("react/jsx-runtime").JSX.Element | null;
@@ -3,6 +3,7 @@ import * as React from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { Button } from '../Button/index.js';
5
5
  import { IconButton } from '../IconButton/index.js';
6
+ import { defaultPagerLabels } from './types.js';
6
7
  // =============================================================================
7
8
  // Pager Component
8
9
  // =============================================================================
@@ -121,7 +122,8 @@ function buildItems(total, current, mode, maxNumbers) {
121
122
  // -----------------------------------------------------------------------------
122
123
  // Component
123
124
  // -----------------------------------------------------------------------------
124
- export function Pager({ currentPage, totalPages, onPageChange, pageSize, pageSizeOptions, onPageSizeChange, mode = 'smart', maxNumbers = DEFAULT_MAX_SLOTS, navStyle = 'text', hidden = false, className, }) {
125
+ export function Pager({ currentPage, totalPages, onPageChange, pageSize, pageSizeOptions, onPageSizeChange, mode = 'smart', maxNumbers = DEFAULT_MAX_SLOTS, navStyle = 'text', hidden = false, className, labels: labelsProp, }) {
126
+ const labels = React.useMemo(() => ({ ...defaultPagerLabels, ...labelsProp }), [labelsProp]);
125
127
  // Normalise inputs
126
128
  const total = Math.max(1, totalPages);
127
129
  const current = Math.min(total, Math.max(1, currentPage));
@@ -130,13 +132,13 @@ export function Pager({ currentPage, totalPages, onPageChange, pageSize, pageSiz
130
132
  if (hidden)
131
133
  return null;
132
134
  const showPageSizeSelector = pageSizeOptions && pageSizeOptions.length > 0 && onPageSizeChange;
133
- return (_jsxs("div", { className: cx('tui-pager', className), children: [_jsx("nav", { className: "tui-pager__nav", "aria-label": "Pagination", children: items.map((it, i) => {
135
+ return (_jsxs("div", { className: cx('tui-pager', className), children: [_jsx("nav", { className: "tui-pager__nav", "aria-label": labels.navigation, children: items.map((it, i) => {
134
136
  if (it.kind === 'ellipsis') {
135
137
  return (_jsx("span", { className: "tui-pager__ellipsis", "aria-hidden": true, children: "..." }, `e${i}`));
136
138
  }
137
139
  if (it.kind === 'prev' || it.kind === 'next') {
138
140
  const isPrev = it.kind === 'prev';
139
- const label = isPrev ? 'Previous page' : 'Next page';
141
+ const label = isPrev ? labels.previous : labels.next;
140
142
  if (navStyle === 'icon') {
141
143
  return (_jsx(IconButton, { icon: isPrev ? 'system/chevron-left' : 'system/chevron-right', label: label, showTooltip: true, size: "sm", theme: "secondary", variant: "outline", disabled: it.disabled, onClick: () => go(it.page), className: "tui-pager__item" }, it.kind));
142
144
  }
@@ -144,8 +146,8 @@ export function Pager({ currentPage, totalPages, onPageChange, pageSize, pageSiz
144
146
  }
145
147
  // Page number
146
148
  if (it.kind === 'page') {
147
- return (_jsx(Button, { size: "sm", theme: it.current ? 'primary' : 'secondary', variant: it.current ? 'solid' : 'outline', "aria-current": it.current ? 'page' : undefined, "aria-label": `Page ${it.page}`, onClick: () => go(it.page), className: "tui-pager__item", children: it.page }, it.page));
149
+ return (_jsx(Button, { size: "sm", theme: it.current ? 'primary' : 'secondary', variant: it.current ? 'solid' : 'outline', "aria-current": it.current ? 'page' : undefined, "aria-label": labels.page(it.page), onClick: () => go(it.page), className: "tui-pager__item", children: it.page }, it.page));
148
150
  }
149
151
  return null;
150
- }) }), _jsxs("div", { className: "tui-pager__info", children: [_jsxs("span", { children: ["Page ", _jsx("strong", { children: current }), " of ", total] }), showPageSizeSelector && (_jsxs("label", { className: "tui-pager__page-size-label", children: [_jsx("span", { className: "tui-visually-hidden", children: "Items per page" }), _jsx("select", { className: "tui-input", value: pageSize, onChange: (e) => onPageSizeChange(Number(e.target.value)), children: pageSizeOptions.map((s) => (_jsxs("option", { value: s, children: [s, " / page"] }, s))) })] }))] })] }));
152
+ }) }), _jsxs("div", { className: "tui-pager__info", children: [_jsx("span", { children: labels.pageStatus(current, total) }), showPageSizeSelector && (_jsxs("label", { className: "tui-pager__page-size-label", children: [_jsx("span", { className: "tui-visually-hidden", children: labels.itemsPerPage }), _jsx("select", { className: "tui-input", value: pageSize, onChange: (e) => onPageSizeChange(Number(e.target.value)), children: pageSizeOptions.map((s) => (_jsx("option", { value: s, children: labels.perPage(s) }, s))) })] }))] })] }));
151
153
  }
@@ -1,2 +1,4 @@
1
1
  export { Pager } from './Pager';
2
2
  export type { PagerProps, PagerMode } from './Pager';
3
+ export { defaultPagerLabels } from './types';
4
+ export type { PagerLabels } from './types';
@@ -1 +1,2 @@
1
1
  export { Pager } from './Pager.js';
2
+ export { defaultPagerLabels } from './types.js';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Overridable label strings for i18n.
3
+ *
4
+ * Static strings use plain `string`, dynamic strings use function signatures.
5
+ * All keys are optional — defaults are English.
6
+ */
7
+ export type PagerLabels = {
8
+ /** Label for previous page button (aria-label on icon style, aria-label on text style).
9
+ * @default "Previous page"
10
+ */
11
+ previous?: string;
12
+ /** Label for next page button.
13
+ * @default "Next page"
14
+ */
15
+ next?: string;
16
+ /** Label for individual page buttons.
17
+ * @default (n) => `Page ${n}`
18
+ */
19
+ page?: (n: number) => string;
20
+ /** aria-label for the nav landmark.
21
+ * @default "Pagination"
22
+ */
23
+ navigation?: string;
24
+ /** "Page X of Y" status text.
25
+ * @default (current, total) => `Page ${current} of ${total}`
26
+ */
27
+ pageStatus?: (current: number, total: number) => string;
28
+ /** Visually-hidden label for the page size selector.
29
+ * @default "Items per page"
30
+ */
31
+ itemsPerPage?: string;
32
+ /** Option text in the page size selector.
33
+ * @default (n) => `${n} / page`
34
+ */
35
+ perPage?: (n: number) => string;
36
+ };
37
+ export declare const defaultPagerLabels: Required<PagerLabels>;
@@ -0,0 +1,12 @@
1
+ // =============================================================================
2
+ // Pager Types
3
+ // =============================================================================
4
+ export const defaultPagerLabels = {
5
+ previous: 'Previous page',
6
+ next: 'Next page',
7
+ page: (n) => `Page ${n}`,
8
+ navigation: 'Pagination',
9
+ pageStatus: (current, total) => `Page ${current} of ${total}`,
10
+ itemsPerPage: 'Items per page',
11
+ perPage: (n) => `${n} / page`,
12
+ };
@@ -1,32 +1,2 @@
1
- import type { SizeStandard, Theme as ThemeFull } from '../../types';
2
- type Size = SizeStandard;
3
- type Theme = ThemeFull;
4
- export type RatingProps = {
5
- /** Controlled value (1..max). Use with onValueChange */
6
- value?: number;
7
- /** Uncontrolled initial value */
8
- defaultValue?: number;
9
- /** Maximum icons shown */
10
- max?: number;
11
- /** Disable interaction (keeps semantics) */
12
- disabled?: boolean;
13
- /** Presentational readOnly (no form semantics) */
14
- readOnly?: boolean;
15
- /** Name for the radio group (if you care about form posts) */
16
- name?: string;
17
- /** Size maps to icon + spacing */
18
- size?: Size;
19
- /** Theme feeds foreground color tokens */
20
- theme?: Theme;
21
- /** Called when the value changes */
22
- onValueChange?: (value: number) => void;
23
- /** Allow clicking the current selection to clear back to 0 */
24
- allowClear?: boolean;
25
- className?: string;
26
- /** Gap override (e.g. '0.25rem') – otherwise uses density utilities */
27
- gap?: string;
28
- /** Accessible label for the rating group. Defaults to "Rating: X of Y" */
29
- 'aria-label'?: string;
30
- };
31
- export declare function Rating({ value, defaultValue, max, disabled, readOnly, name, size, theme, onValueChange, allowClear, className, gap, 'aria-label': ariaLabel, }: RatingProps): import("react/jsx-runtime").JSX.Element;
32
- export {};
1
+ import type { RatingProps } from './types';
2
+ export declare function Rating({ value, defaultValue, max, disabled, readOnly, name, size, theme, onValueChange, allowClear, className, gap, 'aria-label': ariaLabel, labels: labelsProp, }: RatingProps): import("react/jsx-runtime").JSX.Element;
@@ -2,10 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { Icon } from '../Icon/index.js';
5
- export function Rating({ value, defaultValue = 0, max = 5, disabled, readOnly, name, size = 'lg', theme = 'secondary', onValueChange, allowClear, className, gap, 'aria-label': ariaLabel, }) {
5
+ import { defaultRatingLabels } from './types.js';
6
+ export function Rating({ value, defaultValue = 0, max = 5, disabled, readOnly, name, size = 'lg', theme = 'secondary', onValueChange, allowClear, className, gap, 'aria-label': ariaLabel, labels: labelsProp, }) {
6
7
  const isControlled = value != null;
7
8
  const [internal, setInternal] = React.useState(defaultValue);
8
9
  const current = isControlled ? value : internal;
10
+ const labels = { ...defaultRatingLabels, ...labelsProp };
9
11
  const generatedId = React.useId();
10
12
  const groupName = name ?? generatedId;
11
13
  const setValue = (v) => {
@@ -62,13 +64,13 @@ export function Rating({ value, defaultValue = 0, max = 5, disabled, readOnly, n
62
64
  return;
63
65
  }
64
66
  };
65
- const defaultAriaLabel = `Rating: ${current} of ${max}`;
67
+ const defaultAriaLabel = labels.rating(current, max);
66
68
  // Roving tabindex: only the selected radio (or first if none) is in tab order
67
69
  const focusableIndex = current > 0 ? current : 1;
68
70
  return (_jsx("div", { className: cx('tui-rating', `is-size-${size}`, `is-theme-${theme}`, disabled && 'is-disabled', className), style: { gap }, role: readOnly ? 'img' : 'radiogroup', "aria-label": ariaLabel ?? defaultAriaLabel, onKeyDown: readOnly ? undefined : onKeyDown, children: Array.from({ length: max }).map((_, i) => {
69
71
  const n = i + 1;
70
72
  const checked = current >= n;
71
73
  const id = `${groupName}__star-${n}`;
72
- return (_jsxs("div", { className: "tui-rating__item", children: [!readOnly && (_jsx("input", { className: "tui-visually-hidden", type: "radio", id: id, name: groupName, value: n, checked: current === n, "aria-checked": current === n, tabIndex: n === focusableIndex ? 0 : -1, onChange: () => handleSelect(n), disabled: disabled })), readOnly ? (_jsx("span", { className: cx('tui-rating__star', checked && 'is-active'), children: _jsx(Icon, { name: checked ? 'system/star-fill' : 'system/star-outline' }) })) : (_jsxs("label", { className: cx('tui-rating__star', checked && 'is-active'), htmlFor: id, tabIndex: -1, children: [_jsx(Icon, { name: checked ? 'system/star-fill' : 'system/star-outline' }), _jsx("span", { className: "tui-visually-hidden", children: `${n} of ${max}` })] }))] }, n));
74
+ return (_jsxs("div", { className: "tui-rating__item", children: [!readOnly && (_jsx("input", { className: "tui-visually-hidden", type: "radio", id: id, name: groupName, value: n, checked: current === n, "aria-checked": current === n, tabIndex: n === focusableIndex ? 0 : -1, onChange: () => handleSelect(n), disabled: disabled })), readOnly ? (_jsx("span", { className: cx('tui-rating__star', checked && 'is-active'), children: _jsx(Icon, { name: checked ? 'system/star-fill' : 'system/star-outline' }) })) : (_jsxs("label", { className: cx('tui-rating__star', checked && 'is-active'), htmlFor: id, tabIndex: -1, children: [_jsx(Icon, { name: checked ? 'system/star-fill' : 'system/star-outline' }), _jsx("span", { className: "tui-visually-hidden", children: labels.value(n, max) })] }))] }, n));
73
75
  }) }));
74
76
  }
@@ -1,2 +1,3 @@
1
1
  export { Rating } from './Rating';
2
- export type { RatingProps } from './Rating';
2
+ export type { RatingProps, RatingLabels } from './types';
3
+ export { defaultRatingLabels } from './types';
@@ -1 +1,2 @@
1
1
  export { Rating } from './Rating.js';
2
+ export { defaultRatingLabels } from './types.js';