@tangible/ui 0.0.4 → 0.0.6

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 CHANGED
@@ -1,17 +1,17 @@
1
1
  # Tangible UI
2
2
 
3
- Design system for Tangible WordPress plugins. React components + CSS tokens + utility classes.
3
+ Design system for Tangible WordPress plugins. React components, CSS custom property tokens, and CSS-only form elements.
4
4
 
5
5
  **Live Storybook:** https://storybook-tangible-ui.pages.dev
6
6
 
7
7
  ## Components
8
8
 
9
- - **Primitives:** Button, Chip, ChipGroup, Icon, IconButton, Progress, Rating, Tooltip
9
+ - **Primitives:** Button, IconButton, Chip, ChipGroup, Icon, Progress, Rating, Tooltip
10
10
  - **Layout:** Accordion, Card, Modal, Notice, Sidebar, Tabs, Toolbar
11
11
  - **Data:** DataTable, StepList, StepIndicator, Pager
12
- - **Form Controls:** Select, MultiSelect, Combobox, TextInput, Textarea, Checkbox, Switch, Radio
12
+ - **Form Controls:** Select, MultiSelect, Combobox, TextInput, Textarea, Checkbox, Switch, Radio/RadioGroup
13
13
  - **Composites:** Avatar, Dropdown, MoveHandle, OverlapStack, SegmentedControl, Field
14
- - **CSS-only Inputs:** Text, textarea, select, checkbox, radio, toggle, file
14
+ - **CSS-only Inputs:** Text, textarea, select, checkbox, radio, toggle, file (no JS required)
15
15
 
16
16
  ## Getting Started
17
17
 
@@ -21,21 +21,40 @@ Design system for Tangible WordPress plugins. React components + CSS tokens + ut
21
21
  npm install @tangible/ui
22
22
  ```
23
23
 
24
+ #### Optional peer dependencies
25
+
26
+ Some components require additional packages. Install only what you use:
27
+
28
+ | Package | Required by | Size |
29
+ |---------|-------------|------|
30
+ | `@floating-ui/react` | Select, MultiSelect, Combobox, Dropdown, Tooltip | ~90 KB |
31
+ | `@tanstack/react-table` | DataTable | ~50 KB |
32
+
33
+ ```bash
34
+ # If using Select, Dropdown, Tooltip, etc.
35
+ npm install @floating-ui/react
36
+
37
+ # If using DataTable
38
+ npm install @tanstack/react-table
39
+ ```
40
+
41
+ Components without these dependencies (Button, Card, Accordion, Modal, Tabs, etc.) work with zero additional installs.
42
+
24
43
  ### Import styles
25
44
 
26
45
  ```tsx
27
- // In your app entry point
28
46
  import '@tangible/ui/styles';
29
47
  ```
30
48
 
31
- Or for WordPress contexts (no CSS layers):
49
+ For WordPress plugin contexts where CSS layers can lose to unlayered theme styles:
50
+
32
51
  ```tsx
33
52
  import '@tangible/ui/styles/unlayered';
34
53
  ```
35
54
 
36
- ### Wrap your app
55
+ ### Set up the interface wrapper
37
56
 
38
- Components require the `.tui-interface` wrapper to access design tokens:
57
+ All components require the `.tui-interface` wrapper to access design tokens:
39
58
 
40
59
  ```tsx
