@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.
- package/components/Accordion/Accordion.js +11 -3
- package/components/Avatar/Avatar.js +4 -3
- package/components/Avatar/AvatarGroup.js +7 -5
- package/components/Avatar/index.d.ts +2 -2
- package/components/Avatar/index.js +1 -1
- package/components/Avatar/types.d.ts +27 -0
- package/components/Avatar/types.js +8 -0
- package/components/Button/Button.js +4 -2
- package/components/Button/index.d.ts +2 -1
- package/components/Button/index.js +1 -0
- package/components/Button/types.d.ts +10 -0
- package/components/Button/types.js +3 -1
- package/components/Checkbox/Checkbox.js +46 -11
- package/components/Checkbox/types.d.ts +9 -0
- package/components/Combobox/Combobox.d.ts +1 -1
- package/components/Combobox/Combobox.js +28 -7
- package/components/Combobox/index.d.ts +2 -1
- package/components/Combobox/index.js +1 -0
- package/components/Combobox/types.d.ts +9 -0
- package/components/Combobox/types.js +3 -1
- package/components/Dropdown/Dropdown.js +16 -4
- package/components/Field/Field.d.ts +4 -1
- package/components/Field/Field.js +35 -14
- package/components/Field/FieldContext.d.ts +16 -0
- package/components/Field/FieldContext.js +3 -0
- package/components/Field/index.d.ts +2 -1
- package/components/Field/index.js +1 -0
- package/components/MoveHandle/MoveHandle.js +1 -1
- package/components/MoveHandle/types.d.ts +1 -1
- package/components/MultiSelect/MultiSelect.d.ts +1 -1
- package/components/MultiSelect/MultiSelect.js +37 -19
- package/components/MultiSelect/index.d.ts +2 -1
- package/components/MultiSelect/index.js +1 -0
- package/components/MultiSelect/types.d.ts +34 -0
- package/components/MultiSelect/types.js +10 -0
- package/components/Pager/Pager.d.ts +7 -1
- package/components/Pager/Pager.js +7 -5
- package/components/Pager/index.d.ts +2 -0
- package/components/Pager/index.js +1 -0
- package/components/Pager/types.d.ts +37 -0
- package/components/Pager/types.js +12 -0
- package/components/Rating/Rating.d.ts +2 -32
- package/components/Rating/Rating.js +5 -3
- package/components/Rating/index.d.ts +2 -1
- package/components/Rating/index.js +1 -0
- package/components/Rating/types.d.ts +41 -0
- package/components/Rating/types.js +4 -0
- package/components/SegmentedControl/SegmentedControl.js +6 -5
- package/components/SegmentedControl/types.d.ts +17 -5
- package/components/Select/Select.d.ts +1 -0
- package/components/Select/Select.js +109 -77
- package/components/Select/SelectContext.d.ts +4 -16
- package/components/Select/SelectContext.js +5 -35
- package/components/Select/types.d.ts +19 -19
- package/components/Sidebar/Sidebar.js +25 -20
- package/components/StepIndicator/StepIndicator.js +11 -8
- package/components/StepIndicator/index.d.ts +2 -1
- package/components/StepIndicator/index.js +1 -0
- package/components/StepIndicator/types.d.ts +18 -0
- package/components/StepIndicator/types.js +7 -1
- package/components/Table/BulkActionsBar.d.ts +4 -1
- package/components/Table/BulkActionsBar.js +5 -4
- package/components/Table/DataTable.d.ts +4 -1
- package/components/Table/DataTable.js +10 -8
- package/components/Table/index.d.ts +3 -0
- package/components/Table/index.js +2 -0
- package/components/Table/types.d.ts +20 -0
- package/components/Table/types.js +11 -0
- package/components/Tabs/Tabs.js +11 -4
- package/components/TextInput/TextInput.js +2 -1
- package/components/TextInput/types.d.ts +7 -1
- package/components/Textarea/Textarea.js +3 -2
- package/components/Textarea/types.d.ts +6 -1
- package/icons/icons.svg +29 -15
- package/icons/lms/index.d.ts +8 -0
- package/icons/lms/index.js +48 -4
- package/icons/manifest.json +112 -0
- package/icons/player/index.js +9 -9
- package/icons/registry.d.ts +28 -0
- package/icons/registry.js +14 -0
- package/icons/system/index.d.ts +20 -0
- package/icons/system/index.js +112 -2
- package/package.json +1 -1
- package/styles/all.css +1 -1
- package/styles/all.expanded.css +266 -59
- package/styles/all.expanded.unlayered.css +266 -59
- package/styles/all.unlayered.css +1 -1
- package/styles/components/input/index.scss +29 -7
- package/styles/system/_constants.scss +1 -1
- package/styles/system/_tokens.scss +1 -0
- 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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
?
|
|
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
|
-
?
|
|
33
|
-
:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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 }));
|
|
@@ -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
|
*/
|
|
@@ -8,13 +8,21 @@ import { isDev } from '../../utils/is-dev.js';
|
|
|
8
8
|
// Checkbox Component
|
|
9
9
|
// =============================================================================
|
|
10
10
|
//
|
|
11
|
-
//
|
|
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 (
|
|
17
|
-
// --tui-
|
|
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
|
-
//
|
|
72
|
-
|
|
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
|
-
//
|
|
88
|
+
// Shared input element — single 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 (
|
|
94
|
+
return renderInput(rest, className);
|
|
78
95
|
}
|
|
79
|
-
//
|
|
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
|
-
//
|
|
91
|
-
|
|
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("
|
|
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
|
-
|
|
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';
|
|
@@ -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 = {
|
|
@@ -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
|
-
|
|
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 {};
|