@tangible/ui 0.0.7 → 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.
Files changed (103) hide show
  1. package/components/Accordion/Accordion.js +11 -3
  2. package/components/Avatar/Avatar.d.ts +1 -1
  3. package/components/Avatar/Avatar.js +5 -4
  4. package/components/Avatar/AvatarGroup.js +7 -5
  5. package/components/Avatar/index.d.ts +2 -2
  6. package/components/Avatar/index.js +1 -1
  7. package/components/Avatar/types.d.ts +27 -0
  8. package/components/Avatar/types.js +8 -0
  9. package/components/Button/Button.js +4 -2
  10. package/components/Button/index.d.ts +2 -1
  11. package/components/Button/index.js +1 -0
  12. package/components/Button/types.d.ts +10 -0
  13. package/components/Button/types.js +3 -1
  14. package/components/Checkbox/Checkbox.js +46 -11
  15. package/components/Checkbox/types.d.ts +9 -0
  16. package/components/Combobox/Combobox.d.ts +1 -1
  17. package/components/Combobox/Combobox.js +50 -7
  18. package/components/Combobox/index.d.ts +2 -1
  19. package/components/Combobox/index.js +1 -0
  20. package/components/Combobox/types.d.ts +9 -0
  21. package/components/Combobox/types.js +3 -1
  22. package/components/Dropdown/Dropdown.d.ts +1 -1
  23. package/components/Dropdown/Dropdown.js +32 -12
  24. package/components/Field/Field.d.ts +4 -1
  25. package/components/Field/Field.js +35 -14
  26. package/components/Field/FieldContext.d.ts +16 -0
  27. package/components/Field/FieldContext.js +3 -0
  28. package/components/Field/index.d.ts +2 -1
  29. package/components/Field/index.js +1 -0
  30. package/components/Icon/Icon.d.ts +1 -1
  31. package/components/Icon/Icon.js +2 -2
  32. package/components/Modal/Modal.d.ts +5 -1
  33. package/components/Modal/Modal.js +2 -2
  34. package/components/MoveHandle/MoveHandle.d.ts +1 -1
  35. package/components/MoveHandle/MoveHandle.js +4 -4
  36. package/components/MoveHandle/types.d.ts +1 -1
  37. package/components/MultiSelect/MultiSelect.d.ts +1 -1
  38. package/components/MultiSelect/MultiSelect.js +58 -19
  39. package/components/MultiSelect/index.d.ts +2 -1
  40. package/components/MultiSelect/index.js +1 -0
  41. package/components/MultiSelect/types.d.ts +34 -0
  42. package/components/MultiSelect/types.js +10 -0
  43. package/components/Pager/Pager.d.ts +7 -1
  44. package/components/Pager/Pager.js +7 -5
  45. package/components/Pager/index.d.ts +2 -0
  46. package/components/Pager/index.js +1 -0
  47. package/components/Pager/types.d.ts +37 -0
  48. package/components/Pager/types.js +12 -0
  49. package/components/Progress/Progress.d.ts +2 -1
  50. package/components/Progress/Progress.js +3 -3
  51. package/components/Rating/Rating.d.ts +2 -32
  52. package/components/Rating/Rating.js +5 -3
  53. package/components/Rating/index.d.ts +2 -1
  54. package/components/Rating/index.js +1 -0
  55. package/components/Rating/types.d.ts +41 -0
  56. package/components/Rating/types.js +4 -0
  57. package/components/SegmentedControl/SegmentedControl.js +6 -5
  58. package/components/SegmentedControl/types.d.ts +17 -5
  59. package/components/Select/Select.d.ts +1 -0
  60. package/components/Select/Select.js +131 -77
  61. package/components/Select/SelectContext.d.ts +4 -16
  62. package/components/Select/SelectContext.js +5 -35
  63. package/components/Select/types.d.ts +19 -19
  64. package/components/Sidebar/Sidebar.js +25 -20
  65. package/components/StepIndicator/StepIndicator.d.ts +1 -1
  66. package/components/StepIndicator/StepIndicator.js +14 -10
  67. package/components/StepIndicator/index.d.ts +2 -1
  68. package/components/StepIndicator/index.js +1 -0
  69. package/components/StepIndicator/types.d.ts +18 -0
  70. package/components/StepIndicator/types.js +7 -1
  71. package/components/Table/BulkActionsBar.d.ts +4 -1
  72. package/components/Table/BulkActionsBar.js +5 -4
  73. package/components/Table/DataTable.d.ts +4 -1
  74. package/components/Table/DataTable.js +10 -8
  75. package/components/Table/index.d.ts +3 -0
  76. package/components/Table/index.js +2 -0
  77. package/components/Table/types.d.ts +20 -0
  78. package/components/Table/types.js +11 -0
  79. package/components/Tabs/Tabs.js +11 -4
  80. package/components/TextInput/TextInput.js +2 -1
  81. package/components/TextInput/types.d.ts +7 -1
  82. package/components/Textarea/Textarea.js +3 -2
  83. package/components/Textarea/types.d.ts +6 -1
  84. package/components/Tooltip/Tooltip.d.ts +1 -1
  85. package/components/Tooltip/Tooltip.js +16 -10
  86. package/icons/icons.svg +29 -15
  87. package/icons/lms/index.d.ts +8 -0
  88. package/icons/lms/index.js +48 -4
  89. package/icons/manifest.json +112 -0
  90. package/icons/player/index.js +9 -9
  91. package/icons/registry.d.ts +28 -0
  92. package/icons/registry.js +14 -0
  93. package/icons/system/index.d.ts +20 -0
  94. package/icons/system/index.js +112 -2
  95. package/package.json +1 -1
  96. package/styles/all.css +1 -1
  97. package/styles/all.expanded.css +266 -59
  98. package/styles/all.expanded.unlayered.css +266 -59
  99. package/styles/all.unlayered.css +1 -1
  100. package/styles/components/input/index.scss +29 -7
  101. package/styles/system/_constants.scss +1 -1
  102. package/styles/system/_tokens.scss +1 -0
  103. package/tui-manifest.json +78 -52
