@tangible/ui 0.0.2 → 0.0.4
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/README.md +21 -13
- package/components/Accordion/Accordion.d.ts +1 -1
- package/components/Accordion/Accordion.js +3 -3
- package/components/Accordion/types.d.ts +8 -1
- package/components/Avatar/Avatar.js +16 -7
- package/components/Avatar/AvatarGroup.js +7 -5
- package/components/Avatar/types.d.ts +11 -0
- package/components/Button/Button.js +10 -3
- package/components/Button/types.d.ts +9 -1
- package/components/Card/Card.js +26 -13
- package/components/Checkbox/Checkbox.d.ts +1 -1
- package/components/Chip/Chip.d.ts +37 -1
- package/components/Chip/Chip.js +10 -8
- package/components/ChipGroup/ChipGroup.js +5 -4
- package/components/ChipGroup/types.d.ts +3 -0
- package/components/Dropdown/Dropdown.d.ts +19 -1
- package/components/Dropdown/Dropdown.js +84 -28
- package/components/Dropdown/index.d.ts +2 -2
- package/components/Dropdown/index.js +1 -1
- package/components/Dropdown/types.d.ts +15 -0
- package/components/IconButton/IconButton.js +5 -4
- package/components/IconButton/index.d.ts +1 -1
- package/components/IconButton/types.d.ts +24 -4
- package/components/Modal/Modal.d.ts +16 -2
- package/components/Modal/Modal.js +45 -20
- package/components/MoveHandle/MoveHandle.d.ts +2 -0
- package/components/MoveHandle/MoveHandle.js +84 -0
- package/components/MoveHandle/index.d.ts +2 -0
- package/components/MoveHandle/index.js +1 -0
- package/components/MoveHandle/types.d.ts +53 -0
- package/components/MoveHandle/types.js +1 -0
- package/components/Notice/Notice.js +32 -19
- package/components/Select/Select.js +6 -2
- package/components/Sidebar/Sidebar.d.ts +6 -1
- package/components/Sidebar/Sidebar.js +65 -11
- package/components/Sidebar/index.d.ts +1 -1
- package/components/Sidebar/types.d.ts +39 -14
- package/components/Tabs/Tabs.d.ts +1 -1
- package/components/Tabs/Tabs.js +12 -3
- package/components/Tabs/types.d.ts +20 -5
- package/components/TextInput/TextInput.js +10 -2
- package/components/Tooltip/Tooltip.d.ts +2 -2
- package/components/Tooltip/Tooltip.js +61 -40
- package/components/Tooltip/index.d.ts +1 -1
- package/components/Tooltip/types.d.ts +28 -1
- package/components/index.d.ts +4 -2
- package/components/index.js +2 -1
- package/icons/icons.svg +1 -0
- package/icons/manifest.json +8 -0
- package/icons/registry.d.ts +2 -0
- package/icons/registry.js +1 -0
- package/icons/system/index.d.ts +2 -0
- package/icons/system/index.js +11 -0
- package/package.json +1 -1
- package/styles/all.css +1 -1
- package/styles/all.expanded.css +961 -97
- package/styles/all.expanded.unlayered.css +961 -97
- package/styles/all.unlayered.css +1 -1
- package/styles/components/_bundle.scss +2 -0
- package/styles/index.scss +5 -0
- package/styles/system/_control.scss +18 -3
- package/styles/system/_tokens.scss +119 -2
- package/tui-manifest.json +526 -88
- package/utils/focus-trap.js +8 -1
package/README.md
CHANGED
|
@@ -6,10 +6,12 @@ Design system for Tangible WordPress plugins. React components + CSS tokens + ut
|
|
|
6
6
|
|
|
7
7
|
## Components
|
|
8
8
|
|
|
9
|
-
- **Primitives:** Button, Chip, Icon, IconButton, Progress, Rating, Tooltip
|
|
10
|
-
- **Layout:** Accordion, Card, Modal, Notice,
|
|
11
|
-
- **
|
|
12
|
-
- **Form
|
|
9
|
+
- **Primitives:** Button, Chip, ChipGroup, Icon, IconButton, Progress, Rating, Tooltip
|
|
10
|
+
- **Layout:** Accordion, Card, Modal, Notice, Sidebar, Tabs, Toolbar
|
|
11
|
+
- **Data:** DataTable, StepList, StepIndicator, Pager
|
|
12
|
+
- **Form Controls:** Select, MultiSelect, Combobox, TextInput, Textarea, Checkbox, Switch, Radio
|
|
13
|
+
- **Composites:** Avatar, Dropdown, MoveHandle, OverlapStack, SegmentedControl, Field
|
|
14
|
+
- **CSS-only Inputs:** Text, textarea, select, checkbox, radio, toggle, file
|
|
13
15
|
|
|
14
16
|
## Getting Started
|
|
15
17
|
|
|
@@ -26,9 +28,9 @@ npm install @tangible/ui
|
|
|
26
28
|
import '@tangible/ui/styles';
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
Or
|
|
30
|
-
```
|
|
31
|
-
|
|
31
|
+
Or for WordPress contexts (no CSS layers):
|
|
32
|
+
```tsx
|
|
33
|
+
import '@tangible/ui/styles/unlayered';
|
|
32
34
|
```
|
|
33
35
|
|
|
34
36
|
### Wrap your app
|
|
@@ -45,6 +47,8 @@ function App() {
|
|
|
45
47
|
}
|
|
46
48
|
```
|
|
47
49
|
|
|
50
|
+
Dark mode via `data-theme="dark"` on the wrapper.
|
|
51
|
+
|
|
48
52
|
### Use components
|
|
49
53
|
|
|
50
54
|
```tsx
|
|
@@ -82,11 +86,13 @@ npm run storybook # Dev server on port 6006
|
|
|
82
86
|
## Commands
|
|
83
87
|
|
|
84
88
|
```bash
|
|
85
|
-
npm run storybook
|
|
86
|
-
npm run build:lib
|
|
87
|
-
npm run lint
|
|
88
|
-
npm run test
|
|
89
|
-
npm run test:storybook
|
|
89
|
+
npm run storybook # Dev server
|
|
90
|
+
npm run build:lib # Build library (outputs to publish/)
|
|
91
|
+
npm run lint # ESLint
|
|
92
|
+
npm run test # Unit tests (vitest, jsdom)
|
|
93
|
+
npm run test:storybook # Story + a11y tests (vitest, Playwright chromium)
|
|
94
|
+
npm run test:visual # Visual regression (Playwright)
|
|
95
|
+
npm run test:visual:update # Regenerate visual baselines
|
|
90
96
|
```
|
|
91
97
|
|
|
92
98
|
## Documentation
|
|
@@ -94,7 +100,9 @@ npm run test:storybook # Story tests (requires Playwright)
|
|
|
94
100
|
- `CLAUDE.md` — Development guide (architecture, patterns, conventions)
|
|
95
101
|
- `CONTEXT.md` — Project background and LMS requirements
|
|
96
102
|
- `TIMELINE.md` — Development roadmap (Jan–Mar 2026)
|
|
103
|
+
- `TESTING.md` — Testing strategy and infrastructure
|
|
104
|
+
- `AGENTS.md` — Quality gate agent configurations
|
|
97
105
|
|
|
98
106
|
## Status
|
|
99
107
|
|
|
100
|
-
Under active development for Course
|
|
108
|
+
Under active development for Course Builder (LMS) and Quiz modules. Component APIs are stabilising but may change before 1.0.
|
|
@@ -14,7 +14,7 @@ declare function AccordionTrigger({ children, className }: AccordionTriggerProps
|
|
|
14
14
|
declare namespace AccordionTrigger {
|
|
15
15
|
var displayName: string;
|
|
16
16
|
}
|
|
17
|
-
declare function AccordionPanel({ children, className }: AccordionPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
declare function AccordionPanel({ landmark, children, className }: AccordionPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
18
18
|
declare namespace AccordionPanel {
|
|
19
19
|
var displayName: string;
|
|
20
20
|
}
|
|
@@ -162,7 +162,7 @@ function AccordionTrigger({ children, className }) {
|
|
|
162
162
|
}
|
|
163
163
|
};
|
|
164
164
|
const state = isOpen ? 'open' : 'closed';
|
|
165
|
-
const button = (_jsxs("button", { ref: buttonRef, type: "button", id: triggerId, className: cx('tui-accordion__trigger', className), "aria-expanded": isOpen, "aria-controls": panelId, disabled: disabled, "data-state": state, "data-disabled": disabled || undefined, onClick: handleClick, onKeyDown: handleKeyDown, children: [_jsx("span", { className: "tui-accordion__trigger-content", children: children }), _jsx(Icon, { name: "system/chevron-down", size: "
|
|
165
|
+
const button = (_jsxs("button", { ref: buttonRef, type: "button", id: triggerId, className: cx('tui-accordion__trigger', className), "aria-expanded": isOpen, "aria-controls": panelId, disabled: disabled, "data-state": state, "data-disabled": disabled || undefined, onClick: handleClick, onKeyDown: handleKeyDown, children: [_jsx("span", { className: "tui-accordion__trigger-content", children: children }), _jsx(Icon, { name: "system/chevron-down", size: "lg", className: "tui-accordion__indicator", "aria-hidden": "true" })] }));
|
|
166
166
|
// Wrap in heading if headingLevel is specified
|
|
167
167
|
if (headingLevel) {
|
|
168
168
|
const Heading = `h${headingLevel}`;
|
|
@@ -173,10 +173,10 @@ function AccordionTrigger({ children, className }) {
|
|
|
173
173
|
// =============================================================================
|
|
174
174
|
// Accordion Panel
|
|
175
175
|
// =============================================================================
|
|
176
|
-
function AccordionPanel({ children, className }) {
|
|
176
|
+
function AccordionPanel({ landmark = false, children, className }) {
|
|
177
177
|
const { triggerId, panelId, isOpen } = useAccordionItemContext();
|
|
178
178
|
const state = isOpen ? 'open' : 'closed';
|
|
179
|
-
return (_jsx("div", { id: panelId, role:
|
|
179
|
+
return (_jsx("div", { id: panelId, role: landmark ? 'region' : undefined, "aria-labelledby": triggerId, className: cx('tui-accordion__panel', className), "data-state": state, "aria-hidden": !isOpen,
|
|
180
180
|
// Prevent keyboard focus into collapsed panels
|
|
181
181
|
inert: !isOpen || undefined, children: _jsx("div", { className: "tui-accordion__panel-content", children: children }) }));
|
|
182
182
|
}
|
|
@@ -29,7 +29,12 @@ export type AccordionItemProps = {
|
|
|
29
29
|
value: string;
|
|
30
30
|
/** Prevent interaction */
|
|
31
31
|
disabled?: boolean;
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Wrap trigger in heading element (h2–h6).
|
|
34
|
+
* Omitting this reduces discoverability for screen reader users who
|
|
35
|
+
* navigate by headings (NVDA/JAWS `H` key). Only omit when the
|
|
36
|
+
* accordion is already inside an appropriate heading context.
|
|
37
|
+
*/
|
|
33
38
|
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
34
39
|
children: ReactNode;
|
|
35
40
|
className?: string;
|
|
@@ -39,6 +44,8 @@ export type AccordionTriggerProps = {
|
|
|
39
44
|
className?: string;
|
|
40
45
|
};
|
|
41
46
|
export type AccordionPanelProps = {
|
|
47
|
+
/** Render panel as a landmark region (role="region"). Default false to avoid landmark pollution with many panels. */
|
|
48
|
+
landmark?: boolean;
|
|
42
49
|
children: ReactNode;
|
|
43
50
|
className?: string;
|
|
44
51
|
};
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import React, { useState, useMemo } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { Icon } from '../Icon/index.js';
|
|
5
|
+
import { Tooltip } from '../Tooltip/index.js';
|
|
5
6
|
import { AVATAR_COLORS } from './types.js';
|
|
6
7
|
/**
|
|
7
8
|
* Generate initials from a name.
|
|
@@ -45,7 +46,7 @@ function getColorFromName(name, colors) {
|
|
|
45
46
|
* - Shows placeholder icon if neither `src` nor `name` provided
|
|
46
47
|
* - Colors for initials are derived from the name hash for consistency
|
|
47
48
|
*/
|
|
48
|
-
export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circle', color, indicator, indicatorLabel, indicatorPosition = 'bottom-right', className, }, ref) => {
|
|
49
|
+
export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circle', color, indicator, indicatorLabel, indicatorPosition = 'bottom-right', tooltip, className, }, ref) => {
|
|
49
50
|
const [imgError, setImgError] = useState(false);
|
|
50
51
|
// Reset error state when src changes
|
|
51
52
|
React.useEffect(() => {
|
|
@@ -56,12 +57,20 @@ export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circl
|
|
|
56
57
|
const showImage = src && !imgError;
|
|
57
58
|
const showInitials = !showImage && initials;
|
|
58
59
|
const showPlaceholder = !showImage && !initials;
|
|
59
|
-
//
|
|
60
|
-
const isDecorative =
|
|
61
|
-
|
|
60
|
+
// Avatars with no name and no indicator label have no meaningful identity
|
|
61
|
+
const isDecorative = !name && !indicatorLabel;
|
|
62
|
+
const showTooltip = tooltip && !!name;
|
|
63
|
+
const avatarElement = (_jsxs("span", { ref: ref, className: cx('tui-avatar', `is-size-${size}`, `is-shape-${shape}`, !showImage && `is-color-${derivedColor}`, className), ...(isDecorative
|
|
62
64
|
? { 'aria-hidden': true }
|
|
63
|
-
: {
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
: {
|
|
66
|
+
role: 'img',
|
|
67
|
+
'aria-label': name && indicatorLabel
|
|
68
|
+
? `${name}, ${indicatorLabel}`
|
|
69
|
+
: name || indicatorLabel,
|
|
70
|
+
}), 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
|
+
if (showTooltip) {
|
|
72
|
+
return (_jsxs(Tooltip, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: avatarElement }), _jsx(Tooltip.Content, { "aria-hidden": "true", children: name })] }));
|
|
73
|
+
}
|
|
74
|
+
return avatarElement;
|
|
66
75
|
});
|
|
67
76
|
Avatar.displayName = 'Avatar';
|
|
@@ -10,7 +10,7 @@ import { OverlapStack } from '../OverlapStack/index.js';
|
|
|
10
10
|
* - Non-overlap mode uses flex with gap
|
|
11
11
|
* - Override overlap amount via `--tui-avatar-group-overlap` CSS property
|
|
12
12
|
*/
|
|
13
|
-
export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true, children, className }, ref) => {
|
|
13
|
+
export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true, groupLabel: groupLabelFn, children, className }, ref) => {
|
|
14
14
|
const childArray = Children.toArray(children).filter(isValidElement);
|
|
15
15
|
const total = childArray.length;
|
|
16
16
|
// Clone children to inject size/shape props if provided at group level
|
|
@@ -26,9 +26,11 @@ export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true,
|
|
|
26
26
|
// Descriptive label for the group
|
|
27
27
|
const hasOverflow = max !== undefined && total > max;
|
|
28
28
|
const visibleCount = hasOverflow ? max : total;
|
|
29
|
-
const groupLabel =
|
|
30
|
-
?
|
|
31
|
-
:
|
|
29
|
+
const groupLabel = groupLabelFn
|
|
30
|
+
? groupLabelFn(total, visibleCount)
|
|
31
|
+
: hasOverflow
|
|
32
|
+
? `${total} users, showing ${visibleCount}`
|
|
33
|
+
: `${total} users`;
|
|
32
34
|
// Non-overlap mode: simple flex layout
|
|
33
35
|
if (!overlap) {
|
|
34
36
|
return (_jsx("div", { ref: ref, className: cx('tui-avatar-group', className), role: "group", "aria-label": groupLabel, children: clonedChildren }));
|
|
@@ -41,5 +43,5 @@ export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true,
|
|
|
41
43
|
});
|
|
42
44
|
AvatarGroup.displayName = 'AvatarGroup';
|
|
43
45
|
function AvatarOverflow({ count, size, shape }) {
|
|
44
|
-
return (_jsx("span", { className: cx('tui-avatar', 'tui-avatar--overflow', size && `is-size-${size}`, shape && `is-shape-${shape}`),
|
|
46
|
+
return (_jsx("span", { className: cx('tui-avatar', 'tui-avatar--overflow', size && `is-size-${size}`, shape && `is-shape-${shape}`), "aria-hidden": "true", children: _jsx("span", { className: "tui-avatar__content", children: _jsxs("span", { className: "tui-avatar__initials", children: ["+", count] }) }) }));
|
|
45
47
|
}
|
|
@@ -25,6 +25,12 @@ export type AvatarProps = {
|
|
|
25
25
|
indicatorLabel?: string;
|
|
26
26
|
/** Position of the indicator */
|
|
27
27
|
indicatorPosition?: IndicatorPosition;
|
|
28
|
+
/**
|
|
29
|
+
* When true, wraps the avatar with a Tooltip showing the `name`.
|
|
30
|
+
* Helps sighted users discover the user's name on hover/focus.
|
|
31
|
+
* Has no effect when `name` is not provided.
|
|
32
|
+
*/
|
|
33
|
+
tooltip?: boolean;
|
|
28
34
|
/** Additional CSS class */
|
|
29
35
|
className?: string;
|
|
30
36
|
};
|
|
@@ -37,6 +43,11 @@ export type AvatarGroupProps = {
|
|
|
37
43
|
shape?: AvatarShape;
|
|
38
44
|
/** Whether avatars overlap (default: true) */
|
|
39
45
|
overlap?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Custom label function for i18n. Receives total and visible counts.
|
|
48
|
+
* Default: "N users" or "N users, showing M".
|
|
49
|
+
*/
|
|
50
|
+
groupLabel?: (total: number, visible: number) => string;
|
|
40
51
|
/** Children (Avatar components) */
|
|
41
52
|
children: React.ReactNode;
|
|
42
53
|
/** Additional CSS class */
|
|
@@ -3,9 +3,16 @@ 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, leftIconName, rightIconName, leftIcon, rightIcon, iconSize
|
|
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) => {
|
|
7
7
|
const isLink = typeof rest.href === 'string';
|
|
8
8
|
const isDisabled = disabled || loading;
|
|
9
|
+
// Auto-scale icon size with button size when not explicitly set
|
|
10
|
+
const iconSizeMap = { xs: 'xs', sm: 'xs', md: 'sm', lg: 'md' };
|
|
11
|
+
const iconSize = iconSizeProp ?? iconSizeMap[size];
|
|
12
|
+
// Compose loading state into aria-label for screen readers
|
|
13
|
+
const loadingLabel = loading
|
|
14
|
+
? (loadingLabelProp ?? (typeof label === 'string' ? `${label}, loading` : undefined))
|
|
15
|
+
: undefined;
|
|
9
16
|
// Normalize destructive → danger for CSS class
|
|
10
17
|
const themeClass = theme === 'destructive' ? 'danger' : theme;
|
|
11
18
|
const classes = cx('tui-button', `is-size-${size}`, `is-theme-${themeClass}`, variant !== 'solid' && `is-style-${variant}`, fullWidth && 'is-width-full', isDisabled && 'is-disabled', className);
|
|
@@ -25,9 +32,9 @@ export const Button = forwardRef(({ label, children, size = 'md', theme = 'prima
|
|
|
25
32
|
}
|
|
26
33
|
onClick?.(e);
|
|
27
34
|
};
|
|
28
|
-
return (
|
|
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)" }))] }));
|
|
29
36
|
}
|
|
30
37
|
const buttonRest = rest;
|
|
31
|
-
return (_jsx("button", { ref: ref, type: buttonRest.type ?? 'button', className: classes,
|
|
38
|
+
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 }));
|
|
32
39
|
});
|
|
33
40
|
Button.displayName = 'Button';
|
|
@@ -6,7 +6,9 @@ export type { Size };
|
|
|
6
6
|
* - `'primary'`: Primary action (default)
|
|
7
7
|
* - `'secondary'`: Secondary/neutral action
|
|
8
8
|
* - `'danger'`: Destructive action
|
|
9
|
-
* - `'destructive'`: Alias for danger
|
|
9
|
+
* - `'destructive'`: Alias for `'danger'` — mapped internally before
|
|
10
|
+
* class generation. Provided for readability in consumer code where
|
|
11
|
+
* "destructive" better describes the intent (e.g. `theme="destructive"`).
|
|
10
12
|
*/
|
|
11
13
|
export type Theme = ThemeIntent | 'destructive';
|
|
12
14
|
/**
|
|
@@ -60,6 +62,12 @@ type CommonProps = {
|
|
|
60
62
|
* @default false
|
|
61
63
|
*/
|
|
62
64
|
loading?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Accessible label override during loading state. For i18n support.
|
|
67
|
+
* When loading, this replaces the auto-composed label.
|
|
68
|
+
* @default `${label}, loading` (English)
|
|
69
|
+
*/
|
|
70
|
+
loadingLabel?: string;
|
|
63
71
|
/**
|
|
64
72
|
* Link target (for anchor variant).
|
|
65
73
|
*/
|
package/components/Card/Card.js
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import React, { forwardRef, useRef, useEffect } from 'react';
|
|
2
|
+
import React, { forwardRef, useRef, useEffect, createContext, useContext } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { isDev } from '../../utils/is-dev.js';
|
|
5
|
+
const CardDisabledContext = createContext(false);
|
|
5
6
|
export const Card = forwardRef(function Card({ as = 'article', inline, elevated, interactive, disabled, className, style, onClick, children, ...rest }, ref) {
|
|
6
7
|
const Tag = as;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const type = child.type;
|
|
11
|
-
return child.type === CardLink || type.displayName === 'Card.Link';
|
|
12
|
-
});
|
|
13
|
-
// Dev warning: suggest Card.Link for interactive cards
|
|
8
|
+
// Dev warning: suggest Card.Link for interactive cards.
|
|
9
|
+
// The recursive children walk is gated behind isDev() to avoid
|
|
10
|
+
// unnecessary work in production on every render.
|
|
14
11
|
const warnedRef = useRef(false);
|
|
15
12
|
useEffect(() => {
|
|
16
|
-
if (isDev()
|
|
13
|
+
if (!isDev() || warnedRef.current || !(interactive || onClick))
|
|
14
|
+
return;
|
|
15
|
+
const hasCardLinkChild = (function checkChildren(nodes) {
|
|
16
|
+
return React.Children.toArray(nodes).some((child) => {
|
|
17
|
+
if (!React.isValidElement(child))
|
|
18
|
+
return false;
|
|
19
|
+
const type = child.type;
|
|
20
|
+
if (child.type === CardLink || type.displayName === 'Card.Link')
|
|
21
|
+
return true;
|
|
22
|
+
const childProps = child.props;
|
|
23
|
+
if (childProps.children)
|
|
24
|
+
return checkChildren(childProps.children);
|
|
25
|
+
return false;
|
|
26
|
+
});
|
|
27
|
+
})(children);
|
|
28
|
+
if (!hasCardLinkChild) {
|
|
17
29
|
warnedRef.current = true;
|
|
18
30
|
console.warn('[TUI Card] Interactive cards should use <Card.Link> for accessible click targets. ' +
|
|
19
31
|
'`interactive` and `onClick` provide visual hover styles but no keyboard/screen-reader semantics.');
|
|
20
32
|
}
|
|
21
|
-
}
|
|
33
|
+
});
|
|
22
34
|
const classes = cx('tui-card', inline && 'is-layout-inline', elevated && 'is-style-elevated', interactive && 'has-interaction', className);
|
|
23
|
-
return (_jsx(Tag, { ref: ref, className: classes, style: style, "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onClick, ...rest, children: _jsx("div", { className: "tui-card__inner", children: children }) }));
|
|
35
|
+
return (_jsx(Tag, { ref: ref, className: classes, style: style, "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onClick, ...rest, children: _jsx(CardDisabledContext.Provider, { value: !!disabled, children: _jsx("div", { className: "tui-card__inner", children: children }) }) }));
|
|
24
36
|
});
|
|
25
37
|
function CardHead({ className, children, ...rest }) {
|
|
26
38
|
return (_jsx("div", { className: cx('tui-card__head', className), ...rest, children: children }));
|
|
@@ -34,8 +46,9 @@ function CardFoot({ className, children, ...rest }) {
|
|
|
34
46
|
return (_jsx("div", { className: cx('tui-card__foot', className), ...rest, children: children }));
|
|
35
47
|
}
|
|
36
48
|
CardFoot.displayName = 'Card.Foot';
|
|
37
|
-
function CardLink({ className, children, rel, target, ...rest }) {
|
|
38
|
-
|
|
49
|
+
function CardLink({ className, children, rel, target, href, ...rest }) {
|
|
50
|
+
const isDisabled = useContext(CardDisabledContext);
|
|
51
|
+
return (_jsx("a", { className: cx('tui-stretched-link', className), rel: target === '_blank' ? ['noopener', 'noreferrer', rel].filter(Boolean).join(' ') : rel, target: isDisabled ? undefined : target, href: isDisabled ? undefined : href, role: isDisabled ? 'link' : undefined, "aria-disabled": isDisabled || undefined, tabIndex: isDisabled ? -1 : undefined, ...rest, children: children }));
|
|
39
52
|
}
|
|
40
53
|
CardLink.displayName = 'Card.Link';
|
|
41
54
|
Card.Head = CardHead;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const Checkbox: import("react").ForwardRefExoticComponent<Omit<import("react").InputHTMLAttributes<HTMLInputElement>, "role" | "type" | "
|
|
1
|
+
export declare const Checkbox: import("react").ForwardRefExoticComponent<Omit<import("react").InputHTMLAttributes<HTMLInputElement>, "role" | "type" | "onChange" | "defaultChecked" | "checked"> & {
|
|
2
2
|
checked?: boolean;
|
|
3
3
|
defaultChecked?: boolean;
|
|
4
4
|
onCheckedChange?: (checked: boolean) => void;
|
|
@@ -5,6 +5,15 @@ type Size = SizeCompact;
|
|
|
5
5
|
type Theme = ThemeFull;
|
|
6
6
|
type Variant = 'default' | 'outline' | 'ghost' | 'solid' | 'flush';
|
|
7
7
|
export type ChipProps = {
|
|
8
|
+
/**
|
|
9
|
+
* HTML element to render.
|
|
10
|
+
* - `'span'` (default): Inline element. Use for chips within text flow,
|
|
11
|
+
* inline lists, or inside `<p>`/`<li>` elements.
|
|
12
|
+
* - `'div'`: Block element. Use when the chip contains block-level content
|
|
13
|
+
* or needs to be a flex/grid child without inline constraints.
|
|
14
|
+
* - `'a'`: Anchor element. Use for navigational chips with an `href`.
|
|
15
|
+
* @default 'span'
|
|
16
|
+
*/
|
|
8
17
|
as?: 'span' | 'div' | 'a';
|
|
9
18
|
href?: string;
|
|
10
19
|
target?: React.HTMLAttributeAnchorTarget;
|
|
@@ -23,5 +32,32 @@ export type ChipProps = {
|
|
|
23
32
|
/** When inside ChipGroup, identifies this chip for selection tracking */
|
|
24
33
|
value?: OptionValue;
|
|
25
34
|
} & Omit<React.HTMLAttributes<HTMLSpanElement>, 'onClick'>;
|
|
26
|
-
export declare
|
|
35
|
+
export declare const Chip: React.ForwardRefExoticComponent<{
|
|
36
|
+
/**
|
|
37
|
+
* HTML element to render.
|
|
38
|
+
* - `'span'` (default): Inline element. Use for chips within text flow,
|
|
39
|
+
* inline lists, or inside `<p>`/`<li>` elements.
|
|
40
|
+
* - `'div'`: Block element. Use when the chip contains block-level content
|
|
41
|
+
* or needs to be a flex/grid child without inline constraints.
|
|
42
|
+
* - `'a'`: Anchor element. Use for navigational chips with an `href`.
|
|
43
|
+
* @default 'span'
|
|
44
|
+
*/
|
|
45
|
+
as?: "span" | "div" | "a";
|
|
46
|
+
href?: string;
|
|
47
|
+
target?: React.HTMLAttributeAnchorTarget;
|
|
48
|
+
rel?: string;
|
|
49
|
+
children: React.ReactNode;
|
|
50
|
+
size?: Size;
|
|
51
|
+
theme?: Theme;
|
|
52
|
+
variant?: Variant;
|
|
53
|
+
selected?: boolean;
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
interactive?: boolean;
|
|
56
|
+
className?: string;
|
|
57
|
+
leftIcon?: React.ReactNode;
|
|
58
|
+
rightIcon?: React.ReactNode;
|
|
59
|
+
onClick?: React.MouseEventHandler<HTMLElement>;
|
|
60
|
+
/** When inside ChipGroup, identifies this chip for selection tracking */
|
|
61
|
+
value?: OptionValue;
|
|
62
|
+
} & Omit<React.HTMLAttributes<HTMLSpanElement>, "onClick"> & React.RefAttributes<HTMLElement>>;
|
|
27
63
|
export {};
|
package/components/Chip/Chip.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import * as React from 'react';
|
|
3
|
-
import { useCallback, useEffect } from 'react';
|
|
3
|
+
import { useCallback, useEffect, forwardRef } from 'react';
|
|
4
4
|
import { cx } from '../../utils/cx.js';
|
|
5
5
|
import { toKey } from '../../utils/value-key.js';
|
|
6
6
|
import { isDev } from '../../utils/is-dev.js';
|
|
7
7
|
import { useChipGroupContext } from '../ChipGroup/ChipGroupContext.js';
|
|
8
|
-
export function Chip({ as = 'span', href, target, rel, children, size = 'md', theme = 'secondary', variant = 'default', selected: selectedProp, disabled: disabledProp, interactive, className, leftIcon, rightIcon, onClick: onClickProp, value, ...rest }) {
|
|
8
|
+
export const Chip = forwardRef(function Chip({ as = 'span', href, target, rel, children, size = 'md', theme = 'secondary', variant = 'default', selected: selectedProp, disabled: disabledProp, interactive, className, leftIcon, rightIcon, onClick: onClickProp, value, ...rest }, ref) {
|
|
9
9
|
const groupContext = useChipGroupContext();
|
|
10
10
|
// Dev warning: inside ChipGroup without value
|
|
11
11
|
useEffect(() => {
|
|
@@ -25,7 +25,7 @@ export function Chip({ as = 'span', href, target, rel, children, size = 'md', th
|
|
|
25
25
|
const onClick = isManaged ? managedClick : onClickProp;
|
|
26
26
|
const Tag = (as === 'a' ? 'a' : as);
|
|
27
27
|
// Determine if this chip is clickable (needs button semantics)
|
|
28
|
-
const isClickable = (
|
|
28
|
+
const isClickable = (!!onClick || isManaged) && !disabled;
|
|
29
29
|
// Keyboard handler for interactive chips
|
|
30
30
|
const handleKeyDown = useCallback((e) => {
|
|
31
31
|
if (!isClickable || !onClick)
|
|
@@ -38,7 +38,7 @@ export function Chip({ as = 'span', href, target, rel, children, size = 'md', th
|
|
|
38
38
|
const classes = cx('tui-chip', size && `is-size-${size}`, theme && `is-theme-${theme}`, variant !== 'default' && `is-style-${variant}`, selected && 'is-selected', (interactive || isManaged) && 'is-interactive', className);
|
|
39
39
|
const anchorProps = as === 'a'
|
|
40
40
|
? {
|
|
41
|
-
href: disabled ? undefined : href ?? '#',
|
|
41
|
+
href: disabled || isManaged ? undefined : href ?? '#',
|
|
42
42
|
target,
|
|
43
43
|
rel,
|
|
44
44
|
'aria-disabled': disabled || undefined,
|
|
@@ -49,14 +49,16 @@ export function Chip({ as = 'span', href, target, rel, children, size = 'md', th
|
|
|
49
49
|
// Non-anchor clickable chips always need role="button".
|
|
50
50
|
// Managed anchor chips also get role="button" — toggle semantics
|
|
51
51
|
// take priority over link semantics inside a ChipGroup.
|
|
52
|
-
|
|
52
|
+
// Disabled managed chips keep role="button" so they remain visible to AT.
|
|
53
|
+
const needsButtonRole = (isClickable || (isManaged && disabled)) && (as !== 'a' || isManaged);
|
|
53
54
|
const buttonProps = needsButtonRole
|
|
54
55
|
? {
|
|
55
56
|
role: 'button',
|
|
56
|
-
tabIndex: as !== 'a' ? 0 : undefined,
|
|
57
|
+
tabIndex: disabled ? -1 : (as !== 'a' ? 0 : undefined),
|
|
57
58
|
onKeyDown: handleKeyDown,
|
|
58
59
|
'aria-pressed': isManaged ? (selected ?? false) : undefined,
|
|
60
|
+
'aria-disabled': disabled || undefined,
|
|
59
61
|
}
|
|
60
62
|
: {};
|
|
61
|
-
return (_jsxs(Tag, { className: classes, ...anchorProps, ...buttonProps, onClick: disabled ? undefined : onClick, ...rest, children: [leftIcon && leftIcon, _jsx("span", { className: "tui-chip__text", children: children }), rightIcon && rightIcon] }));
|
|
62
|
-
}
|
|
63
|
+
return (_jsxs(Tag, { ref: ref, className: classes, ...anchorProps, ...buttonProps, onClick: disabled ? undefined : onClick, ...rest, children: [leftIcon && leftIcon, _jsx("span", { className: "tui-chip__text", children: children }), rightIcon && rightIcon] }));
|
|
64
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useMemo } from 'react';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useId, useMemo } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { useControllableState } from '../../utils/use-controllable-state.js';
|
|
5
5
|
import { toKey } from '../../utils/value-key.js';
|
|
@@ -17,7 +17,7 @@ import { isDev } from '../../utils/is-dev.js';
|
|
|
17
17
|
//
|
|
18
18
|
// =============================================================================
|
|
19
19
|
export function ChipGroup(props) {
|
|
20
|
-
const { multiple = false, disabled = false, density = 'sm', direction = 'inline', alignment, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, } = props;
|
|
20
|
+
const { multiple = false, disabled = false, density = 'sm', direction = 'inline', alignment, multipleLabel = 'Multiple selections allowed', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, } = props;
|
|
21
21
|
// --- Single mode ---
|
|
22
22
|
// isControlled override: 'value' in props detects explicit value={undefined}
|
|
23
23
|
// (deselection) vs prop not passed at all (uncontrolled).
|
|
@@ -57,12 +57,13 @@ export function ChipGroup(props) {
|
|
|
57
57
|
}
|
|
58
58
|
}, [multiple, setSingleValue, setMultiValue]);
|
|
59
59
|
const contextValue = useMemo(() => ({ selectedValues, multiple, disabled, onSelect }), [selectedValues, multiple, disabled, onSelect]);
|
|
60
|
+
const descriptionId = useId();
|
|
60
61
|
// Dev-only: warn if group has no accessible name
|
|
61
62
|
useEffect(() => {
|
|
62
63
|
if (isDev() && !ariaLabel && !ariaLabelledBy) {
|
|
63
64
|
console.warn('ChipGroup: Missing accessible name. Provide aria-label or aria-labelledby.');
|
|
64
65
|
}
|
|
65
66
|
}, [ariaLabel, ariaLabelledBy]);
|
|
66
|
-
return (_jsx(ChipGroupContext.Provider, { value: contextValue, children:
|
|
67
|
+
return (_jsx(ChipGroupContext.Provider, { value: contextValue, children: _jsxs("div", { role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-disabled": disabled || undefined, "aria-describedby": multiple ? descriptionId : undefined, className: cx('tui-chip-group', `is-density-${density}`, direction === 'stack' && 'is-direction-stack', alignment && `is-align-${alignment}`, className), children: [children, multiple && (_jsx("span", { id: descriptionId, className: "tui-visually-hidden", children: multipleLabel }))] }) }));
|
|
67
68
|
}
|
|
68
69
|
ChipGroup.displayName = 'ChipGroup';
|
|
@@ -8,6 +8,9 @@ type ChipGroupBaseProps = {
|
|
|
8
8
|
direction?: 'inline' | 'stack';
|
|
9
9
|
/** Alignment along main axis. */
|
|
10
10
|
alignment?: 'start' | 'center' | 'end';
|
|
11
|
+
/** Visually hidden label describing multi-select behaviour. For i18n support.
|
|
12
|
+
* @default 'Multiple selections allowed' */
|
|
13
|
+
multipleLabel?: string;
|
|
11
14
|
'aria-label'?: string;
|
|
12
15
|
'aria-labelledby'?: string;
|
|
13
16
|
className?: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DropdownProps, DropdownTriggerProps, DropdownContentProps, DropdownItemProps } from './types';
|
|
1
|
+
import type { DropdownProps, DropdownTriggerProps, DropdownContentProps, DropdownItemProps, DropdownSeparatorProps, DropdownHeaderProps } from './types';
|
|
2
2
|
declare function DropdownRoot({ open: controlledOpen, onOpenChange, defaultOpen, children, }: DropdownProps): import("react/jsx-runtime").JSX.Element;
|
|
3
3
|
declare namespace DropdownRoot {
|
|
4
4
|
var displayName: string;
|
|
@@ -19,13 +19,31 @@ declare function DropdownItemComponent({ onSelect, href, target, disabled, keepO
|
|
|
19
19
|
declare namespace DropdownItemComponent {
|
|
20
20
|
var displayName: string;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Non-interactive separator for visually grouping menu items.
|
|
24
|
+
*/
|
|
25
|
+
declare function DropdownSeparatorComponent({ className }: DropdownSeparatorProps): import("react/jsx-runtime").JSX.Element;
|
|
26
|
+
declare namespace DropdownSeparatorComponent {
|
|
27
|
+
var displayName: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Non-interactive section label for grouping menu items.
|
|
31
|
+
*/
|
|
32
|
+
declare function DropdownHeaderComponent({ className, children }: DropdownHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
33
|
+
declare namespace DropdownHeaderComponent {
|
|
34
|
+
var displayName: string;
|
|
35
|
+
}
|
|
22
36
|
type DropdownCompound = typeof DropdownRoot & {
|
|
23
37
|
Trigger: typeof DropdownTriggerComponent;
|
|
24
38
|
Content: typeof DropdownContentComponent;
|
|
25
39
|
Item: typeof DropdownItemComponent;
|
|
40
|
+
Separator: typeof DropdownSeparatorComponent;
|
|
41
|
+
Header: typeof DropdownHeaderComponent;
|
|
26
42
|
};
|
|
27
43
|
export declare const Dropdown: DropdownCompound;
|
|
28
44
|
export declare const DropdownTrigger: typeof DropdownTriggerComponent;
|
|
29
45
|
export declare const DropdownContent: typeof DropdownContentComponent;
|
|
30
46
|
export declare const DropdownItem: typeof DropdownItemComponent;
|
|
47
|
+
export declare const DropdownSeparator: typeof DropdownSeparatorComponent;
|
|
48
|
+
export declare const DropdownHeader: typeof DropdownHeaderComponent;
|
|
31
49
|
export { useDropdownContext as useDropdown } from './DropdownContext';
|