41
60
  function App() {
@@ -47,34 +66,112 @@ function App() {
47
66
  }
48
67
  ```
49
68
 
50
- Dark mode via `data-theme="dark"` on the wrapper.
69
+ ### Dark mode
70
+
71
+ Set `data-theme` on the wrapper:
72
+
73
+ ```tsx
74
+ <div className="tui-interface" data-theme="dark">
75
+ ```
76
+
77
+ - `"dark"` — force dark mode
78
+ - `"auto"` — follow `prefers-color-scheme`
79
+ - Omit attribute — light mode (inherits host colour)
51
80
 
52
81
  ### Use components
53
82
 
54
83
  ```tsx
55
- import { Button, Card, Tooltip, IconButton } from '@tangible/ui';
84
+ import { Button, Card, Select, SelectOption } from '@tangible/ui';
56
85
 
57
86
  function Example() {
58
87
  return (
59
88
  <Card>
60
89
  <Card.Body>
61
- <Button label="Click me" theme="primary" />
62
-
63
- <IconButton icon="system/settings" label="Settings" showTooltip />
64
-
65
- <Tooltip>
66
- <Tooltip.Trigger asChild>
67
- <Button label="Hover me" variant="outline" />
68
- </Tooltip.Trigger>
69
- <Tooltip.Content>Hello!</Tooltip.Content>
70
- </Tooltip>
90
+ <Button label="Save" theme="primary" />
91
+
92
+ <Select placeholder="Choose..." onValueChange={(v) => console.log(v)}>
93
+ <Select.Trigger />
94
+ <Select.Content>
95
+ <Select.Option value="a">Option A</Select.Option>
96
+ <Select.Option value="b">Option B</Select.Option>
97
+ </Select.Content>
98
+ </Select>
71
99
  </Card.Body>
72
100
  </Card>
73
101
  );
74
102
  }
75
103
  ```
76
104
 
77
- See the [Storybook](https://storybook-tangible-ui.pages.dev) for full component documentation and examples.
105
+ ### Tree-shaking
106
+
107
+ Individual component imports are available if your bundler doesn't tree-shake the barrel export:
108
+
109
+ ```tsx
110
+ import { Button } from '@tangible/ui/components/Button';
111
+ import { Tooltip } from '@tangible/ui/components/Tooltip';
112
+ ```
113
+
114
+ ## Customisation
115
+
116
+ ### Token overrides
117
+
118
+ Components are styled via CSS custom properties. Override them on `.tui-interface` or any ancestor:
119
+
120
+ ```css
121
+ /* Global overrides */
122
+ .tui-interface {
123
+ --tui-radius-md: 2px;
124
+ --tui-focus-ring-color: hotpink;
125
+ }
126
+
127
+ /* Scoped to a specific context */
128
+ .my-sidebar .tui-interface {
129
+ --tui-button-radius: 0;
130
+ --tui-control-height-md: 32px;
131
+ }
132
+ ```
133
+
134
+ ### Component API tokens
135
+
136
+ Each component reads its own `--tui-{component}-*` tokens via fallback chains. These are never defined by TUI — only read. Set them from consuming code:
137
+
138
+ ```css
139
+ .compact-form {
140
+ --tui-accordion-padding: 8px;
141
+ --tui-select-trigger-radius: 2px;
142
+ --tui-modal-spacing: 24px;
143
+ }
144
+ ```
145
+
146
+ See each component's `styles.scss` header for its full token API.
147
+
148
+ ### Form control sizing
149
+
150
+ All form controls share a unified sizing system:
151
+
152
+ ```css
153
+ .my-context .tui-interface {
154
+ --tui-control-height-sm: 28px;
155
+ --tui-control-height-md: 32px;
156
+ --tui-control-height-lg: 40px;
157
+
158
+ /* Optional: decouple font size from size tier */
159
+ --tui-control-font-size-sm: 13px;
160
+ --tui-control-font-size-md: 13px;
161
+ --tui-control-font-size-lg: 14px;
162
+ }
163
+ ```
164
+
165
+ ### Icons
166
+
167
+ Four icon sets available via the registry: `system`, `cred`, `reaction`, `player`.
168
+
169
+ ```tsx
170
+ import { Icon } from '@tangible/ui';
171
+
172
+ <Icon name="system/check" />
173
+ <Icon name="reaction/clap-fill" size="lg" />
174
+ ```
78
175
 
79
176
  ## Development
80
177
 
@@ -83,7 +180,7 @@ npm install
83
180
  npm run storybook # Dev server on port 6006
84
181
  ```
85
182
 
86
- ## Commands
183
+ ### Commands
87
184
 
88
185
  ```bash
89
186
  npm run storybook # Dev server
@@ -97,12 +194,12 @@ npm run test:visual:update # Regenerate visual baselines
97
194
 
98
195
  ## Documentation
99
196
 
100
- - `CLAUDE.md` — Development guide (architecture, patterns, conventions)
101
- - `CONTEXT.md` — Project background and LMS requirements
102
- - `TIMELINE.md` — Development roadmap (Jan–Mar 2026)
197
+ - `CLAUDE.md` — Architecture, patterns, conventions, gotchas
198
+ - `CONTEXT.md` — Project background and design philosophy
199
+ - `TIMELINE.md` — Development roadmap
103
200
  - `TESTING.md` — Testing strategy and infrastructure
104
- - `AGENTS.md` — Quality gate agent configurations
201
+ - `CHANGELOG.md` — Release history
105
202
 
106
203
  ## Status
107
204
 
108
- Under active development for Course Builder (LMS) and Quiz modules. Component APIs are stabilising but may change before 1.0.
205
+ Under active development. Component APIs are stabilising but may change before 1.0.
@@ -10,7 +10,7 @@ declare function AccordionItem({ value, disabled, headingLevel, children, classN
10
10
  declare namespace AccordionItem {
11
11
  var displayName: string;
12
12
  }
13
- declare function AccordionTrigger({ children, className }: AccordionTriggerProps): import("react/jsx-runtime").JSX.Element;
13
+ declare function AccordionTrigger({ asChild, 'aria-label': ariaLabel, children, className }: AccordionTriggerProps): import("react/jsx-runtime").JSX.Element;
14
14
  declare namespace AccordionTrigger {
15
15
  var displayName: string;
16
16
  }
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
2
+ import React, { cloneElement, isValidElement, useCallback, useId, useMemo, useRef, useState } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
+ import { composeRefs } from '../../utils/compose-refs.js';
5
+ import { mergeProps } from '../../utils/merge-props.js';
4
6
  import { Icon } from '../Icon/index.js';
5
7
  import { AccordionContext, AccordionItemContext, useAccordionContext, useAccordionItemContext } from './AccordionContext.js';
6
8
  function AccordionRoot(props) {
7
- const { type, children, className } = props;
9
+ const { type, variant = 'card', children, className } = props;
8
10
  // Track trigger refs for keyboard navigation
9
11
  const triggersRef = useRef(new Map());
10
12
  const registerTrigger = useCallback((value, element) => {
@@ -18,11 +20,11 @@ function AccordionRoot(props) {
18
20
  const getTriggers = useCallback(() => triggersRef.current, []);
19
21
  // State management differs by type
20
22
  if (type === 'single') {
21
- return (_jsx(AccordionSingle, { ...props, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
23
+ return (_jsx(AccordionSingle, { ...props, variant: variant, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
22
24
  }
23
- return (_jsx(AccordionMultiple, { ...props, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
25
+ return (_jsx(AccordionMultiple, { ...props, variant: variant, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
24
26
  }
25
- function AccordionSingle({ value: controlledValue, defaultValue, onValueChange, collapsible = false, children, className, registerTrigger, getTriggers, }) {
27
+ function AccordionSingle({ value: controlledValue, defaultValue, onValueChange, collapsible = false, variant, children, className, registerTrigger, getTriggers, }) {
26
28
  const [internalValue, setInternalValue] = useState(defaultValue);
27
29
  const isControlled = controlledValue !== undefined;
28
30
  const currentValue = isControlled ? controlledValue : internalValue;
@@ -37,6 +39,8 @@ function AccordionSingle({ value: controlledValue, defaultValue, onValueChange,
37
39
  // Opening new item
38
40
  newValue = itemValue;
39
41
  }
42
+ if (newValue === currentValue)
43
+ return;
40
44
  if (!isControlled) {
41
45
  setInternalValue(newValue);
42
46
  }
@@ -50,9 +54,9 @@ function AccordionSingle({ value: controlledValue, defaultValue, onValueChange,
50
54
  registerTrigger,
51
55
  getTriggers,
52
56
  }), [collapsible, isOpen, toggle, registerTrigger, getTriggers]);
53
- return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', className), "data-type": "single", children: children }) }));
57
+ return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', `is-variant-${variant}`, className), "data-type": "single", children: children }) }));
54
58
  }
55
- function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange, children, className, registerTrigger, getTriggers, }) {
59
+ function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange, variant, children, className, registerTrigger, getTriggers, }) {
56
60
  const [internalValue, setInternalValue] = useState(defaultValue ?? []);
57
61
  const isControlled = controlledValue !== undefined;
58
62
  const currentValue = isControlled ? controlledValue : internalValue;
@@ -74,7 +78,7 @@ function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange
74
78
  registerTrigger,
75
79
  getTriggers,
76
80
  }), [isOpen, toggle, registerTrigger, getTriggers]);
77
- return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', className), "data-type": "multiple", children: children }) }));
81
+ return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', `is-variant-${variant}`, className), "data-type": "multiple", children: children }) }));
78
82
  }
79
83
  // =============================================================================
80
84
  // Accordion Item
@@ -99,21 +103,49 @@ function AccordionItem({ value, disabled = false, headingLevel, children, classN
99
103
  // =============================================================================
100
104
  // Accordion Trigger
101
105
  // =============================================================================
102
- function AccordionTrigger({ children, className }) {
106
+ function AccordionTrigger({ asChild = false, 'aria-label': ariaLabel, children, className }) {
103
107
  const { toggle, registerTrigger, getTriggers } = useAccordionContext();
104
108
  const { value, disabled, headingLevel, triggerId, panelId, isOpen } = useAccordionItemContext();
105
- const buttonRef = useRef(null);
109
+ const triggerRef = useRef(null);
106
110
  // Register trigger for keyboard navigation
107
111
  React.useEffect(() => {
108
- registerTrigger(value, buttonRef.current);
112
+ registerTrigger(value, triggerRef.current);
109
113
  return () => registerTrigger(value, null);
110
114
  }, [value, registerTrigger]);
111
- const handleClick = () => {
112
- if (!disabled) {
113
- toggle(value);
115
+ const handleClick = (event) => {
116
+ if (disabled)
117
+ return;
118
+ // In asChild mode, the trigger is a container with nested interactive elements.
119
+ // Only toggle when the click is on the trigger itself or passive content (text,
120
+ // chips, icons) — not when it originates from a nested button, link, or input.
121
+ if (asChild) {
122
+ const target = event.target;
123
+ const currentTarget = event.currentTarget;
124
+ if (target !== currentTarget) {
125
+ const interactive = target.closest('button, a, input, select, textarea, [role="button"], [role="link"]');
126
+ if (interactive && interactive !== currentTarget && currentTarget.contains(interactive)) {
127
+ return;
128
+ }
129
+ }
114
130
  }
131
+ toggle(value);
115
132
  };
116
133
  const handleKeyDown = (event) => {
134
+ // In asChild mode, only handle keyboard events when the trigger element
135
+ // itself has focus — not when focus is on a nested interactive child.
136
+ // (A native <button> trigger can't have focusable children, so this
137
+ // check is only needed for asChild.)
138
+ if (asChild && event.target !== event.currentTarget)
139
+ return;
140
+ // Enter/Space activation for asChild mode. Native <button> elements fire
141
+ // click on Enter/Space automatically; role="button" elements do not.
142
+ if (asChild && (event.key === 'Enter' || event.key === ' ')) {
143
+ event.preventDefault();
144
+ if (!disabled) {
145
+ toggle(value);
146
+ }
147
+ return;
148
+ }
117
149
  const triggers = getTriggers();
118
150
  // Sort triggers by DOM order (not Map insertion order) to handle
119
151
  // conditional rendering, async mounting, or reordered items
@@ -124,14 +156,16 @@ function AccordionTrigger({ children, className }) {
124
156
  return 1;
125
157
  });
126
158
  const currentIndex = sortedTriggers.findIndex(([v]) => v === value);
127
- // Find next/prev non-disabled trigger
159
+ // Find next/prev non-disabled trigger — check both native disabled (button)
160
+ // and aria-disabled (asChild elements)
161
+ const isElementDisabled = (el) => el.disabled || el.getAttribute('aria-disabled') === 'true';
128
162
  const findNextIndex = (start, direction) => {
129
163
  let index = start;
130
164
  const len = sortedTriggers.length;
131
165
  for (let i = 0; i < len; i++) {
132
166
  index = (index + direction + len) % len;
133
167
  const [, triggerElement] = sortedTriggers[index];
134
- if (triggerElement && !triggerElement.disabled) {
168
+ if (triggerElement && !isElementDisabled(triggerElement)) {
135
169
  return index;
136
170
  }
137
171
  }
@@ -147,14 +181,24 @@ function AccordionTrigger({ children, className }) {
147
181
  event.preventDefault();
148
182
  targetIndex = findNextIndex(currentIndex, -1);
149
183
  break;
150
- case 'Home':
184
+ case 'Home': {
151
185
  event.preventDefault();
152
- targetIndex = findNextIndex(sortedTriggers.length - 1, 1); // Start from end, go forward
186
+ const first = sortedTriggers.findIndex(([, el]) => !isElementDisabled(el));
187
+ if (first !== -1)
188
+ targetIndex = first;
153
189
  break;
154
- case 'End':
190
+ }
191
+ case 'End': {
155
192
  event.preventDefault();
156
- targetIndex = findNextIndex(0, -1); // Start from beginning, go backward
193
+ // Scan from end to find last non-disabled trigger
194
+ for (let i = sortedTriggers.length - 1; i >= 0; i--) {
195
+ if (!isElementDisabled(sortedTriggers[i][1])) {
196
+ targetIndex = i;
197
+ break;
198
+ }
199
+ }
157
200
  break;
201
+ }
158
202
  }
159
203
  if (targetIndex !== null && targetIndex !== currentIndex) {
160
204
  const [, targetElement] = sortedTriggers[targetIndex];
@@ -162,7 +206,34 @@ function AccordionTrigger({ children, className }) {
162
206
  }
163
207
  };
164
208
  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: "lg", className: "tui-accordion__indicator", "aria-hidden": "true" })] }));
209
+ // asChild: merge trigger props onto the child element
210
+ if (asChild && isValidElement(children)) {
211
+ const triggerProps = {
212
+ id: triggerId,
213
+ role: 'button',
214
+ tabIndex: disabled ? -1 : 0,
215
+ 'aria-expanded': isOpen,
216
+ 'aria-controls': panelId,
217
+ 'aria-disabled': disabled || undefined,
218
+ 'aria-label': ariaLabel,
219
+ 'data-state': state,
220
+ 'data-disabled': disabled || undefined,
221
+ className: cx('tui-accordion__trigger', className),
222
+ onClick: handleClick,
223
+ onKeyDown: handleKeyDown,
224
+ };
225
+ const childRef = children.ref;
226
+ const merged = mergeProps(children.props, triggerProps);
227
+ merged.ref = composeRefs(triggerRef, childRef);
228
+ const element = cloneElement(children, merged);
229
+ if (headingLevel) {
230
+ const Heading = `h${headingLevel}`;
231
+ return _jsx(Heading, { className: "tui-accordion__heading", children: element });
232
+ }
233
+ return element;
234
+ }
235
+ // Default: render as button with built-in chevron indicator
236
+ const button = (_jsxs("button", { ref: triggerRef, type: "button", id: triggerId, className: cx('tui-accordion__trigger', className), "aria-expanded": isOpen, "aria-controls": panelId, "aria-label": ariaLabel, disabled: disabled, "data-state": state, "data-disabled": disabled || undefined, onClick: handleClick, onKeyDown: handleKeyDown, children: [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
237
  // Wrap in heading if headingLevel is specified
167
238
  if (headingLevel) {
168
239
  const Heading = `h${headingLevel}`;
@@ -176,7 +247,7 @@ function AccordionTrigger({ children, className }) {
176
247
  function AccordionPanel({ landmark = false, children, className }) {
177
248
  const { triggerId, panelId, isOpen } = useAccordionItemContext();
178
249
  const state = isOpen ? 'open' : 'closed';
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,
250
+ return (_jsx("div", { id: panelId, role: landmark ? 'region' : undefined, "aria-labelledby": landmark ? triggerId : undefined, className: cx('tui-accordion__panel', className), "data-state": state, "aria-hidden": !isOpen,
180
251
  // Prevent keyboard focus into collapsed panels
181
252
  inert: !isOpen || undefined, children: _jsx("div", { className: "tui-accordion__panel-content", children: children }) }));
182
253
  }
@@ -1,2 +1,2 @@
1
1
  export { Accordion } from './Accordion';
2
- export type { AccordionProps, AccordionSingleProps, AccordionMultipleProps, AccordionItemProps, AccordionTriggerProps, AccordionPanelProps, } from './types';
2
+ export type { AccordionProps, AccordionSingleProps, AccordionMultipleProps, AccordionItemProps, AccordionTriggerProps, AccordionPanelProps, AccordionVariant, } from './types';
@@ -1,5 +1,12 @@
1
1
  import type { ReactNode } from 'react';
2
+ export type AccordionVariant = 'card' | 'flush';
2
3
  type AccordionBaseProps = {
4
+ /**
5
+ * Visual treatment.
6
+ * - `card` (default): Bordered cards with gap between items.
7
+ * - `flush`: Items share borders, no gap, no radius — denser layout.
8
+ */
9
+ variant?: AccordionVariant;
3
10
  children: ReactNode;
4
11
  className?: string;
5
12
  };
@@ -40,7 +47,17 @@ export type AccordionItemProps = {
40
47
  className?: string;
41
48
  };
42
49
  export type AccordionTriggerProps = {
43
- children: ReactNode;
50
+ /**
51
+ * When true, merges trigger props (aria attributes, event handlers, keyboard
52
+ * navigation) onto the child element instead of rendering a built-in button.
53
+ * The child must be a single React element that accepts ref and event handlers.
54
+ * The built-in chevron indicator is not rendered in asChild mode.
55
+ * @default false
56
+ */
57
+ asChild?: boolean;
58
+ /** Accessible label for the trigger. Required when children is empty (e.g. icon-only triggers). */
59
+ 'aria-label'?: string;
60
+ children?: ReactNode;
44
61
  className?: string;
45
62
  };
46
63
  export type AccordionPanelProps = {
@@ -54,8 +71,8 @@ export type AccordionContextValue = {
54
71
  collapsible: boolean;
55
72
  isOpen: (value: string) => boolean;
56
73
  toggle: (value: string) => void;
57
- registerTrigger: (value: string, element: HTMLButtonElement | null) => void;
58
- getTriggers: () => Map<string, HTMLButtonElement>;
74
+ registerTrigger: (value: string, element: HTMLElement | null) => void;
75
+ getTriggers: () => Map<string, HTMLElement>;
59
76
  };
60
77
  export type AccordionItemContextValue = {
61
78
  value: string;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
+ import { isDev } from '../../utils/is-dev.js';
3
4
  import { useFloating, offset, flip, shift, size as sizeMiddleware, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useRole, } from '@floating-ui/react';
4
5
  import { cx } from '../../utils/cx.js';
5
6
  import { toKey } from '../../utils/value-key.js';
@@ -396,7 +397,7 @@ function ComboboxOptionComponent({ value: optionValue, disabled = false, textVal
396
397
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
397
398
  // Warn in dev if textValue couldn't be derived
398
399
  useEffect(() => {
399
- if (import.meta.env.DEV && !textValue) {
400
+ if (isDev() && !textValue) {
400
401
  console.warn(`Combobox.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
401
402
  }
