@tangible/ui 0.0.6 → 0.0.7

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.
@@ -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, '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, children, }: ComboboxProps): import("react/jsx-runtime").JSX.Element;
3
3
  declare namespace ComboboxRoot {
4
4
  var displayName: string;
5
5
  }
@@ -12,7 +12,7 @@ import { ComboboxActionsContext, ComboboxStateContext, ComboboxContentContext, u
12
12
  // =============================================================================
13
13
  // Combobox Root
14
14
  // =============================================================================
15
- function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inputValue: controlledInputValue, onInputChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', openOnFocus = true, filterMode = 'always', onQueryChange, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, inputClassName, children, }) {
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
16
  // Controlled/uncontrolled value (initialize from defaultValue)
17
17
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
18
18
  const isValueControlled = controlledValue !== undefined;
@@ -365,7 +365,7 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
365
365
  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
366
  inputRef.current = node;
367
367
  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 }), 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] }) }) }));
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] }) }) }));
369
369
  }
370
370
  ComboboxRoot.displayName = 'Combobox';
371
371
  // =============================================================================
@@ -86,6 +86,11 @@ export type ComboboxProps = {
86
86
  * ID of element that labels this combobox.
87
87
  */
88
88
  'aria-labelledby'?: string;
89
+ /**
90
+ * Whether to show the clear button when a value is present.
91
+ * @default true
92
+ */
93
+ clearable?: boolean;
89
94
  /**
90
95
  * Class name applied directly to the `<input>` element.
91
96
  * Use for utilities like `tui-input-reset` that must target the input itself.
@@ -5,16 +5,18 @@ import { FieldContext, useFieldContext } from './FieldContext.js';
5
5
  export const Field = forwardRef(function Field({ error = false, required = false, disabled = false, inline = false, className, children, }, ref) {
6
6
  const baseId = useId();
7
7
  const controlId = `${baseId}-control`;
8
+ const labelId = `${baseId}-label`;
8
9
  const helperTextId = `${baseId}-helper`;
9
10
  const errorId = `${baseId}-error`;
10
11
  const contextValue = useMemo(() => ({
11
12
  controlId,
13
+ labelId,
12
14
  helperTextId,
13
15
  errorId,
14
16
  hasError: error,
15
17
  required,
16
18
  disabled,
17
- }), [controlId, helperTextId, errorId, error, required, disabled]);
19
+ }), [controlId, labelId, helperTextId, errorId, error, required, disabled]);
18
20
  const classes = cx('tui-field', error && 'is-error', disabled && 'is-disabled', inline && 'is-layout-inline', className);
19
21
  return (_jsx(FieldContext.Provider, { value: contextValue, children: _jsx("div", { ref: ref, className: classes, children: children }) }));
20
22
  });
@@ -22,16 +24,16 @@ export const Field = forwardRef(function Field({ error = false, required = false
22
24
  // Field.Label
23
25
  // =============================================================================
24
26
  function FieldLabel({ hidden = false, className, children, ...rest }) {
25
- const { controlId, required } = useFieldContext();
27
+ const { controlId, labelId, required } = useFieldContext();
26
28
  const classes = cx('tui-field__label', hidden && 'tui-visually-hidden', className);
27
- return (_jsxs("label", { 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" })] }))] }));
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" })] }))] }));
28
30
  }
29
31
  FieldLabel.displayName = 'Field.Label';
30
32
  // =============================================================================
31
33
  // Field.Control
32
34
  // =============================================================================
33
35
  function FieldControl({ children }) {
34
- const { controlId, helperTextId, errorId, hasError, required, disabled, } = useFieldContext();
36
+ const { controlId, labelId, helperTextId, errorId, hasError, required, disabled, } = useFieldContext();
35
37
  const child = Children.only(children);
36
38
  if (!isValidElement(child)) {
37
39
  throw new Error('Field.Control expects a single React element as its child');
@@ -48,10 +50,18 @@ function FieldControl({ children }) {
48
50
  describedByParts.push(errorId);
49
51
  }
50
52
  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;
51
60
  // Clone child with a11y props
52
61
  // Note: aria-invalid and aria-required must be string "true", not boolean
53
62
  return cloneElement(child, {
54
63
  id: controlId,
64
+ 'aria-labelledby': labelledBy,
55
65
  'aria-describedby': describedBy,
56
66
  'aria-invalid': hasError ? 'true' : undefined,
57
67
  'aria-required': required ? 'true' : undefined,
@@ -1,6 +1,8 @@
1
1
  export type FieldContextValue = {
2
2
  /** ID for the form control element */
3
3
  controlId: string;
4
+ /** ID for the label element (for aria-labelledby on non-labelable controls) */
5
+ labelId: string;
4
6
  /** ID for helper text (for aria-describedby) */
5
7
  helperTextId: string;
6
8
  /** ID for error message (for aria-describedby) */
@@ -1,2 +1,6 @@
1
1
  import type { RadioProps } from './types';
2
+ /**
3
+ * Ref targets the inner `<button role="radio">`, not the outer wrapper `<div>`.
4
+ * This is intentional — roving tabindex and focus management operate on the button.
5
+ */
2
6
  export declare const Radio: import("react").ForwardRefExoticComponent<RadioProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useCallback, useEffect, useRef } from 'react';
