@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
@@ -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
@@ -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.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,7 +66,7 @@ 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) {
@@ -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('');
@@ -291,6 +293,7 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
291
293
  // Handle clear button - clears both input text and selected value
292
294
  const handleClear = useCallback((e) => {
293
295
  e.preventDefault(); // Keep focus in input
296
+ e.stopPropagation(); // Don't open dropdown
294
297
  setInputValue('');
295
298
  clearValue();
296
299
  inputRef.current?.focus();
@@ -365,7 +368,7 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
365
368
  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
369
  inputRef.current = node;
367
370
  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] }) }) }));
371
+ }, 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
372
  }
370
373
  ComboboxRoot.displayName = 'Combobox';
371
374
  // =============================================================================
@@ -380,7 +383,7 @@ function ComboboxContentComponent({ className, children }) {
380
383
  orderedOptions,
381
384
  }), [listRef, activeIndex, orderedOptions]);
382
385
  // 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: {
386
+ return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { id: listboxId, role: "listbox", style: { display: 'none' }, "aria-hidden": "true", children: _jsx(ComboboxContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": inputId, className: cx('tui-combobox__content', className), style: {
384
387
  ...floatingStyles,
385
388
  minWidth: refs.reference.current?.offsetWidth,
386
389
  pointerEvents: 'auto',
@@ -395,9 +398,11 @@ function ComboboxOptionComponent({ value: optionValue, disabled = false, textVal
395
398
  const { listRef, activeIndex, orderedOptions } = useComboboxContentContext();
396
399
  const ref = useRef(null);
397
400
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
398
- // Warn in dev if textValue couldn't be derived
401
+ // Warn in dev if textValue couldn't be derived (fire once per mount)
402
+ const warnedTextValueRef = useRef(false);
399
403
  useEffect(() => {
400
- if (isDev() && !textValue) {
404
+ if (isDev() && !textValue && !warnedTextValueRef.current) {
405
+ warnedTextValueRef.current = true;
401
406
  console.warn(`Combobox.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
402
407
  }
403
408
  }, [textValue, optionValue]);
@@ -440,7 +445,16 @@ ComboboxOptionComponent.displayName = 'Combobox.Option';
440
445
  // =============================================================================
441
446
  function ComboboxGroupComponent({ className, children }) {
442
447
  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 }) }));
448
+ const groupRef = useRef(null);
449
+ const [hasLabel, setHasLabel] = useState(false);
450
+ // Check if a Label child rendered — guard aria-labelledby to prevent dangling reference
451
+ useLayoutEffect(() => {
452
+ if (groupRef.current) {
453
+ const labelEl = groupRef.current.querySelector(`#${CSS.escape(`${groupId}-label`)}`);
454
+ setHasLabel(!!labelEl);
455
+ }
456
+ }, [groupId, children]);
457
+ 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
458
  }
445
459
  ComboboxGroupComponent.displayName = 'Combobox.Group';
446
460
  const ComboboxGroupContext = React.createContext(null);
@@ -449,6 +463,13 @@ const ComboboxGroupContext = React.createContext(null);
449
463
  // =============================================================================
450
464
  function ComboboxLabelComponent({ className, children }) {
451
465
  const groupContext = React.useContext(ComboboxGroupContext);
466
+ const warnedRef = useRef(false);
467
+ useEffect(() => {
468
+ if (isDev() && !groupContext && !warnedRef.current) {
469
+ warnedRef.current = true;
470
+ console.warn('Combobox.Label should be used inside Combobox.Group for accessibility.');
471
+ }
472
+ }, [groupContext]);
452
473
  return (_jsx("div", { id: groupContext ? `${groupContext.groupId}-label` : undefined, className: cx('tui-combobox__label', className), children: children }));
453
474
  }
454
475
  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
+ };
@@ -130,9 +130,10 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
130
130
  }, [triggerRef, refs]);
131
131
  // Classify children: count navigable items and collect disabled indices.
132
132
  // Separator and Header sub-components are non-navigable.
133
- const { disabledIndices, totalItemCount } = useMemo(() => {
133
+ const { disabledIndices, totalItemCount, firstEnabledIndex } = useMemo(() => {
134
134
  const disabled = [];
135
135
  let itemIdx = 0;
136
+ let firstEnabled = -1;
136
137
  Children.forEach(children, (child) => {
137
138
  if (!isValidElement(child))
138
139
  return;
@@ -146,9 +147,12 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
146
147
  if (props.disabled) {
147
148
  disabled.push(itemIdx);
148
149
  }
150
+ else if (firstEnabled === -1) {
151
+ firstEnabled = itemIdx;
152
+ }
149
153
  itemIdx++;
150
154
  });
151
- return { disabledIndices: disabled, totalItemCount: itemIdx };
155
+ return { disabledIndices: disabled, totalItemCount: itemIdx, firstEnabledIndex: firstEnabled };
152
156
  }, [children]);
153
157
  // ArrowUp focus-last: set activeIndex to last valid item before paint
154
158
  useLayoutEffect(() => {
@@ -203,8 +207,16 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
203
207
  ref: (node) => {
204
208
  listRef.current[currentIndex] = node;
205
209
  },
206
- // Disabled items get tabIndex -1 always
207
- tabIndex: isDisabled ? -1 : (activeIndex === currentIndex ? 0 : -1),
210
+ // Disabled items get tabIndex -1 always.
211
+ // When activeIndex is null (initial render before floating-ui
212
+ // activates), the first non-disabled item gets tabIndex 0 so at
213
+ // least one item is always keyboard-reachable.
214
+ tabIndex: isDisabled
215
+ ? -1
216
+ : (activeIndex === currentIndex
217
+ || (activeIndex === null && !disabledIndices.includes(currentIndex) && currentIndex === firstEnabledIndex))
218
+ ? 0
219
+ : -1,
208
220
  }),
209
221
  // Add menuitem role if not already specified
210
222
  role: existingRole || 'menuitem',
@@ -1,4 +1,6 @@
1
1
  import React from 'react';
2
+ import type { FieldLabels } from './FieldContext';
3
+ export type { FieldLabels };
2
4
  export type FieldProps = {
3
5
  /** Whether the field has an error state */
4
6
  error?: boolean;
@@ -8,6 +10,8 @@ export type FieldProps = {
8
10
  disabled?: boolean;
9
11
  /** Inline layout: label and control on same row */
10
12
  inline?: boolean;
13
+ /** Overridable strings for i18n. */
14
+ labels?: FieldLabels;
11
15
  /** Additional class name for the field wrapper */
12
16
  className?: string;
13
17
  children?: React.ReactNode;
@@ -36,4 +40,3 @@ type FieldCompound = React.ForwardRefExoticComponent<FieldProps & React.RefAttri
36
40
  Error: React.FC<ErrorProps>;
37
41
  };
38
42
  export declare const Field: FieldCompound;
39
- export {};