@tangible/ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +100 -0
  2. package/components/Accordion/Accordion.d.ts +22 -0
  3. package/components/Accordion/Accordion.js +192 -0
  4. package/components/Accordion/AccordionContext.d.ts +5 -0
  5. package/components/Accordion/AccordionContext.js +23 -0
  6. package/components/Accordion/index.d.ts +2 -0
  7. package/components/Accordion/index.js +1 -0
  8. package/components/Accordion/types.d.ts +61 -0
  9. package/components/Accordion/types.js +1 -0
  10. package/components/Avatar/Avatar.d.ts +11 -0
  11. package/components/Avatar/Avatar.js +67 -0
  12. package/components/Avatar/AvatarGroup.d.ts +11 -0
  13. package/components/Avatar/AvatarGroup.js +45 -0
  14. package/components/Avatar/index.d.ts +9 -0
  15. package/components/Avatar/index.js +7 -0
  16. package/components/Avatar/types.d.ts +44 -0
  17. package/components/Avatar/types.js +12 -0
  18. package/components/Button/Button.d.ts +4 -0
  19. package/components/Button/Button.js +33 -0
  20. package/components/Button/index.d.ts +2 -0
  21. package/components/Button/index.js +1 -0
  22. package/components/Button/types.d.ts +127 -0
  23. package/components/Button/types.js +1 -0
  24. package/components/Card/Card.d.ts +29 -0
  25. package/components/Card/Card.js +47 -0
  26. package/components/Card/index.d.ts +2 -0
  27. package/components/Card/index.js +1 -0
  28. package/components/Chip/Chip.d.ts +24 -0
  29. package/components/Chip/Chip.js +37 -0
  30. package/components/Chip/index.d.ts +2 -0
  31. package/components/Chip/index.js +1 -0
  32. package/components/Chips/Chips.d.ts +31 -0
  33. package/components/Chips/Chips.js +21 -0
  34. package/components/Chips/index.d.ts +2 -0
  35. package/components/Chips/index.js +1 -0
  36. package/components/ContentIndicator/ContentIndicator.d.ts +2 -0
  37. package/components/ContentIndicator/ContentIndicator.js +21 -0
  38. package/components/ContentIndicator/index.d.ts +2 -0
  39. package/components/ContentIndicator/index.js +1 -0
  40. package/components/ContentIndicator/types.d.ts +57 -0
  41. package/components/ContentIndicator/types.js +1 -0
  42. package/components/Dropdown/Dropdown.d.ts +31 -0
  43. package/components/Dropdown/Dropdown.js +219 -0
  44. package/components/Dropdown/DropdownContext.d.ts +3 -0
  45. package/components/Dropdown/DropdownContext.js +9 -0
  46. package/components/Dropdown/index.d.ts +2 -0
  47. package/components/Dropdown/index.js +1 -0
  48. package/components/Dropdown/types.d.ts +102 -0
  49. package/components/Dropdown/types.js +8 -0
  50. package/components/Icon/Icon.d.ts +22 -0
  51. package/components/Icon/Icon.js +24 -0
  52. package/components/Icon/index.d.ts +2 -0
  53. package/components/Icon/index.js +1 -0
  54. package/components/IconButton/IconButton.d.ts +2 -0
  55. package/components/IconButton/IconButton.js +50 -0
  56. package/components/IconButton/index.d.ts +2 -0
  57. package/components/IconButton/index.js +1 -0
  58. package/components/IconButton/types.d.ts +79 -0
  59. package/components/IconButton/types.js +1 -0
  60. package/components/Modal/Modal.d.ts +52 -0
  61. package/components/Modal/Modal.js +133 -0
  62. package/components/Modal/context.d.ts +6 -0
  63. package/components/Modal/context.js +9 -0
  64. package/components/Modal/index.d.ts +2 -0
  65. package/components/Modal/index.js +1 -0
  66. package/components/Notice/Notice.d.ts +93 -0
  67. package/components/Notice/Notice.js +144 -0
  68. package/components/Notice/index.d.ts +2 -0
  69. package/components/Notice/index.js +1 -0
  70. package/components/OverlapStack/OverlapStack.d.ts +44 -0
  71. package/components/OverlapStack/OverlapStack.js +41 -0
  72. package/components/OverlapStack/index.d.ts +2 -0
  73. package/components/OverlapStack/index.js +1 -0
  74. package/components/Pager/Pager.d.ts +26 -0
  75. package/components/Pager/Pager.js +151 -0
  76. package/components/Pager/index.d.ts +2 -0
  77. package/components/Pager/index.js +1 -0
  78. package/components/Progress/Progress.d.ts +2 -0
  79. package/components/Progress/Progress.js +100 -0
  80. package/components/Progress/index.d.ts +4 -0
  81. package/components/Progress/index.js +2 -0
  82. package/components/Progress/types.d.ts +251 -0
  83. package/components/Progress/types.js +1 -0
  84. package/components/Progress/useProgressSegments.d.ts +40 -0
  85. package/components/Progress/useProgressSegments.js +42 -0
  86. package/components/Rating/Rating.d.ts +32 -0
  87. package/components/Rating/Rating.js +74 -0
  88. package/components/Rating/index.d.ts +2 -0
  89. package/components/Rating/index.js +1 -0
  90. package/components/SegmentedControl/SegmentedControl.d.ts +10 -0
  91. package/components/SegmentedControl/SegmentedControl.js +183 -0
  92. package/components/SegmentedControl/SegmentedControlContext.d.ts +3 -0
  93. package/components/SegmentedControl/SegmentedControlContext.js +9 -0
  94. package/components/SegmentedControl/index.d.ts +2 -0
  95. package/components/SegmentedControl/index.js +1 -0
  96. package/components/SegmentedControl/types.d.ts +63 -0
  97. package/components/SegmentedControl/types.js +1 -0
  98. package/components/Sidebar/Sidebar.d.ts +17 -0
  99. package/components/Sidebar/Sidebar.js +107 -0
  100. package/components/Sidebar/index.d.ts +2 -0
  101. package/components/Sidebar/index.js +1 -0
  102. package/components/Sidebar/types.d.ts +65 -0
  103. package/components/Sidebar/types.js +4 -0
  104. package/components/StepIndicator/StepIndicator.d.ts +2 -0
  105. package/components/StepIndicator/StepIndicator.js +64 -0
  106. package/components/StepIndicator/index.d.ts +2 -0
  107. package/components/StepIndicator/index.js +1 -0
  108. package/components/StepIndicator/types.d.ts +68 -0
  109. package/components/StepIndicator/types.js +1 -0
  110. package/components/StepList/StepList.d.ts +12 -0
  111. package/components/StepList/StepList.js +59 -0
  112. package/components/StepList/StepListContext.d.ts +3 -0
  113. package/components/StepList/StepListContext.js +9 -0
  114. package/components/StepList/index.d.ts +2 -0
  115. package/components/StepList/index.js +1 -0
  116. package/components/StepList/types.d.ts +91 -0
  117. package/components/StepList/types.js +4 -0
  118. package/components/Table/BulkActionsBar.d.ts +12 -0
  119. package/components/Table/BulkActionsBar.js +9 -0
  120. package/components/Table/DataTable.d.ts +35 -0
  121. package/components/Table/DataTable.js +184 -0
  122. package/components/Table/Pagination.d.ts +13 -0
  123. package/components/Table/Pagination.js +13 -0
  124. package/components/Table/index.d.ts +2 -0
  125. package/components/Table/index.js +1 -0
  126. package/components/Tabs/Tabs.d.ts +23 -0
  127. package/components/Tabs/Tabs.js +309 -0
  128. package/components/Tabs/TabsContext.d.ts +3 -0
  129. package/components/Tabs/TabsContext.js +12 -0
  130. package/components/Tabs/index.d.ts +2 -0
  131. package/components/Tabs/index.js +1 -0
  132. package/components/Tabs/types.d.ts +75 -0
  133. package/components/Tabs/types.js +1 -0
  134. package/components/Toolbar/Toolbar.d.ts +18 -0
  135. package/components/Toolbar/Toolbar.js +241 -0
  136. package/components/Toolbar/index.d.ts +2 -0
  137. package/components/Toolbar/index.js +1 -0
  138. package/components/Toolbar/types.d.ts +28 -0
  139. package/components/Toolbar/types.js +1 -0
  140. package/components/Tooltip/Tooltip.d.ts +15 -0
  141. package/components/Tooltip/Tooltip.js +166 -0
  142. package/components/Tooltip/TooltipContext.d.ts +15 -0
  143. package/components/Tooltip/TooltipContext.js +25 -0
  144. package/components/Tooltip/index.d.ts +2 -0
  145. package/components/Tooltip/index.js +1 -0
  146. package/components/Tooltip/types.d.ts +85 -0
  147. package/components/Tooltip/types.js +8 -0
  148. package/components/index.d.ts +52 -0
  149. package/components/index.js +26 -0
  150. package/constants.d.ts +16 -0
  151. package/constants.js +16 -0
  152. package/icons/cred/index.d.ts +31 -0
  153. package/icons/cred/index.js +136 -0
  154. package/icons/icons.svg +155 -0
  155. package/icons/lms/index.d.ts +21 -0
  156. package/icons/lms/index.js +81 -0
  157. package/icons/manifest.json +1226 -0
  158. package/icons/player/index.d.ts +55 -0
  159. package/icons/player/index.js +268 -0
  160. package/icons/reaction/index.d.ts +79 -0
  161. package/icons/reaction/index.js +400 -0
  162. package/icons/registry.d.ts +316 -0
  163. package/icons/registry.js +163 -0
  164. package/icons/system/index.d.ts +155 -0
  165. package/icons/system/index.js +818 -0
  166. package/package.json +121 -0
  167. package/styles/all.css +1 -0
  168. package/styles/all.expanded.css +4137 -0
  169. package/styles/all.expanded.unlayered.css +4137 -0
  170. package/styles/all.unlayered.css +1 -0
  171. package/styles/components/_bundle.scss +51 -0
  172. package/styles/components/index.scss +1 -0
  173. package/styles/components/input/index.scss +248 -0
  174. package/styles/index.scss +71 -0
  175. package/styles/system/_constants.scss +12 -0
  176. package/styles/system/_motion.scss +47 -0
  177. package/styles/system/_palette-fns.scss +10 -0
  178. package/styles/system/_palettes.scss +80 -0
  179. package/styles/system/_tokens.scss +249 -0
  180. package/styles/system/index.scss +4 -0
  181. package/styles/utilities/_index.scss +373 -0
  182. package/tui-manifest.json +1858 -0
  183. package/types/index.d.ts +2 -0
  184. package/types/index.js +1 -0
  185. package/types/index.ts +2 -0
  186. package/types/sizes.d.ts +17 -0
  187. package/types/sizes.js +10 -0
  188. package/types/sizes.ts +21 -0
  189. package/types/svg.d.ts +5 -0
  190. package/types/themes.d.ts +14 -0
  191. package/types/themes.js +9 -0
  192. package/types/themes.ts +17 -0
  193. package/utils/color/contrast.d.ts +33 -0
  194. package/utils/color/contrast.js +88 -0
  195. package/utils/color-scheme.d.ts +25 -0
  196. package/utils/color-scheme.js +55 -0
  197. package/utils/compose-refs.d.ts +17 -0
  198. package/utils/compose-refs.js +38 -0
  199. package/utils/cx.d.ts +12 -0
  200. package/utils/cx.js +14 -0
  201. package/utils/focus-trap.d.ts +40 -0
  202. package/utils/focus-trap.js +93 -0
  203. package/utils/index.d.ts +10 -0
  204. package/utils/index.js +16 -0
  205. package/utils/math.d.ts +4 -0
  206. package/utils/math.js +19 -0
  207. package/utils/merge-props.d.ts +25 -0
  208. package/utils/merge-props.js +60 -0
  209. package/utils/polymorphic.d.ts +28 -0
  210. package/utils/polymorphic.js +44 -0
  211. package/utils/portal.d.ts +11 -0
  212. package/utils/portal.js +105 -0