@@ -246,10 +246,18 @@ function AccordionTrigger({ asChild = false, 'aria-label': ariaLabel, children,
246
246
  // =============================================================================
247
247
  function AccordionPanel({ landmark = false, children, className }) {
248
248
  const { triggerId, panelId, isOpen } = useAccordionItemContext();
249
+ const panelRef = useRef(null);
249
250
  const state = isOpen ? 'open' : 'closed';
250
- return (_jsx("div", { id: panelId, role: landmark ? 'region' : undefined, "aria-labelledby": landmark ? triggerId : undefined, className: cx('tui-accordion__panel', className), "data-state": state, "aria-hidden": !isOpen,
251
- // Prevent keyboard focus into collapsed panels
252
- inert: !isOpen || undefined, children: _jsx("div", { className: "tui-accordion__panel-content", children: children }) }));
251
+ // Set inert via DOM property for React 18 compatibility.
252
+ // React 18 doesn't handle `inert` as a boolean prop — it renders it as a
253
+ // string attribute and may not reliably remove it on re-renders.
254
+ // Setting the DOM property directly works across React 18 and 19.
255
+ React.useEffect(() => {
256
+ if (panelRef.current) {
257
+ panelRef.current.inert = !isOpen;
258
+ }
259
+ }, [isOpen]);
260
+ return (_jsx("div", { ref: panelRef, id: panelId, role: landmark ? 'region' : undefined, "aria-labelledby": landmark ? triggerId : undefined, className: cx('tui-accordion__panel', className), "data-state": state, "aria-hidden": !isOpen, children: _jsx("div", { className: "tui-accordion__panel-content", children: children }) }));
253
261
  }
254
262
  // =============================================================================
255
263
  // Export Compound Component
@@ -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>>;
@@ -3,7 +3,7 @@ import React, { useState, useMemo } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { Icon } from '../Icon/index.js';
5
5
  import { Tooltip } from '../Tooltip/index.js';
6
- import { AVATAR_COLORS } from './types.js';
6
+ import { AVATAR_COLORS, defaultAvatarLabels } from './types.js';
7
7
  /**
8
8
  * Generate initials from a name.
9
9
  * "Mary Ghen" → "MG", "Bob" → "B", "Jean-Luc Picard" → "JP"
@@ -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, 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(() => {
@@ -54,6 +54,7 @@ export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circl
54
54
  }, [src]);
55
55
  const initials = useMemo(() => (name ? getInitials(name) : ''), [name]);
56
56
  const derivedColor = useMemo(() => color ?? (name ? getColorFromName(name, AVATAR_COLORS) : 'slate'), [color, name]);
57
+ const labels = useMemo(() => ({ ...defaultAvatarLabels, ...labelsProp }), [labelsProp]);
57
58
  const showImage = src && !imgError;
58
59
  const showInitials = !showImage && initials;
59
60
  const showPlaceholder = !showImage && !initials;
@@ -65,12 +66,12 @@ export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circl
65
66
  : {
66
67
  role: 'img',
67
68
  'aria-label': name && indicatorLabel
68
- ? `${name}, ${indicatorLabel}`
69
+ ? labels.description(name, indicatorLabel)
69
70
  : name || indicatorLabel,
70
71
  }), children: [_jsxs("span", { className: "tui-avatar__content", children: [showImage && (_jsx("img", { src: src, alt: "", className: "tui-avatar__image", onError: () => setImgError(true) })), showInitials && (_jsx("span", { className: "tui-avatar__initials", "aria-hidden": "true", children: initials })), showPlaceholder && (_jsx(Icon, { name: "system/user-circle-outline", className: "tui-avatar__placeholder" }))] }), indicator && (_jsx("span", { className: cx('tui-avatar__indicator', `is-position-${indicatorPosition}`), "aria-hidden": "true", children: indicator }))] }));
71
72
  if (showTooltip) {
72
73
  return (_jsxs(Tooltip, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: avatarElement }), _jsx(Tooltip.Content, { "aria-hidden": "true", children: name })] }));
73
74
  }
74
75
  return avatarElement;
75
- });
76
+ }));
76
77
  Avatar.displayName = 'Avatar';
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { Children, isValidElement, cloneElement } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { OverlapStack } from '../OverlapStack/index.js';
5
+ import { defaultAvatarGroupLabels } from './types.js';
5
6
  /**
6
7
  * AvatarGroup displays multiple avatars with optional overlap.
7
8
  *
@@ -10,7 +11,8 @@ import { OverlapStack } from '../OverlapStack/index.js';
10
11
  * - Non-overlap mode uses flex with gap
11
12
  * - Override overlap amount via `--tui-avatar-group-overlap` CSS property
12
13
  */
13
- export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true, groupLabel: groupLabelFn, children, className }, ref) => {
14
+ export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true, groupLabel: groupLabelFn, labels: labelsProp, children, className }, ref) => {
15
+ const labels = { ...defaultAvatarGroupLabels, ...labelsProp };
14
16
  const childArray = Children.toArray(children).filter(isValidElement);
15
17
  const total = childArray.length;
16
18
  // Clone children to inject size/shape props if provided at group level
@@ -23,14 +25,14 @@ export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true,
23
25
  }