402
403
  }, [textValue, optionValue]);
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
+ import { isDev } from '../../utils/is-dev.js';
4
5
  import { iconRegistry } from '../../icons/registry.js';
5
6
  /**
6
7
  * Icon component for SVG icons from the registry or emoji characters.
@@ -12,7 +13,7 @@ import { iconRegistry } from '../../icons/registry.js';
12
13
  export const Icon = React.forwardRef(({ name, emoji, label, size, className }, ref) => {
13
14
  const SvgIcon = name ? iconRegistry[name] : null;
14
15
  // Dev warning for invalid icon name
15
- if (import.meta.env.DEV && name && !SvgIcon) {
16
+ if (isDev() && name && !SvgIcon) {
16
17
  console.warn(`[Icon] Unknown icon name: "${name}". Check the registry.`);
17
18
  }
18
19
  // Decorative if no label provided
@@ -108,7 +108,7 @@ function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-l
108
108
  let target = null;
109
109
  if (initialFocusSelector) {
110
110
  target = dialog.querySelector(initialFocusSelector);
111
- if (!target && import.meta.env.DEV) {
111
+ if (!target && isDev()) {
112
112
  console.warn(`Modal: initialFocusSelector="${initialFocusSelector}" did not match any element. ` +
113
113
  `Falling back to first focusable element.`);
114
114
  }
@@ -118,7 +118,7 @@ function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-l
118
118
  }
119
119
  target.focus({ preventScroll: true });
120
120
  // Development warning for missing labelledBy target
121
- if (import.meta.env.DEV && labelledBy) {
121
+ if (isDev() && labelledBy) {
122
122
  const labelElement = document.getElementById(labelledBy);
123
123
  if (!labelElement) {
124
124
  console.warn(`Modal: aria-labelledby="${labelledBy}" references a non-existent element. ` +
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
2
+ import { forwardRef, useCallback, useEffect, useId, useRef, useState } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { isDev } from '../../utils/is-dev.js';
5
5
  import { Icon } from '../Icon/index.js';
@@ -64,6 +64,17 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
64
64
  fallback?.focus();
65
65
  }
66
66
  }, [mode, canMoveUp, canMoveDown]);
67
+ // Debounce the lock icon — prevents visual jitter when `locked` flashes
68
+ // briefly (e.g. during a save). Behaviour (disabled buttons) applies
69
+ // immediately; only the icon swap is delayed.
70
+ const [showLockIcon, setShowLockIcon] = useState(locked);
71
+ useEffect(() => {
72
+ if (locked) {
73
+ const id = setTimeout(() => setShowLockIcon(true), 150);
74
+ return () => clearTimeout(id);
75
+ }
76
+ setShowLockIcon(false);
77
+ }, [locked]);
67
78
  // Drag handle label precedence: dragHandleProps > labels.drag > default
68
79
  const resolvedDragLabel = dragHandleProps?.['aria-label'] ?? labels?.drag ?? 'Drag to reorder';
69
80
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -80,5 +91,5 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
80
91
  const resolvedLockedDesc = locked
81
92
  ? (labels?.locked ?? 'This item is locked and cannot be reordered')
82
93
  : undefined;
83
- return (_jsxs("div", { ref: mergedRef, role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": locked ? lockedDescId : undefined, "aria-disabled": locked || undefined, className: cx('tui-move-handle', `is-size-${size}`, locked && 'is-locked', hasIndex && 'has-index', className), children: [locked && (_jsx("span", { id: lockedDescId, className: "tui-visually-hidden", children: resolvedLockedDesc })), onMoveUp && (_jsx("button", { type: "button", className: "tui-move-handle__up", "data-direction": "up", "aria-label": labels?.moveUp ?? 'Move up', disabled: locked || !canMoveUp, onClick: onMoveUp, children: _jsx(Icon, { name: "system/chevron-up" }) })), _jsx("div", { className: "tui-move-handle__center", children: locked ? (_jsx("span", { className: "tui-move-handle__lock", "aria-hidden": "true", children: _jsx(Icon, { name: "system/lock" }) })) : (_jsxs(_Fragment, { children: [hasIndex && (_jsx("span", { className: "tui-move-handle__index", "aria-hidden": "true", children: index })), _jsx("button", { type: "button", className: "tui-move-handle__handle", "data-role": "drag-handle", "aria-label": resolvedDragLabel, tabIndex: hasArrows ? -1 : 0, ...restDragProps, children: _jsx(Icon, { name: "system/handle-alt" }) })] })) }), onMoveDown && (_jsx("button", { type: "button", className: "tui-move-handle__down", "data-direction": "down", "aria-label": labels?.moveDown ?? 'Move down', disabled: locked || !canMoveDown, onClick: onMoveDown, children: _jsx(Icon, { name: "system/chevron-down" }) }))] }));
94
+ return (_jsxs("div", { ref: mergedRef, role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": locked ? lockedDescId : undefined, "aria-disabled": locked || undefined, className: cx('tui-move-handle', `is-size-${size}`, locked && 'is-locked', hasIndex && 'has-index', className), children: [locked && (_jsx("span", { id: lockedDescId, className: "tui-visually-hidden", children: resolvedLockedDesc })), onMoveUp && (_jsx("button", { type: "button", className: "tui-move-handle__up", "data-direction": "up", "aria-label": labels?.moveUp ?? 'Move up', disabled: locked || !canMoveUp, onClick: onMoveUp, children: _jsx(Icon, { name: "system/chevron-up" }) })), _jsx("div", { className: "tui-move-handle__center", children: showLockIcon ? (_jsx("span", { className: "tui-move-handle__lock", "aria-hidden": "true", children: _jsx(Icon, { name: "system/lock" }) })) : (_jsxs(_Fragment, { children: [hasIndex && (_jsx("span", { className: "tui-move-handle__index", "aria-hidden": "true", children: index })), _jsx("button", { type: "button", className: "tui-move-handle__handle", "data-role": "drag-handle", "aria-label": resolvedDragLabel, tabIndex: hasArrows ? -1 : 0, ...restDragProps, children: _jsx(Icon, { name: "system/handle-alt" }) })] })) }), onMoveDown && (_jsx("button", { type: "button", className: "tui-move-handle__down", "data-direction": "down", "aria-label": labels?.moveDown ?? 'Move down', disabled: locked || !canMoveDown, onClick: onMoveDown, children: _jsx(Icon, { name: "system/chevron-down" }) }))] }));
84
95
  });
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, cloneElement, isValidElement, } from 'react';
3
+ import { isDev } from '../../utils/is-dev.js';
3
4
  import { useFloating, offset, flip, shift, size as sizeMiddleware, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useTypeahead, useRole, useClick, } from '@floating-ui/react';
4
5
  import { cx } from '../../utils/cx.js';
5
6
  import { getPortalRootFor } from '../../utils/portal.js';
@@ -549,7 +550,7 @@ function MultiSelectOptionComponent({ value: optionValue, disabled = false, text
549
550
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
550
551
  // Warn in dev if textValue couldn't be derived
551
552
  useEffect(() => {
552
- if (import.meta.env.DEV && !textValue) {
553
+ if (isDev() && !textValue) {
553
554
  console.warn(`MultiSelect.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
554
555
  }
555
556
  }, [textValue, optionValue]);
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import React from 'react';
3
3
  import { useProgressSegments } from './useProgressSegments.js';
4
4
  import { cx } from '../../utils/cx.js';
5
+ import { isDev } from '../../utils/is-dev.js';
5
6
  // =============================================================================
6
7
  // COMPONENT
7
8
  // =============================================================================
@@ -23,7 +24,7 @@ export function Progress(props) {
23
24
  // Calculate percentages for standard mode
24
25
  const pct = Math.max(0, Math.min(100, (value / max) * 100));
25
26
  // Dev warning: inside position only supports labelStart (or children)
26
- if (import.meta.env.DEV && labelPosition === 'inside' && labelStart && labelEnd) {
27
+ if (isDev() && labelPosition === 'inside' && labelStart && labelEnd) {
27
28
  console.warn('Progress: labelPosition="inside" only supports a single label. ' +
28
29
  'labelEnd will be ignored. Use labelStart or children for inside content.');
29
30
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, cloneElement, isValidElement, } from 'react';
3
+ import { isDev } from '../../utils/is-dev.js';
3
4
  import { useFloating, offset, flip, shift, size as sizeMiddleware, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useTypeahead, useRole, useClick, } from '@floating-ui/react';
4
5
  import { cx } from '../../utils/cx.js';
5
6
  import { toKey } from '../../utils/value-key.js';
@@ -435,7 +436,7 @@ function SelectOptionComponent({ value: optionValue, disabled = false, textValue
435
436
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
436
437
  // Warn in dev if textValue couldn't be derived
437
438
  useEffect(() => {
438
- if (import.meta.env.DEV && !textValue) {
439
+ if (isDev() && !textValue) {
439
440
  console.warn(`Select.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
440
441
  }
441
442
  }, [textValue, optionValue]);
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
+ import { isDev } from '../../utils/is-dev.js';
4
5
  import { StepListContext, useStepListContext } from './StepListContext.js';
5
6
  import { StepIndicator } from '../StepIndicator/index.js';
6
7
  // =============================================================================
@@ -9,7 +10,7 @@ import { StepIndicator } from '../StepIndicator/index.js';
9
10
  function StepListRoot(props) {
10
11
  const { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ariaCurrent = 'step', current, onSelect, children, className, } = props;
11
12
  // Dev warning for missing nav label
12
- if (import.meta.env.DEV && !ariaLabel && !ariaLabelledBy) {
13
+ if (isDev() && !ariaLabel && !ariaLabelledBy) {
13
14
  console.warn('StepList: Navigation landmark requires a label. ' +
14
15
  'Provide either `aria-label` or `aria-labelledby` prop for screen reader users.');
15
16
  }