@@ -0,0 +1,309 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import { cx } from '../../utils/cx.js';
4
+ import { Icon } from '../Icon/index.js';
5
+ import { TabsContext, useTabsContext } from './TabsContext.js';
6
+ // =============================================================================
7
+ // Utility: Sanitize ID
8
+ // =============================================================================
9
+ const sanitizeId = (str) => str.replace(/[^a-zA-Z0-9-_]/g, '-');
10
+ function TabsRoot({ variant = 'underline', activationMode = 'auto', value: controlledValue, defaultValue, onValueChange, orientation = 'horizontal', className, children, }) {
11
+ const baseId = useId();
12
+ const [internalValue, setInternalValue] = useState(defaultValue);
13
+ const [focusedValue, setFocusedValue] = useState(undefined);
14
+ const [registryVersion, setRegistryVersion] = useState(0);
15
+ const tabsRef = useRef(new Map());
16
+ const panelsRef = useRef(new Set());
17
+ const mountCounterRef = useRef(0);
18
+ const prevDisabledRef = useRef(new Map());
19
+ const isControlled = controlledValue !== undefined;
20
+ const activeValue = isControlled ? controlledValue : internalValue;
21
+ // ID generators
22
+ const getTabId = useCallback((value) => `${baseId}-tab-${sanitizeId(value)}`, [baseId]);
23
+ const getPanelId = useCallback((value) => `${baseId}-panel-${sanitizeId(value)}`, [baseId]);
24
+ // Tab registration
25
+ const registerTab = useCallback((record) => {
26
+ const existing = tabsRef.current.get(record.value);
27
+ tabsRef.current.set(record.value, {
28
+ ...record,
29
+ mountIndex: existing?.mountIndex ?? mountCounterRef.current++,
30
+ });
31
+ setRegistryVersion((v) => v + 1);
32
+ }, []);
33
+ const unregisterTab = useCallback((value) => {
34
+ tabsRef.current.delete(value);
35
+ setRegistryVersion((v) => v + 1);
36
+ }, []);
37
+ // Panel registration
38
+ const registerPanel = useCallback((value) => {
39
+ panelsRef.current.add(value);
40
+ setRegistryVersion((v) => v + 1);
41
+ }, []);
42
+ const unregisterPanel = useCallback((value) => {
43
+ panelsRef.current.delete(value);
44
+ setRegistryVersion((v) => v + 1);
45
+ }, []);
46
+ // Get tabs sorted by DOM order, falling back to mount order for disconnected nodes
47
+ const getOrderedTabs = useCallback(() => {
48
+ const tabs = Array.from(tabsRef.current.values());
49
+ return tabs.sort((a, b) => {
50
+ const position = a.element.compareDocumentPosition(b.element);
51
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING)
52
+ return -1;
53
+ if (position & Node.DOCUMENT_POSITION_PRECEDING)
54
+ return 1;
55
+ // Disconnected or same node: fall back to stable mount order
56
+ return a.mountIndex - b.mountIndex;
57
+ });
58
+ }, []);
59
+ // Selection handler
60
+ const onSelect = useCallback((newValue) => {
61
+ if (isControlled) {
62
+ // Controlled: ONLY call callback, never touch internal state
63
+ onValueChange?.(newValue);
64
+ }
65
+ else {
66
+ // Uncontrolled: update internal state, then notify
67
+ setInternalValue(newValue);
68
+ onValueChange?.(newValue);
69
+ }
70
+ }, [isControlled, onValueChange]);
71
+ // Handle dynamic disable: if active or focused tab becomes disabled, move selection/focus
72
+ useEffect(() => {
73
+ const enabledTabs = getOrderedTabs().filter((t) => !t.disabled);
74
+ if (enabledTabs.length === 0)
75
+ return; // Edge case: all disabled
76
+ // 1. If ACTIVE tab became disabled, move selection
77
+ const activeTab = tabsRef.current.get(activeValue ?? '');
78
+ const activeWasDisabled = prevDisabledRef.current.get(activeValue ?? '');
79
+ if (activeTab?.disabled && !activeWasDisabled) {
80
+ const firstEnabled = enabledTabs[0];
81
+ if (isControlled) {
82
+ onValueChange?.(firstEnabled.value);
83
+ }
84
+ else {
85
+ setInternalValue(firstEnabled.value);
86
+ onValueChange?.(firstEnabled.value);
87
+ }
88
+ }
89
+ // 2. If FOCUSED tab became disabled, move focus to nearest enabled
90
+ const focusedTab = tabsRef.current.get(focusedValue ?? '');
91
+ const focusedWasDisabled = prevDisabledRef.current.get(focusedValue ?? '');
92
+ if (focusedTab?.disabled && !focusedWasDisabled) {
93
+ // Find next enabled tab after current, or wrap to first
94
+ const currentIdx = enabledTabs.findIndex((t) => t.mountIndex > focusedTab.mountIndex);
95
+ const nextEnabled = enabledTabs[currentIdx >= 0 ? currentIdx : 0];
96
+ setFocusedValue(nextEnabled.value);
97
+ nextEnabled.element.focus();
98
+ }
99
+ // Update tracking
100
+ tabsRef.current.forEach((record, val) => {
101
+ prevDisabledRef.current.set(val, record.disabled);
102
+ });
103
+ }, [registryVersion, activeValue, focusedValue, isControlled, onValueChange, getOrderedTabs]);
104
+ // Dev-only: Tab-Panel pairing validation
105
+ useEffect(() => {
106
+ if (import.meta.env.DEV) {
107
+ // Defer validation to next microtask so all tabs/panels have registered
108
+ queueMicrotask(() => {
109
+ const tabValues = new Set(Array.from(tabsRef.current.keys()));
110
+ const panelValues = panelsRef.current;
111
+ // Tabs without panels
112
+ tabValues.forEach((v) => {
113
+ if (!panelValues.has(v)) {
114
+ console.warn(`Tabs: Tab "${v}" has no matching Panel.`);
115
+ }
116
+ });
117
+ // Panels without tabs
118
+ panelValues.forEach((v) => {
119
+ if (!tabValues.has(v)) {
120
+ console.warn(`Tabs: Panel "${v}" has no matching Tab.`);
121
+ }
122
+ });
123
+ });
124
+ }
125
+ }, [registryVersion]);
126
+ const contextValue = useMemo(() => ({
127
+ variant,
128
+ orientation,
129
+ activationMode,
130
+ activeValue,
131
+ focusedValue,
132
+ baseId,
133
+ registerTab,
134
+ unregisterTab,
135
+ registerPanel,
136
+ unregisterPanel,
137
+ getOrderedTabs,
138
+ onSelect,
139
+ setFocusedValue,
140
+ getTabId,
141
+ getPanelId,
142
+ tabsRef,
143
+ }), [
144
+ variant,
145
+ orientation,
146
+ activationMode,
147
+ activeValue,
148
+ focusedValue,
149
+ baseId,
150
+ registerTab,
151
+ unregisterTab,
152
+ registerPanel,
153
+ unregisterPanel,
154
+ getOrderedTabs,
155
+ onSelect,
156
+ setFocusedValue,
157
+ getTabId,
158
+ getPanelId,
159
+ ]);
160
+ return (_jsx(TabsContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-tabs', className), "data-variant": variant, "data-orientation": orientation, children: children }) }));
161
+ }
162
+ // =============================================================================
163
+ // Tabs.List
164
+ // =============================================================================
165
+ function TabsList({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }) {
166
+ const { orientation, activationMode, activeValue, focusedValue, getOrderedTabs, onSelect, setFocusedValue, tabsRef, } = useTabsContext();
167
+ // Dev-only: Warn if missing accessible name
168
+ useEffect(() => {
169
+ if (import.meta.env.DEV) {
170
+ if (!ariaLabel && !ariaLabelledBy) {
171
+ console.warn('Tabs.List: Missing accessible name. Provide aria-label or aria-labelledby.');
172
+ }
173
+ }
174
+ }, [ariaLabel, ariaLabelledBy]);
175
+ // Focus tracking via real focus events (O(1) lookup via data attribute)
176
+ const handleFocusIn = (event) => {
177
+ const tab = event.target.closest('[role="tab"]');
178
+ if (!tab)
179
+ return;
180
+ // O(1) lookup via data attribute
181
+ const value = tab.getAttribute('data-tui-tab-value');
182
+ if (!value)
183
+ return;
184
+ const record = tabsRef.current.get(value);
185
+ if (record && !record.disabled) {
186
+ setFocusedValue(value);
187
+ // In auto mode, select on focus
188
+ if (activationMode === 'auto') {
189
+ onSelect(value);
190
+ }
191
+ }
192
+ };
193
+ // Keyboard navigation
194
+ const handleKeyDown = (event) => {
195
+ const tabs = getOrderedTabs().filter((t) => !t.disabled);
196
+ // Guard: no enabled tabs
197
+ if (tabs.length === 0)
198
+ return;
199
+ // Find current index; if focusedValue unset or not found, start from first/active tab
200
+ let currentIndex = tabs.findIndex((t) => t.value === focusedValue);
201
+ if (currentIndex === -1) {
202
+ // Fall back to active tab, or first tab
203
+ currentIndex = tabs.findIndex((t) => t.value === activeValue);
204
+ if (currentIndex === -1)
205
+ currentIndex = 0;
206
+ }
207
+ const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
208
+ const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
209
+ let targetIndex = null;
210
+ switch (event.key) {
211
+ case nextKey:
212
+ event.preventDefault();
213
+ targetIndex = (currentIndex + 1) % tabs.length;
214
+ break;
215
+ case prevKey:
216
+ event.preventDefault();
217
+ targetIndex = (currentIndex - 1 + tabs.length) % tabs.length;
218
+ break;
219
+ case 'Home':
220
+ event.preventDefault();
221
+ targetIndex = 0;
222
+ break;
223
+ case 'End':
224
+ event.preventDefault();
225
+ targetIndex = tabs.length - 1;
226
+ break;
227
+ case 'Enter':
228
+ case ' ':
229
+ if (activationMode === 'manual' && focusedValue) {
230
+ event.preventDefault();
231
+ onSelect(focusedValue);
232
+ }
233
+ return;
234
+ }
235
+ if (targetIndex !== null) {
236
+ const targetTab = tabs[targetIndex];
237
+ targetTab.element.focus();
238
+ // Focus event handler will update focusedValue and (in auto mode) call onSelect
239
+ }
240
+ };
241
+ return (_jsx("div", { role: "tablist", className: cx('tui-tabs__list', className), "aria-orientation": orientation, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, onKeyDown: handleKeyDown, onFocusCapture: handleFocusIn, children: children }));
242
+ }
243
+ // =============================================================================
244
+ // Tabs.Tab
245
+ // =============================================================================
246
+ function Tab({ value, icon, disabled = false, className, children }) {
247
+ const { activationMode, activeValue, focusedValue, registerTab, unregisterTab, onSelect, getTabId, getPanelId, } = useTabsContext();
248
+ const tabId = getTabId(value);
249
+ const panelId = getPanelId(value);
250
+ const isActive = activeValue === value;
251
+ // Determine which tab gets tabIndex={0}
252
+ const getFocusableValue = () => {
253
+ if (activationMode === 'auto') {
254
+ return activeValue;
255
+ }
256
+ // Manual: focused tab gets tabIndex=0, or fall back to active
257
+ return focusedValue ?? activeValue;
258
+ };
259
+ const isFocusable = value === getFocusableValue();
260
+ // Callback ref for registration (handles null during unmount)
261
+ const callbackRef = useCallback((node) => {
262
+ if (node) {
263
+ registerTab({ value, element: node, disabled, tabId, panelId });
264
+ }
265
+ else {
266
+ unregisterTab(value);
267
+ }
268
+ }, [value, disabled, tabId, panelId, registerTab, unregisterTab]);
269
+ // Click handler
270
+ const handleClick = () => {
271
+ if (disabled)
272
+ return;
273
+ // In manual mode, explicitly select. In auto mode, focus handler will select.
274
+ if (activationMode === 'manual') {
275
+ onSelect(value);
276
+ }
277
+ };
278
+ return (_jsxs("button", { ref: callbackRef, type: "button", role: "tab", id: tabId, className: cx('tui-tabs__tab', className), "data-tui-tab-value": value, "data-state": isActive ? 'active' : 'inactive', "aria-selected": isActive, "aria-controls": panelId, tabIndex: disabled ? -1 : isFocusable ? 0 : -1, disabled: disabled, onClick: handleClick, children: [icon && _jsx(Icon, { name: icon, size: "sm", "aria-hidden": "true" }), children && _jsx("span", { className: "tui-tabs__tab-label", children: children })] }));
279
+ }
280
+ // =============================================================================
281
+ // Tabs.Panel
282
+ // =============================================================================
283
+ function TabPanel({ value, className, children }) {
284
+ const { activeValue, registerPanel, unregisterPanel, getTabId, getPanelId } = useTabsContext();
285
+ const tabId = getTabId(value);
286
+ const panelId = getPanelId(value);
287
+ const isActive = activeValue === value;
288
+ // Register panel on mount
289
+ useEffect(() => {
290
+ registerPanel(value);
291
+ return () => unregisterPanel(value);
292
+ }, [value, registerPanel, unregisterPanel]);
293
+ return (_jsx("div", { role: "tabpanel", id: panelId, className: cx('tui-tabs__panel', className), "data-state": isActive ? 'active' : 'inactive', "aria-labelledby": tabId, "aria-hidden": !isActive,
294
+ // tabIndex={0} allows direct focus on panel for screen reader navigation
295
+ tabIndex: isActive ? 0 : undefined,
296
+ // inert prevents Tab navigation into hidden panel content
297
+ inert: !isActive || undefined, children: children }));
298
+ }
299
+ // =============================================================================
300
+ // Compound Component Export
301
+ // =============================================================================
302
+ TabsList.displayName = 'Tabs.List';
303
+ Tab.displayName = 'Tabs.Tab';
304
+ TabPanel.displayName = 'Tabs.Panel';
305
+ export const Tabs = TabsRoot;
306
+ Tabs.displayName = 'Tabs';
307
+ Tabs.List = TabsList;
308
+ Tabs.Tab = Tab;
309
+ Tabs.Panel = TabPanel;
@@ -0,0 +1,3 @@
1
+ import type { TabsContextValue } from './types';
2
+ export declare const TabsContext: import("react").Context<TabsContextValue | null>;
3
+ export declare function useTabsContext(): TabsContextValue;
@@ -0,0 +1,12 @@
1
+ import { createContext, useContext } from 'react';
2
+ // =============================================================================
3
+ // Tabs Context
4
+ // =============================================================================
5
+ export const TabsContext = createContext(null);
6
+ export function useTabsContext() {
7
+ const context = useContext(TabsContext);
8
+ if (!context) {
9
+ throw new Error('Tabs compound components must be used within a Tabs component');
10
+ }
11
+ return context;
12
+ }
@@ -0,0 +1,2 @@
1
+ export { Tabs } from './Tabs';
2
+ export type { TabsProps, TabsListProps, TabProps, TabPanelProps, TabsVariant, TabsActivationMode, TabsOrientation, } from './types';
@@ -0,0 +1 @@
1
+ export { Tabs } from './Tabs.js';
@@ -0,0 +1,75 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { IconName } from '../../icons/registry';
3
+ export type TabsVariant = 'pill' | 'underline' | 'outline';
4
+ export type TabsActivationMode = 'auto' | 'manual';
5
+ export type TabsOrientation = 'horizontal' | 'vertical';
6
+ export type TabsProps = {
7
+ /** Visual style */
8
+ variant?: TabsVariant;
9
+ /** Auto: select on focus. Manual: arrow keys move focus only, Enter/Space selects. */
10
+ activationMode?: TabsActivationMode;
11
+ /** Controlled active tab */
12
+ value?: string;
13
+ /** Uncontrolled initial tab */
14
+ defaultValue?: string;
15
+ /** Callback when active tab changes */
16
+ onValueChange?: (value: string) => void;
17
+ /** Affects arrow key mapping and aria-orientation */
18
+ orientation?: TabsOrientation;
19
+ /** Additional classes */
20
+ className?: string;
21
+ children: ReactNode;
22
+ };
23
+ export type TabsListProps = {
24
+ /** Accessible label (required) */
25
+ 'aria-label'?: string;
26
+ /** Alternative to aria-label */
27
+ 'aria-labelledby'?: string;
28
+ /** Additional classes */
29
+ className?: string;
30
+ children: ReactNode;
31
+ };
32
+ export type TabProps = {
33
+ /** Unique identifier, must match a Panel */
34
+ value: string;
35
+ /** Icon name from registry */
36
+ icon?: IconName;
37
+ /** Disable tab (skipped in keyboard nav) */
38
+ disabled?: boolean;
39
+ /** Additional classes */
40
+ className?: string;
41
+ children?: ReactNode;
42
+ };
43
+ export type TabPanelProps = {
44
+ /** Must match a Tab value */
45
+ value: string;
46
+ /** Additional classes */
47
+ className?: string;
48
+ children?: ReactNode;
49
+ };
50
+ export type TabRecord = {
51
+ value: string;
52
+ element: HTMLButtonElement;
53
+ disabled: boolean;
54
+ tabId: string;
55
+ panelId: string;
56
+ mountIndex: number;
57
+ };
58
+ export type TabsContextValue = {
59
+ variant: TabsVariant;
60
+ orientation: TabsOrientation;
61
+ activationMode: TabsActivationMode;
62
+ activeValue: string | undefined;
63
+ focusedValue: string | undefined;
64
+ baseId: string;
65
+ registerTab: (record: Omit<TabRecord, 'mountIndex'>) => void;
66
+ unregisterTab: (value: string) => void;
67
+ registerPanel: (value: string) => void;
68
+ unregisterPanel: (value: string) => void;
69
+ getOrderedTabs: () => TabRecord[];
70
+ onSelect: (value: string) => void;
71
+ setFocusedValue: (value: string | undefined) => void;
72
+ getTabId: (value: string) => string;
73
+ getPanelId: (value: string) => string;
74
+ tabsRef: React.MutableRefObject<Map<string, TabRecord>>;
75
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import React from 'react';
2
+ import type { ToolbarProps, ToolbarGroupProps, ToolbarItemProps } from './types';
3
+ type ToolbarCompound = {
4
+ (props: ToolbarProps): React.JSX.Element;
5
+ displayName?: string;
6
+ Group: typeof ToolbarGroup;
7
+ Item: typeof ToolbarItem;
8
+ };
9
+ declare function ToolbarGroup({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, children, className, }: ToolbarGroupProps): import("react/jsx-runtime").JSX.Element;
10
+ declare namespace ToolbarGroup {
11
+ var displayName: string;
12
+ }
13
+ declare function ToolbarItem({ children }: ToolbarItemProps): React.ReactElement<Record<string, unknown>, string | React.JSXElementConstructor<any>> | null;
14
+ declare namespace ToolbarItem {
15
+ var displayName: string;
16
+ }
17
+ export declare const Toolbar: ToolbarCompound;
18
+ export {};
@@ -0,0 +1,241 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React, { useCallback, useEffect, useRef, cloneElement, isValidElement, } from 'react';
3
+ import { cx } from '../../utils/cx.js';
4
+ // =============================================================================
5
+ // Toolbar Component
6
+ // =============================================================================
7
+ //
8
+ // WAI-ARIA toolbar pattern with roving tabindex. Only elements marked with
9
+ // data-tui-toolbar-item participate in keyboard navigation - this prevents
10
+ // hijacking nested focusables (inputs, menus, etc.).
11
+ //
12
+ // Per APG: Use toolbars for groups of 3+ controls. For 1-2 controls, standard
13
+ // button groups without role="toolbar" avoid unnecessary keyboard complexity.
14
+ //
15
+ // Usage:
16
+ // <Toolbar aria-label="Actions">
17
+ // <Toolbar.Group aria-label="Navigation">
18
+ // <Toolbar.Item>
19
+ // <IconButton icon="system/chevron-left" label="Previous" />
20
+ // </Toolbar.Item>
21
+ // </Toolbar.Group>
22
+ // </Toolbar>
23
+ //
24
+ // =============================================================================
25
+ // Selectors for toolbar items
26
+ const ITEM_SELECTOR = '[data-tui-toolbar-item]';
27
+ const DISABLED_SELECTOR = ':disabled, [aria-disabled="true"]';
28
+ // Elements that should not have arrow keys intercepted (composite widgets per APG)
29
+ const ARROW_EXEMPT_SELECTOR = 'input, textarea, select, [contenteditable="true"], ' +
30
+ '[role="textbox"], [role="combobox"], [role="listbox"], ' +
31
+ '[role="menu"], [role="tree"], [role="grid"], [role="spinbutton"], [role="slider"], ' +
32
+ '[role="radiogroup"]';
33
+ // -----------------------------------------------------------------------------
34
+ // Helper: Find next/prev enabled item
35
+ // -----------------------------------------------------------------------------
36
+ function findNextItem(items, currentIndex, direction, loop) {
37
+ const len = items.length;
38
+ if (len === 0)
39
+ return null;
40
+ let index = currentIndex;
41
+ for (let i = 0; i < len; i++) {
42
+ index += direction;
43
+ // Handle boundaries
44
+ if (index >= len) {
45
+ if (!loop)
46
+ return null;
47
+ index = 0;
48
+ }
49
+ else if (index < 0) {
50
+ if (!loop)
51
+ return null;
52
+ index = len - 1;
53
+ }
54
+ // Skip disabled items
55
+ const item = items[index];
56
+ if (!item.matches(DISABLED_SELECTOR)) {
57
+ return index;
58
+ }
59
+ }
60
+ return null; // All items disabled
61
+ }
62
+ function ToolbarRoot({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, orientation = 'horizontal', loop = true, children, className, style, }) {
63
+ const containerRef = useRef(null);
64
+ const focusedIndexRef = useRef(0);
65
+ // Instance-scoped map for storing original tabindex values
66
+ const originalTabIndexMapRef = useRef(new WeakMap());
67
+ // Dev warning for missing accessible name
68
+ useEffect(() => {
69
+ if (import.meta.env?.DEV && !ariaLabel && !ariaLabelledBy) {
70
+ console.warn('[Toolbar] aria-label or aria-labelledby is required for accessibility.');
71
+ }
72
+ }, [ariaLabel, ariaLabelledBy]);
73
+ // -------------------------------------------------------------------------
74
+ // Item discovery
75
+ // -------------------------------------------------------------------------
76
+ const getItems = useCallback(() => {
77
+ if (!containerRef.current)
78
+ return [];
79
+ return Array.from(containerRef.current.querySelectorAll(ITEM_SELECTOR));
80
+ }, []);
81
+ // -------------------------------------------------------------------------
82
+ // Roving tabindex management
83
+ // -------------------------------------------------------------------------
84
+ const updateTabIndices = useCallback((items, focusIdx) => {
85
+ const map = originalTabIndexMapRef.current;
86
+ items.forEach((item, i) => {
87
+ // Store original tabindex if not already stored
88
+ if (!map.has(item)) {
89
+ map.set(item, item.hasAttribute('tabindex') ? item.tabIndex : null);
90
+ }
91
+ // Disabled items get tabIndex=-1 (not in Tab order)
92
+ const isDisabled = item.matches(DISABLED_SELECTOR);
93
+ if (isDisabled) {
94
+ item.setAttribute('tabindex', '-1');
95
+ }
96
+ else {
97
+ item.setAttribute('tabindex', i === focusIdx ? '0' : '-1');
98
+ }
99
+ });
100
+ }, []);
101
+ // Initialize tabindex and observe for dynamic item changes
102
+ useEffect(() => {
103
+ const container = containerRef.current;
104
+ if (!container)
105
+ return;
106
+ const initializeTabIndices = () => {
107
+ const items = getItems();
108
+ if (items.length === 0)
109
+ return;
110
+ // If current focused item is still valid, keep it
111
+ const currentItems = getItems();
112
+ const currentFocusedItem = currentItems[focusedIndexRef.current];
113
+ if (currentFocusedItem && !currentFocusedItem.matches(DISABLED_SELECTOR)) {
114
+ updateTabIndices(items, focusedIndexRef.current);
115
+ return;
116
+ }
117
+ // Otherwise find first enabled item
118
+ const firstEnabled = items.findIndex((el) => !el.matches(DISABLED_SELECTOR));
119
+ const newIdx = Math.max(0, firstEnabled);
120
+ focusedIndexRef.current = newIdx;
121
+ updateTabIndices(items, newIdx);
122
+ };
123
+ // Initial setup
124
+ initializeTabIndices();
125
+ // Watch for items being added/removed
126
+ const observer = new MutationObserver((mutations) => {
127
+ // Only re-init if toolbar items changed
128
+ const relevantChange = mutations.some((m) => m.type === 'childList' ||
129
+ (m.type === 'attributes' && m.attributeName === 'data-tui-toolbar-item'));
130
+ if (relevantChange) {
131
+ initializeTabIndices();
132
+ }
133
+ });
134
+ observer.observe(container, {
135
+ childList: true,
136
+ subtree: true,
137
+ attributes: true,
138
+ attributeFilter: ['data-tui-toolbar-item', 'disabled', 'aria-disabled'],
139
+ });
140
+ return () => observer.disconnect();
141
+ }, [getItems, updateTabIndices]);
142
+ // -------------------------------------------------------------------------
143
+ // Focus sync - DOM is source of truth
144
+ // -------------------------------------------------------------------------
145
+ const handleFocusIn = useCallback((e) => {
146
+ // Target may be inner element (SVG, span) - find closest toolbar item
147
+ const item = e.target.closest(ITEM_SELECTOR);
148
+ if (!item)
149
+ return;
150
+ const items = getItems();
151
+ const idx = items.indexOf(item);
152
+ if (idx !== -1) {
153
+ focusedIndexRef.current = idx;
154
+ // Defer DOM mutation to avoid React hydration mismatches in SSR contexts
155
+ queueMicrotask(() => updateTabIndices(items, idx));
156
+ }
157
+ }, [getItems, updateTabIndices]);
158
+ // -------------------------------------------------------------------------
159
+ // Keyboard navigation
160
+ // -------------------------------------------------------------------------
161
+ const handleKeyDown = useCallback((e) => {
162
+ // Bail if modifier keys held
163
+ if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey)
164
+ return;
165
+ // Bail if target is or is inside exempt element (input, textarea, composite widget)
166
+ if (e.target.closest(ARROW_EXEMPT_SELECTOR))
167
+ return;
168
+ const items = getItems();
169
+ if (items.length === 0)
170
+ return;
171
+ // Find which item currently has focus (using closest for inner elements)
172
+ const activeItem = document.activeElement?.closest(ITEM_SELECTOR);
173
+ if (!activeItem)
174
+ return;
175
+ const currentIdx = items.indexOf(activeItem);
176
+ if (currentIdx === -1)
177
+ return;
178
+ const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
179
+ const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
180
+ let targetIdx = null;
181
+ switch (e.key) {
182
+ case nextKey:
183
+ targetIdx = findNextItem(items, currentIdx, 1, loop);
184
+ break;
185
+ case prevKey:
186
+ targetIdx = findNextItem(items, currentIdx, -1, loop);
187
+ break;
188
+ case 'Home':
189
+ targetIdx = findNextItem(items, -1, 1, false);
190
+ break;
191
+ case 'End':
192
+ targetIdx = findNextItem(items, items.length, -1, false);
193
+ break;
194
+ default:
195
+ return;
196
+ }
197
+ if (targetIdx !== null && targetIdx !== currentIdx) {
198
+ e.preventDefault();
199
+ items[targetIdx].focus();
200
+ // focusin handler will update state
201
+ }
202
+ }, [getItems, orientation, loop]);
203
+ return (_jsx("div", { ref: containerRef, role: "toolbar", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-orientation": orientation, className: cx('tui-toolbar', className), style: style,
204
+ // Capture phase catches focus on inner elements (SVG icons, spans) before
205
+ // they handle it, allowing us to update roving tabindex correctly
206
+ onFocusCapture: handleFocusIn, onKeyDown: handleKeyDown, children: children }));
207
+ }
208
+ // =============================================================================
209
+ // Toolbar Group
210
+ // =============================================================================
211
+ function ToolbarGroup({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, children, className, }) {
212
+ // Only add role="group" if accessible name is provided
213
+ // Unnamed groups are worse than no group
214
+ const hasLabel = ariaLabel || ariaLabelledBy;
215
+ return (_jsx("div", { role: hasLabel ? 'group' : undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: cx('tui-toolbar__group', className), children: children }));
216
+ }
217
+ // =============================================================================
218
+ // Toolbar Item
219
+ // =============================================================================
220
+ function ToolbarItem({ children }) {
221
+ if (!isValidElement(children)) {
222
+ console.warn('[Toolbar.Item] Expected a single valid React element as child');
223
+ return null;
224
+ }
225
+ const child = children;
226
+ // cloneElement preserves the child's ref automatically - don't touch it
227
+ // Only add the data attribute for toolbar item registration
228
+ return cloneElement(child, {
229
+ ...child.props,
230
+ 'data-tui-toolbar-item': '',
231
+ });
232
+ }
233
+ // =============================================================================
234
+ // Export Compound Component
235
+ // =============================================================================
236
+ ToolbarGroup.displayName = 'Toolbar.Group';
237
+ ToolbarItem.displayName = 'Toolbar.Item';
238
+ export const Toolbar = ToolbarRoot;
239
+ Toolbar.displayName = 'Toolbar';
240
+ Toolbar.Group = ToolbarGroup;
241
+ Toolbar.Item = ToolbarItem;
@@ -0,0 +1,2 @@
1
+ export { Toolbar } from './Toolbar';
2
+ export type { ToolbarProps, ToolbarGroupProps, ToolbarItemProps, ToolbarOrientation, } from './types';