24
26
  return child;
25
27
  });
26
- // Descriptive label for the group
28
+ // Descriptive label for the group — groupLabel prop takes precedence over labels bag
27
29
  const hasOverflow = max !== undefined && total > max;
28
30
  const visibleCount = hasOverflow ? max : total;
29
31
  const groupLabel = groupLabelFn
30
32
  ? groupLabelFn(total, visibleCount)
31
33
  : hasOverflow
32
- ? `${total} users, showing ${visibleCount}`
33
- : `${total} users`;
34
+ ? labels.group(total, visibleCount)
35
+ : labels.groupAll(total);
34
36
  // Non-overlap mode: simple flex layout
35
37
  if (!overlap) {
36
38
  return (_jsx("div", { ref: ref, className: cx('tui-avatar-group', className), role: "group", "aria-label": groupLabel, children: clonedChildren }));
@@ -39,7 +41,7 @@ export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true,
39
41
  // Note: We don't use OverlapStack's `frame` prop because its border-radius
40
42
  // wouldn't match each avatar's shape. Instead, we apply ring styles via CSS
41
43
  // directly to avatars inside .is-overlap groups.
42
- return (_jsx(OverlapStack, { ref: ref, className: cx('tui-avatar-group', 'is-overlap', className), max: max, renderOverflow: (count) => (_jsx(AvatarOverflow, { count: count, size: size, shape: shape })), overflowLabel: (count) => `${count} more users`, "aria-label": groupLabel, children: clonedChildren }));
44
+ return (_jsx(OverlapStack, { ref: ref, className: cx('tui-avatar-group', 'is-overlap', className), max: max, renderOverflow: (count) => (_jsx(AvatarOverflow, { count: count, size: size, shape: shape })), overflowLabel: labels.overflow, "aria-label": groupLabel, children: clonedChildren }));
43
45
  });
44
46
  AvatarGroup.displayName = 'AvatarGroup';