2
+ import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { composeRefs } from '../../utils/compose-refs.js';
5
5
  import { toKey } from '../../utils/value-key.js';
@@ -14,8 +14,14 @@ import { useRadioGroupContext } from './RadioGroupContext.js';
14
14
  // Arrow keys in the group move focus AND select.
15
15
  //
16
16
  // =============================================================================
17
- export const Radio = forwardRef(function Radio({ value, label, disabled = false, className }, externalRef) {
17
+ /**
18
+ * Ref targets the inner `<button role="radio">`, not the outer wrapper `<div>`.
19
+ * This is intentional — roving tabindex and focus management operate on the button.
20
+ */
21
+ export const Radio = forwardRef(function Radio({ value, label, description, disabled = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }, externalRef) {
18
22
  const { selectedValue, focusableValue, rootDisabled, registerItem, unregisterItem, onSelect, } = useRadioGroupContext();
23
+ const id = useId();
24
+ const descriptionId = `${id}-desc`;
19
25
  const isSelected = selectedValue !== undefined && toKey(selectedValue) === toKey(value);
20
26
  const isDisabled = rootDisabled || disabled;
21
27
  const isFocusable = focusableValue !== undefined && toKey(focusableValue) === toKey(value);
@@ -24,8 +30,8 @@ export const Radio = forwardRef(function Radio({ value, label, disabled = false,
24
30
  useEffect(() => {
25
31
  if (hasWarnedRef.current)
26
32
  return;
27
- if (isDev() && !label) {
28
- console.warn('Radio: Missing accessible name. Provide a label prop.');
33
+ if (isDev() && !label && !ariaLabel && !ariaLabelledBy) {
34
+ console.warn('Radio: Missing accessible name. Provide a label, aria-label, or aria-labelledby prop.');
29
35
  hasWarnedRef.current = true;
30
36
  }
31
37
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -45,6 +51,10 @@ export const Radio = forwardRef(function Radio({ value, label, disabled = false,
45
51
  return;
46
52
  onSelect(value);
47
53
  };
48
- return (_jsxs("button", { ref: composeRefs(callbackRef, externalRef), type: "button", role: "radio", className: cx('tui-radio', className), "aria-checked": isSelected, disabled: isDisabled, tabIndex: isFocusable ? 0 : -1, onClick: handleClick, children: [_jsx("span", { className: "tui-radio__indicator", "aria-hidden": "true" }), label && _jsx("span", { className: "tui-radio__label", children: label })] }));
54
+ const hasExpandedContent = !!(description || children);
55
+ return (_jsxs("div", { className: cx('tui-radio', hasExpandedContent && 'has-content', className), children: [_jsxs("button", { ref: composeRefs(callbackRef, externalRef), type: "button", role: "radio", className: "tui-radio__control", "aria-checked": isSelected, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": description ? descriptionId : undefined,
56
+ // Item-level disabled: native disabled (removes from focus cycle).
57
+ // Group-level disabled: aria-disabled (preserves AT group context).
58
+ disabled: disabled || undefined, "aria-disabled": rootDisabled || undefined, tabIndex: isFocusable ? 0 : -1, onClick: handleClick, children: [_jsx("span", { className: "tui-radio__indicator", "aria-hidden": "true" }), label && _jsx("span", { className: "tui-radio__label", children: label })] }), hasExpandedContent && (_jsxs("div", { className: "tui-radio__body", children: [description && (_jsx("p", { id: descriptionId, className: "tui-radio__description", children: description })), children] }))] }));
49
59
  });
50
60
  Radio.displayName = 'Radio';
@@ -1,2 +1,2 @@
1
1
  import type { RadioGroupProps } from './types';
2
- export declare function RadioGroup({ value: controlledValue, defaultValue, onValueChange, disabled, orientation, loop, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }: RadioGroupProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function RadioGroup({ value: controlledValue, defaultValue, onValueChange, disabled, orientation, loop, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, className, children, }: RadioGroupProps): import("react/jsx-runtime").JSX.Element;
@@ -17,7 +17,7 @@ import { isDev } from '../../utils/is-dev.js';
17
17
  // --tui-radio-accent Accent color for selected state
18
18
  //
19
19
  // =============================================================================
20
- export function RadioGroup({ value: controlledValue, defaultValue, onValueChange, disabled = false, orientation = 'vertical', loop = true, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }) {
20
+ export function RadioGroup({ value: controlledValue, defaultValue, onValueChange, disabled = false, orientation = 'vertical', loop = true, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, className, children, }) {
21
21
  const [selectedValue, setSelectedValue] = useControllableState({
22
22
  value: controlledValue,
23
23
  defaultValue,
@@ -50,5 +50,5 @@ export function RadioGroup({ value: controlledValue, defaultValue, onValueChange
50
50
  unregisterItem,
51
51
  onSelect,
52
52
  }), [selectedValue, focusableValue, disabled, orientation, registerItem, unregisterItem, onSelect]);
53
- return (_jsx(RadioGroupContext.Provider, { value: contextValue, children: _jsx("div", { role: "radiogroup", className: cx('tui-radio-group', orientation === 'horizontal' && 'is-horizontal', className), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-disabled": disabled || undefined, "aria-orientation": orientation === 'horizontal' ? 'horizontal' : undefined, onKeyDown: handleKeyDown, children: children }) }));
53
+ return (_jsx(RadioGroupContext.Provider, { value: contextValue, children: _jsx("div", { role: "radiogroup", className: cx('tui-radio-group', orientation === 'horizontal' && 'is-horizontal', className), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, "aria-disabled": disabled || undefined, "aria-orientation": orientation, onKeyDown: handleKeyDown, children: children }) }));
54
54
  }
@@ -10,14 +10,24 @@ export type RadioGroupProps = {
10
10
  loop?: boolean;
11
11
  'aria-label'?: string;
12
12
  'aria-labelledby'?: string;
13
+ 'aria-describedby'?: string;
14
+ 'aria-invalid'?: boolean | 'true' | 'false';
15
+ 'aria-required'?: boolean | 'true' | 'false';
13
16
  className?: string;
14
17
  children: ReactNode;
15
18
  };
16
19
  export type RadioProps = {
17
20
  value: OptionValue;
18
21
  label?: ReactNode;
22
+ /**
23
+ * Description text displayed below the label.
24
+ */
25
+ description?: string;
19
26
  disabled?: boolean;
27
+ 'aria-label'?: string;
28
+ 'aria-labelledby'?: string;
20
29
  className?: string;
30
+ children?: ReactNode;
21
31
  };
22
32
  export type RadioItemRecord = RovingItemRecord;
23
33
  export type RadioGroupContextValue = {
@@ -8,18 +8,13 @@ import { isDev } from '../../utils/is-dev.js';
8
8
  // Switch Component
9
9
  // =============================================================================
10
10
  //
11
- // <button role="switch"> with animated thumb. Uses new tui-switch SCSS.
12
- // Existing tui-toggle CSS untouched (stays for CSS-only usage).
11
+ // <button role="switch"> with animated thumb. Uses tui-switch SCSS.
13
12
  //
14
13
  // Bare (no label): returns <button> directly for Field.Control cloneElement.
15
- // With label: wraps in <label>, native label click focuses the button.
16
- //
17
- // CSS token API:
18
- // --tui-switch-accent Accent color for on state
19
- // --tui-switch-track-off Track color when off
14
+ // With label: wraps in <label>, label-text clicks forward to button.
20
15
  //
21
16
  // =============================================================================
22
- // Props that should route to the <button>, not the wrapper
17
+ // Props that should route to the <button>, not the wrapper.
23
18
  const BUTTON_PROPS = new Set([
24
19
  'id',
25
20
  'aria-describedby',
@@ -29,6 +24,7 @@ const BUTTON_PROPS = new Set([
29
24
  'aria-labelledby',
30
25
  'form',
31
26
  'tabIndex',
27
+ 'onClick',
32
28
  'onFocus',
33
29
  'onBlur',
34
30
  ]);
@@ -57,13 +53,18 @@ export const Switch = forwardRef(function Switch({ checked: controlledChecked, d
57
53
  // Extract onClick from rest so prop spreading can't override internal handler
58
54
  const { onClick: onClickProp, ...restWithoutClick } = rest;
59
55
  const handleClick = (e) => {
56
+ if (disabled)
57
+ return;
60
58
  setChecked((prev) => !prev);
61
59
  onClickProp?.(e);
62
60
  };
63
61
  const isChecked = checked ?? false;
62
+ // Shared button element — single source of truth for both render paths.
63
+ // In labeled mode, buttonProps/sizeClass are set differently (see below).
64
+ const renderButton = (extraProps, extraClass) => (_jsx("button", { ref: composeRefs(internalRef, externalRef), type: "button", role: "switch", "aria-checked": isChecked, disabled: disabled, className: cx('tui-switch__track', extraClass), onClick: handleClick, ...extraProps, children: _jsx("span", { className: "tui-switch__thumb" }) }));
64
65
  // Bare: no label — Field.Control can inject id/aria-* directly
65
66
  if (!label) {
66
- return (_jsx("button", { ref: composeRefs(internalRef, externalRef), type: "button", role: "switch", "aria-checked": isChecked, disabled: disabled, className: cx('tui-switch__track', sizeClass, isChecked && 'is-checked', className), onClick: handleClick, ...restWithoutClick, children: _jsx("span", { className: "tui-switch__thumb" }) }));
67
+ return renderButton(restWithoutClick, cx(sizeClass, className));
67
68
  }
68
69
  // Split rest props: some go on button, some on wrapper
69
70
  const buttonProps = {};
@@ -76,16 +77,29 @@ export const Switch = forwardRef(function Switch({ checked: controlledChecked, d
76
77
  wrapperProps[key] = val;
77
78
  }
78
79
  }
79
- // With label: <label> wrapper provides click-anywhere-to-toggle natively.
80
- // We intercept the label click to toggle state and prevent the browser's
81
- // default label→button activation (which would double-toggle).
80
+ // DEV: warn if button-like props leaked to the wrapper
81
+ if (isDev()) {
82
+ const suspect = Object.keys(wrapperProps).filter((k) => k.startsWith('aria-') || k.startsWith('on') || k === 'tabIndex');
83
+ if (suspect.length > 0) {
84
+ console.warn(`Switch: Props [${suspect.join(', ')}] ended up on the <label> wrapper. ` +
85
+ 'Add them to BUTTON_PROPS if they belong on the <button>.');
86
+ }
87
+ }
88
+ // <label> wrapping a <button> causes native click-forwarding (button IS
89
+ // a labelable element). Without preventDefault, clicking the button fires
90
+ // handleClick AND the forwarded click — double-toggle.
91
+ // We suppress native forwarding. Clicks on the button are handled by its
92
+ // own onClick (handleClick). Clicks on the label text toggle state here.
82
93
  const handleLabelClick = (e) => {
83
- // Prevent native label click from also activating the button
84
94
  e.preventDefault();
95
+ // Clicks on/inside the button are handled by handleClick (via bubbling)
96
+ if (internalRef.current?.contains(e.target))
97
+ return;
85
98
  if (disabled)
86
99
  return;
87
100
  internalRef.current?.focus();
88
101
  setChecked((prev) => !prev);
89
102
  };
90
- return (_jsxs("label", { className: cx('tui-switch', sizeClass, disabled && 'is-disabled', className), onClick: handleLabelClick, ...wrapperProps, children: [_jsx("button", { ref: composeRefs(internalRef, externalRef), type: "button", role: "switch", "aria-checked": isChecked, disabled: disabled, className: cx('tui-switch__track', isChecked && 'is-checked'), ...buttonProps, children: _jsx("span", { className: "tui-switch__thumb" }) }), _jsx("span", { className: "tui-switch__label", children: label })] }));
103
+ return (_jsxs("label", { className: cx('tui-switch', sizeClass, disabled && 'is-disabled', className), onClick: handleLabelClick, ...wrapperProps, children: [renderButton(buttonProps), _jsx("span", { className: "tui-switch__label", children: label })] }));
91
104
  });
105
+ Switch.displayName = 'Switch';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangible/ui",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Tangible Design System",
5
5
  "type": "module",
6
6
  "main": "./components/index.js",