45
47
  function AvatarOverflow({ count, size, shape }) {
@@ -1,7 +1,7 @@
1
1
  import { Avatar as AvatarBase } from './Avatar';
2
2
  import { AvatarGroup } from './AvatarGroup';
3
- export type { AvatarProps, AvatarGroupProps, AvatarSize, AvatarShape, AvatarColor, IndicatorPosition, } from './types';
4
- export { AVATAR_COLORS } from './types';
3
+ export type { AvatarProps, AvatarGroupProps, AvatarGroupLabels, AvatarLabels, AvatarSize, AvatarShape, AvatarColor, IndicatorPosition, } from './types';
4
+ export { AVATAR_COLORS, defaultAvatarGroupLabels, defaultAvatarLabels } from './types';
5
5
  type AvatarCompound = typeof AvatarBase & {
6
6
  Group: typeof AvatarGroup;
7
7
  };
@@ -1,6 +1,6 @@
1
1
  import { Avatar as AvatarBase } from './Avatar.js';
2
2
  import { AvatarGroup } from './AvatarGroup.js';
3
- export { AVATAR_COLORS } from './types.js';
3
+ export { AVATAR_COLORS, defaultAvatarGroupLabels, defaultAvatarLabels } from './types.js';
4
4
  export const Avatar = AvatarBase;
5
5
  Avatar.Group = AvatarGroup;
6
6
  // Named export for direct import
@@ -8,6 +8,14 @@ export type IndicatorPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bott
8
8
  */
9
9
  export type AvatarColor = 'coral' | 'amber' | 'lime' | 'teal' | 'cyan' | 'blue' | 'violet' | 'pink' | 'slate' | 'emerald';
10
10
  export declare const AVATAR_COLORS: AvatarColor[];
11
+ /**
12
+ * Overridable strings for Avatar i18n.
13
+ */
14
+ export type AvatarLabels = {
15
+ /** Accessible label when both name and indicator are present. @default `"${name}, ${indicatorLabel}"` */
16
+ description?: (name: string, indicatorLabel: string) => string;
17
+ };
18
+ export declare const defaultAvatarLabels: Required<AvatarLabels>;
11
19
  export type AvatarProps = {
12
20
  /** Image source URL */
13
21
  src?: string;
@@ -31,9 +39,23 @@ export type AvatarProps = {
31
39
  * Has no effect when `name` is not provided.
32
40
  */
33
41
  tooltip?: boolean;
42
+ /** Overridable strings for i18n. */
43
+ labels?: AvatarLabels;
34
44
  /** Additional CSS class */
35
45
  className?: string;
36
46
  };
47
+ /**
48
+ * Overridable strings for AvatarGroup i18n.
49
+ */
50
+ export type AvatarGroupLabels = {
51
+ /** Label when some avatars are hidden. Default: `"${total} users, showing ${visible}"` */
52
+ group?: (total: number, visible: number) => string;
53
+ /** Label when all avatars are visible. Default: `"${total} users"` */
54
+ groupAll?: (total: number) => string;
55
+ /** Overflow badge label (AT-only). Default: `"${count} more users"` */
56
+ overflow?: (count: number) => string;
57
+ };
58
+ export declare const defaultAvatarGroupLabels: Required<AvatarGroupLabels>;
37
59
  export type AvatarGroupProps = {
38
60
  /** Maximum avatars to show before "+N" overflow */
39
61
  max?: number;
@@ -48,6 +70,11 @@ export type AvatarGroupProps = {
48
70
  * Default: "N users" or "N users, showing M".
49
71
  */
50
72
  groupLabel?: (total: number, visible: number) => string;
73
+ /**
74
+ * Overridable strings for i18n. Coexists with `groupLabel` —
75
+ * `groupLabel` takes precedence for the group aria-label when provided.
76
+ */
77
+ labels?: AvatarGroupLabels;
51
78
  /** Children (Avatar components) */
52
79
  children: React.ReactNode;
53
80
  /** Additional CSS class */
@@ -10,3 +10,11 @@ export const AVATAR_COLORS = [
10
10
  'slate',
11
11
  'emerald',
12
12
  ];
13
+ export const defaultAvatarLabels = {
14
+ description: (name, indicatorLabel) => `${name}, ${indicatorLabel}`,
15
+ };
16
+ export const defaultAvatarGroupLabels = {
17
+ group: (total, visible) => `${total} users, showing ${visible}`,
18
+ groupAll: (total) => `${total} users`,
19
+ overflow: (count) => `${count} more users`,
20
+ };
@@ -3,9 +3,11 @@ import React, { forwardRef } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { Icon } from '../Icon/index.js';
5
5
  import { getSafeRel } from '../../utils/polymorphic.js';
6
- export const Button = forwardRef(({ label, children, size = 'md', theme = 'primary', variant = 'solid', fullWidth, disabled = false, loading = false, loadingLabel: loadingLabelProp, leftIconName, rightIconName, leftIcon, rightIcon, iconSize: iconSizeProp, className, target, rel, onClick, style, ...rest }, ref) => {
6
+ import { defaultButtonLabels } from './types.js';
7
+ export const Button = forwardRef(({ label, children, size = 'md', theme = 'primary', variant = 'solid', fullWidth, disabled = false, loading = false, loadingLabel: loadingLabelProp, labels: labelsProp, leftIconName, rightIconName, leftIcon, rightIcon, iconSize: iconSizeProp, className, target, rel, onClick, style, ...rest }, ref) => {
7
8
  const isLink = typeof rest.href === 'string';
8
9
  const isDisabled = disabled || loading;
10
+ const labels = { ...defaultButtonLabels, ...labelsProp };
9
11
  // Auto-scale icon size with button size when not explicitly set
10
12
  const iconSizeMap = { xs: 'xs', sm: 'xs', md: 'sm', lg: 'md' };
11
13
  const iconSize = iconSizeProp ?? iconSizeMap[size];
@@ -32,7 +34,7 @@ export const Button = forwardRef(({ label, children, size = 'md', theme = 'prima
32
34
  }
33
35
  onClick?.(e);
34
36
  };
35
- return (_jsxs("a", { ref: ref, href: isDisabled ? undefined : href, className: classes, "aria-label": loadingLabel, "aria-disabled": isDisabled || undefined, "aria-busy": loading || undefined, tabIndex: isDisabled ? -1 : tabIndex, onClick: handleClick, "data-loading": loading || undefined, target: target, rel: safeRel, style: style, ...anchorRest, children: [content, target === '_blank' && (_jsx("span", { className: "tui-visually-hidden", children: " (opens in new tab)" }))] }));
37
+ return (_jsxs("a", { ref: ref, href: isDisabled ? undefined : href, className: classes, "aria-label": loadingLabel, "aria-disabled": isDisabled || undefined, "aria-busy": loading || undefined, tabIndex: isDisabled ? -1 : tabIndex, onClick: handleClick, "data-loading": loading || undefined, target: target, rel: safeRel, style: style, ...anchorRest, children: [content, target === '_blank' && (_jsx("span", { className: "tui-visually-hidden", children: labels.newTab }))] }));
36
38
  }
37
39
  const buttonRest = rest;
38
40
  return (_jsx("button", { ref: ref, type: buttonRest.type ?? 'button', className: classes, "aria-label": loadingLabel, disabled: isDisabled, "aria-busy": loading || undefined, onClick: onClick, "data-loading": loading || undefined, style: style, ...buttonRest, children: content }));
@@ -1,2 +1,3 @@
1
1
  export { Button } from './Button';
2
- export type { ButtonProps } from './Button';
2
+ export type { ButtonProps, ButtonLabels } from './types';
3
+ export { defaultButtonLabels } from './types';
@@ -1 +1,2 @@
1
1
  export { Button } from './Button.js';
2
+ export { defaultButtonLabels } from './types.js';
@@ -19,6 +19,11 @@ export type Theme = ThemeIntent | 'destructive';
19
19
  * - `'link'`: Text-link styling, no background
20
20
  */
21
21
  export type Variant = 'solid' | 'outline' | 'ghost' | 'link';
22
+ export type ButtonLabels = {
23
+ /** Label for the "(opens in new tab)" visually-hidden hint on external links. */
24
+ newTab?: string;
25
+ };
26
+ export declare const defaultButtonLabels: Required<ButtonLabels>;
22
27
  type CommonProps = {
23
28
  /**
24
29
  * Button label text. If provided, renders as the button content.
@@ -68,6 +73,11 @@ type CommonProps = {
68
73
  * @default `${label}, loading` (English)
69
74
  */
70
75
  loadingLabel?: string;
76
+ /**
77
+ * Override default English strings for i18n.
78
+ * Covers strings not already handled by existing props (e.g. `loadingLabel`).
79
+ */
80
+ labels?: ButtonLabels;
71
81
  /**
72
82
  * Link target (for anchor variant).
73
83
  */
@@ -1 +1,3 @@
1
- export {};
1
+ export const defaultButtonLabels = {
2
+ newTab: ' (opens in new tab)',
3
+ };
@@ -8,13 +8,21 @@ import { isDev } from '../../utils/is-dev.js';
8
8
  // Checkbox Component
9
9
  // =============================================================================
10
10
  //
11
- // Native <input type="checkbox"> with accent-color, reusing tui-inline-choice CSS.
11
+ // Custom <input type="checkbox"> with appearance: none, SVG checkmark/indeterminate
12
+ // icons via background-image, and token-driven colors.
12
13
  //
13
14
  // Bare (no label): returns <input> directly for Field.Control cloneElement.
14
15
  // With label: wraps in <label class="tui-inline-choice">.
15
16
  //
16
- // CSS token API (inherited from input styles):
17
- // --tui-input-accent Accent color for checked state
17
+ // CSS token API (component layer, read via fallback):
18
+ // --tui-checkbox-accent Accent color --tui-input-accent → --tui-theme-primary-base
19
+ // --tui-checkbox-border Border color → --tui-color-border
20
+ // --tui-checkbox-border-invalid Invalid border → --tui-theme-danger-base
21
+ // --tui-checkbox-radius Border radius → --tui-radius-sm
22
+ // --tui-checkbox-bg Background → --tui-color-bg
23
+ // --tui-checkbox-size Font-size (controls box size via em units)
24
+ // --tui-checkbox-gap Gap between checkbox and label (labeled mode)
25
+ // --tui-checkbox-label-color Label text colour (labeled mode)
18
26
  //
19
27
  // =============================================================================
20
28
  // Props that should route to the <input>, not the wrapper label
@@ -23,6 +31,7 @@ const INPUT_PROPS = new Set([
23
31
  'name',
24
32
  'value',
25
33
  'aria-describedby',
34
+ 'aria-errormessage',
26
35
  'aria-invalid',
27
36
  'aria-required',
28
37
  'aria-label',
@@ -31,6 +40,9 @@ const INPUT_PROPS = new Set([
31
40
  'required',
32
41
  'tabIndex',
33
42
  'autoFocus',
43
+ 'onClick',
44
+ 'onKeyDown',
45
+ 'onKeyUp',
34
46
  'onFocus',
35
47
  'onBlur',
36
48
  ]);
@@ -67,16 +79,21 @@ export const Checkbox = forwardRef(function Checkbox({ checked: controlledChecke
67
79
  }
68
80
  // eslint-disable-next-line react-hooks/exhaustive-deps
69
81
  }, []);
70
- const handleChange = () => {
71
- // Clicking an indeterminate checkbox produces checked=true (native behaviour)
72
- setChecked((prev) => !prev);
82
+ const handleChange = (e) => {
83
+ // Use native checked value — clicking an indeterminate checkbox produces
84
+ // checked=true, which !prev would incorrectly invert to false.
85
+ setChecked(e.target.checked);
73
86
  };
74
87
  const isChecked = checked ?? false;
75
- // Bare: no labelField.Control can inject id/aria-* directly
88
+ // Shared input elementsingle source of truth for both render paths.
89
+ // In labeled mode, extraProps contains only INPUT_PROPS; in bare mode,
90
+ // all rest props go directly on the input.
91
+ const renderInput = (extraProps, extraClass) => (_jsx("input", { ref: composeRefs(internalRef, externalRef), type: "checkbox", checked: isChecked, disabled: disabled, className: extraClass, "aria-checked": indeterminate && !isChecked ? 'mixed' : undefined, onChange: handleChange, ...extraProps }));
92
+ // Bare: no label — Field.Control can inject id/aria-* via cloneElement
76
93
  if (!label) {
77
- return (_jsx("input", { ref: composeRefs(internalRef, externalRef), type: "checkbox", checked: isChecked, disabled: disabled, className: className, "data-indeterminate": indeterminate || undefined, "aria-checked": indeterminate ? 'mixed' : undefined, onChange: handleChange, ...rest }));
94
+ return renderInput(rest, className);
78
95
  }
79
- // Split rest props: some go on input, some on wrapper
96
+ // Labeled: split rest props between input and wrapper
80
97
  const inputProps = {};
81
98
  const wrapperProps = {};
82
99
  for (const [key, val] of Object.entries(rest)) {
@@ -87,6 +104,24 @@ export const Checkbox = forwardRef(function Checkbox({ checked: controlledChecke
87
104
  wrapperProps[key] = val;
88
105
  }
89
106
  }
90
- // With label: wrap in tui-inline-choice
91
- return (_jsxs("label", { className: cx('tui-inline-choice', disabled && 'is-disabled', className), ...wrapperProps, children: [_jsx("input", { ref: composeRefs(internalRef, externalRef), type: "checkbox", checked: isChecked, disabled: disabled, "data-indeterminate": indeterminate || undefined, "aria-checked": indeterminate ? 'mixed' : undefined, onChange: handleChange, ...inputProps }), _jsx("span", { children: label })] }));
107
+ // DEV: warn if input-like props leaked to the wrapper
108
+ if (isDev()) {
109
+ const suspect = Object.keys(wrapperProps).filter((k) => k.startsWith('aria-') || k.startsWith('on') || k === 'tabIndex');
110
+ if (suspect.length > 0) {
111
+ console.warn(`Checkbox: Props [${suspect.join(', ')}] ended up on the <label> wrapper. ` +
112
+ 'Add them to INPUT_PROPS if they belong on the <input>.');
113
+ }
114
+ // Warn about dual-labelling conflicts
115
+ if (inputProps['aria-labelledby']) {
116
+ console.warn('Checkbox: Both `label` prop and `aria-labelledby` are present. ' +
117
+ '`aria-labelledby` takes precedence — the `label` prop text will not be the accessible name. ' +
118
+ 'Prefer one labelling mechanism.');
119
+ }
120
+ if (inputProps['aria-label']) {
121
+ console.warn('Checkbox: Both `label` prop and `aria-label` are present. ' +
122
+ '`aria-label` takes precedence — the visible label text will not be the accessible name.');
123
+ }
124
+ }
125
+ return (_jsxs("label", { className: cx('tui-inline-choice', disabled && 'is-disabled', className), ...wrapperProps, children: [renderInput(inputProps), _jsx("span", { children: label })] }));
92
126
  });
127
+ Checkbox.displayName = 'Checkbox';
@@ -4,6 +4,15 @@ export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' |
4
4
  defaultChecked?: boolean;
5
5
  onCheckedChange?: (checked: boolean) => void;
6
6
  indeterminate?: boolean;
7
+ /**
8
+ * When provided, renders the checkbox inside a `<label>` with this content.
9
+ * When omitted, renders a bare `<input>` — you must provide an accessible name
10
+ * via `aria-label`, `aria-labelledby`, or a wrapping `Field.Control` + `Field.Label`.
11
+ *
12
+ * @remarks When used inside `Field.Control`, `Field.Label` injects `aria-labelledby`
13
+ * onto the input. If both `label` and `Field.Label` are present, AT may concatenate
14
+ * both names. Prefer one labelling mechanism — either `label` prop or `Field.Label`.
15
+ */
7
16
  label?: ReactNode;
8
17
  disabled?: boolean;
9
18
  className?: string;
@@ -1,5 +1,5 @@
1
1
  import type { ComboboxProps, ComboboxContentProps, ComboboxOptionProps, ComboboxGroupProps, ComboboxLabelProps } from './types';
2
- declare function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inputValue: controlledInputValue, onInputChange, open: controlledOpen, defaultOpen, onOpenChange, disabled, placeholder, size, openOnFocus, filterMode, onQueryChange, clearable, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, inputClassName, children, }: ComboboxProps): import("react/jsx-runtime").JSX.Element;
2
+ declare function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inputValue: controlledInputValue, onInputChange, open: controlledOpen, defaultOpen, onOpenChange, disabled, placeholder, size, openOnFocus, filterMode, onQueryChange, clearable, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, inputClassName, labels: labelsProp, children, }: ComboboxProps): import("react/jsx-runtime").JSX.Element;
3
3
  declare namespace ComboboxRoot {
4
4
  var displayName: string;
5
5
  }
@@ -9,13 +9,15 @@ import { hashForId } from '../../utils/hash.js';
9
9
  import { composeEventHandlers } from '../../utils/compose-events.js';
10
10
  import { Icon } from '../Icon/index.js';
11
11
  import { ComboboxActionsContext, ComboboxStateContext, ComboboxContentContext, useComboboxContext, useComboboxContentContext, } from './ComboboxContext.js';
12
+ import { defaultComboboxLabels } from './types.js';
12
13
  // =============================================================================
13
14
  // Combobox Root
14
15
  // =============================================================================
15
- function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inputValue: controlledInputValue, onInputChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', openOnFocus = true, filterMode = 'always', onQueryChange, clearable = true, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, inputClassName, children, }) {
16
+ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inputValue: controlledInputValue, onInputChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', openOnFocus = true, filterMode = 'always', onQueryChange, clearable = true, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, inputClassName, labels: labelsProp, children, }) {
17
+ const labels = { ...defaultComboboxLabels, ...labelsProp };
16
18
  // Controlled/uncontrolled value (initialize from defaultValue)
17
19
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
18
- const isValueControlled = controlledValue !== undefined;
20
+ const isValueControlled = useRef(controlledValue !== undefined).current;
19
21
  const value = isValueControlled ? controlledValue : uncontrolledValue;
20
22
  // Controlled/uncontrolled inputValue
21
23
  const [uncontrolledInputValue, setUncontrolledInputValue] = useState('');
@@ -41,6 +43,9 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
41
43
  // Option registration
42
44
  const optionsRef = useRef(new Map());
43
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;
44
49
  // IDs
45
50
  const baseId = useId();
46
51
  const inputId = `${baseId}-input`;
@@ -153,9 +158,22 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
153
158
  setRegistryVersion((v) => v + 1);
154
159
  }, []);
155
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;
156
166
  optionsRef.current.delete(toKey(optionValue));
157
167
  setRegistryVersion((v) => v + 1);
158
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]);
159
177
  // Active option ID for aria-activedescendant (hash-based for stability during filtering)
160
178
  const activeOptionId = activeIndex >= 0 && orderedOptions[activeIndex]
161
179
  ? `${listboxId}-opt-${hashForId(toKey(orderedOptions[activeIndex].value))}`
@@ -291,6 +309,7 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
291
309
  // Handle clear button - clears both input text and selected value
292
310
  const handleClear = useCallback((e) => {
293
311
  e.preventDefault(); // Keep focus in input
312
+ e.stopPropagation(); // Don't open dropdown
294
313
  setInputValue('');
295
314
  clearValue();
296
315
  inputRef.current?.focus();
@@ -365,7 +384,7 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
365
384
  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) => {
366
385
  inputRef.current = node;
367
386
  refs.setReference(node);
368
- }, 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("span", { className: "tui-combobox__clear", onPointerDown: handleClear, "aria-hidden": "true", 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] }) }) }));
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] }) }) }));
369
388
  }
370
389
  ComboboxRoot.displayName = 'Combobox';
371
390
  // =============================================================================
@@ -373,6 +392,12 @@ ComboboxRoot.displayName = 'Combobox';
373
392
  // =============================================================================
374
393
  function ComboboxContentComponent({ className, children }) {
375
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;
376
401
  const portalRoot = getPortalRootFor(refs.reference.current);
377
402
  const contentContext = useMemo(() => ({
378
403
  listRef,
@@ -380,7 +405,7 @@ function ComboboxContentComponent({ className, children }) {
380
405
  orderedOptions,
381
406
  }), [listRef, activeIndex, orderedOptions]);
382
407
  // Always render for option registration
383
- return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { 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: {
384
409
  ...floatingStyles,
385
410
  minWidth: refs.reference.current?.offsetWidth,
386
411
  pointerEvents: 'auto',
@@ -395,9 +420,11 @@ function ComboboxOptionComponent({ value: optionValue, disabled = false, textVal
395
420
  const { listRef, activeIndex, orderedOptions } = useComboboxContentContext();
396
421
  const ref = useRef(null);
397
422
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
398
- // Warn in dev if textValue couldn't be derived
423
+ // Warn in dev if textValue couldn't be derived (fire once per mount)
424
+ const warnedTextValueRef = useRef(false);
399
425
  useEffect(() => {
400
- if (isDev() && !textValue) {
426
+ if (isDev() && !textValue && !warnedTextValueRef.current) {
427
+ warnedTextValueRef.current = true;
401
428
  console.warn(`Combobox.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
402
429
  }
403
430
  }, [textValue, optionValue]);
@@ -440,7 +467,16 @@ ComboboxOptionComponent.displayName = 'Combobox.Option';
440
467
  // =============================================================================
441
468
  function ComboboxGroupComponent({ className, children }) {
442
469
  const groupId = useId();
443
- return (_jsx("div", { role: "group", "aria-labelledby": `${groupId}-label`, className: cx('tui-combobox__group', className), children: _jsx(ComboboxGroupContext.Provider, { value: { groupId }, children: children }) }));
470
+ const groupRef = useRef(null);
471
+ const [hasLabel, setHasLabel] = useState(false);
472
+ // Check if a Label child rendered — guard aria-labelledby to prevent dangling reference
473
+ useLayoutEffect(() => {
474
+ if (groupRef.current) {
475
+ const labelEl = groupRef.current.querySelector(`#${CSS.escape(`${groupId}-label`)}`);
476
+ setHasLabel(!!labelEl);
477
+ }
478
+ }, [groupId, children]);
479
+ return (_jsx("div", { ref: groupRef, role: "group", "aria-labelledby": hasLabel ? `${groupId}-label` : undefined, className: cx('tui-combobox__group', className), children: _jsx(ComboboxGroupContext.Provider, { value: { groupId }, children: children }) }));
444
480
  }
445
481
  ComboboxGroupComponent.displayName = 'Combobox.Group';
446
482
  const ComboboxGroupContext = React.createContext(null);
@@ -449,6 +485,13 @@ const ComboboxGroupContext = React.createContext(null);
449
485
  // =============================================================================
450
486
  function ComboboxLabelComponent({ className, children }) {
451
487
  const groupContext = React.useContext(ComboboxGroupContext);
488
+ const warnedRef = useRef(false);
489
+ useEffect(() => {
490
+ if (isDev() && !groupContext && !warnedRef.current) {
491
+ warnedRef.current = true;
492
+ console.warn('Combobox.Label should be used inside Combobox.Group for accessibility.');
493
+ }
494
+ }, [groupContext]);
452
495
  return (_jsx("div", { id: groupContext ? `${groupContext.groupId}-label` : undefined, className: cx('tui-combobox__label', className), children: children }));
453
496
  }
454
497
  ComboboxLabelComponent.displayName = 'Combobox.Label';
@@ -1,2 +1,3 @@
1
1
  export { Combobox, ComboboxContent, ComboboxOption, ComboboxGroup, ComboboxLabel, useCombobox, } from './Combobox';
2
- export type { ComboboxProps, ComboboxContentProps, ComboboxOptionProps, ComboboxGroupProps, ComboboxLabelProps, FilterMode as ComboboxFilterMode, RegisteredOption as ComboboxRegisteredOption, } from './types';
2
+ export type { ComboboxProps, ComboboxContentProps, ComboboxOptionProps, ComboboxGroupProps, ComboboxLabelProps, ComboboxLabels, FilterMode as ComboboxFilterMode, RegisteredOption as ComboboxRegisteredOption, } from './types';
3
+ export { defaultComboboxLabels } from './types';
@@ -1 +1,2 @@
1
1
  export { Combobox, ComboboxContent, ComboboxOption, ComboboxGroup, ComboboxLabel, useCombobox, } from './Combobox.js';
2
+ export { defaultComboboxLabels } from './types.js';
@@ -2,6 +2,11 @@ import type { FloatingContext, ReferenceType } from '@floating-ui/react';
2
2
  import type { RefObject, CSSProperties, MutableRefObject } from 'react';
3
3
  import type { SizeStandard } from '../../types/sizes';
4
4
  import type { OptionValue } from '../../utils/value-key';
5
+ export type ComboboxLabels = {
6
+ /** Label for the clear button. */
7
+ clear?: string;
8
+ };
9
+ export declare const defaultComboboxLabels: Required<ComboboxLabels>;
5
10
  /**
6
11
  * Controls when filtering should be active.
7
12
  * - 'always': Consumer filters whenever inputValue changes (default)
@@ -96,6 +101,10 @@ export type ComboboxProps = {
96
101
  * Use for utilities like `tui-input-reset` that must target the input itself.
97
102
  */
98
103
  inputClassName?: string;
104
+ /**
105
+ * Override default English strings for i18n.
106
+ */
107
+ labels?: ComboboxLabels;
99
108
  children: React.ReactNode;
100
109
  };
101
110
  export type ComboboxContentProps = {
@@ -1 +1,3 @@
1
- export {};
1
+ export const defaultComboboxLabels = {
2
+ clear: 'Clear selection',
3
+ };
@@ -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
  }