@inkra/react 0.3.0 → 0.5.0

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 (245) hide show
  1. package/README.md +113 -3
  2. package/dist/Button.js +3 -2
  3. package/dist/PencilIcon.d.ts +10 -0
  4. package/dist/PencilIcon.js +83 -39
  5. package/dist/PencilLine.js +9 -1
  6. package/dist/PencilText.js +15 -9
  7. package/dist/accordion/Accordion.js +65 -5
  8. package/dist/alert-dialog/index.js +14 -4
  9. package/dist/audio-player/index.d.ts +2 -0
  10. package/dist/audio-player/index.js +131 -34
  11. package/dist/breadcrumb/index.js +4 -1
  12. package/dist/calendar/index.d.ts +4 -1
  13. package/dist/calendar/index.js +37 -12
  14. package/dist/calendar/locales.d.ts +7 -0
  15. package/dist/calendar/locales.js +2 -0
  16. package/dist/carousel/index.js +20 -10
  17. package/dist/choice-grid/index.d.ts +6 -1
  18. package/dist/choice-grid/index.js +63 -33
  19. package/dist/color-picker/GradientEditor.d.ts +40 -0
  20. package/dist/color-picker/GradientEditor.js +64 -0
  21. package/dist/color-picker/gradient.d.ts +25 -0
  22. package/dist/color-picker/gradient.js +60 -0
  23. package/dist/color-picker/index.d.ts +27 -7
  24. package/dist/color-picker/index.js +144 -40
  25. package/dist/color-swatch/index.d.ts +10 -0
  26. package/dist/color-swatch/index.js +9 -0
  27. package/dist/command/index.js +27 -4
  28. package/dist/content/index.d.ts +5 -0
  29. package/dist/content/index.js +15 -3
  30. package/dist/context-menu/index.js +1 -1
  31. package/dist/data-table/index.d.ts +7 -1
  32. package/dist/data-table/index.js +67 -16
  33. package/dist/data-table/model.d.ts +2 -0
  34. package/dist/data-table/model.js +7 -0
  35. package/dist/date-picker/index.d.ts +2 -0
  36. package/dist/date-picker/index.js +20 -21
  37. package/dist/day-agenda/index.d.ts +6 -1
  38. package/dist/day-agenda/index.js +49 -21
  39. package/dist/day-agenda/layout.js +65 -9
  40. package/dist/display/ProgressRing.js +7 -3
  41. package/dist/display/index.d.ts +4 -1
  42. package/dist/display/index.js +3 -2
  43. package/dist/dockable-panel/index.js +15 -13
  44. package/dist/drawer/index.js +11 -5
  45. package/dist/editable/index.d.ts +24 -2
  46. package/dist/editable/index.js +148 -40
  47. package/dist/empty/illustrations.d.ts +8 -0
  48. package/dist/empty/illustrations.js +78 -0
  49. package/dist/empty/index.d.ts +6 -1
  50. package/dist/empty/index.js +11 -3
  51. package/dist/flip/index.js +28 -8
  52. package/dist/form/Field.js +11 -10
  53. package/dist/form/Input.js +4 -1
  54. package/dist/form/Textarea.d.ts +3 -1
  55. package/dist/form/Textarea.js +9 -5
  56. package/dist/heatmap/index.js +4 -1
  57. package/dist/hotkey/index.d.ts +1 -0
  58. package/dist/hotkey/index.js +9 -4
  59. package/dist/hover-card/index.js +1 -1
  60. package/dist/i18n/catalog.d.ts +5 -0
  61. package/dist/i18n/catalog.js +289 -0
  62. package/dist/i18n/index.d.ts +23 -0
  63. package/dist/i18n/index.js +50 -0
  64. package/dist/image-cropper/index.js +21 -17
  65. package/dist/image-viewer/VideoResource.d.ts +21 -0
  66. package/dist/image-viewer/VideoResource.js +196 -0
  67. package/dist/image-viewer/index.d.ts +14 -0
  68. package/dist/image-viewer/index.js +141 -86
  69. package/dist/image-viewer/lucideGeometry.d.ts +3 -0
  70. package/dist/image-viewer/lucideGeometry.js +19 -0
  71. package/dist/index.d.ts +4 -0
  72. package/dist/index.js +4 -0
  73. package/dist/input-number/index.js +13 -5
  74. package/dist/internal/cache.d.ts +4 -0
  75. package/dist/internal/cache.js +18 -1
  76. package/dist/internal/iconGeometry.d.ts +2 -0
  77. package/dist/internal/iconGeometry.js +32 -0
  78. package/dist/internal/renderTokens.d.ts +4 -0
  79. package/dist/internal/renderTokens.js +4 -0
  80. package/dist/internal/stroke.d.ts +7 -0
  81. package/dist/internal/stroke.js +63 -7
  82. package/dist/internal/surfaceBudget.d.ts +20 -0
  83. package/dist/internal/surfaceBudget.js +69 -0
  84. package/dist/internal/surfaceRenderer.js +87 -28
  85. package/dist/internal/surfaceVisibility.d.ts +3 -0
  86. package/dist/internal/surfaceVisibility.js +60 -0
  87. package/dist/internal/textRenderer.d.ts +3 -0
  88. package/dist/internal/textRenderer.js +13 -5
  89. package/dist/internal/usePencilSurface.js +17 -1
  90. package/dist/internal/wash.js +4 -3
  91. package/dist/journey-map/index.d.ts +1 -1
  92. package/dist/journey-map/index.js +6 -3
  93. package/dist/journey-map/terrain.js +10 -5
  94. package/dist/keypad/index.js +4 -1
  95. package/dist/layout/index.js +1 -1
  96. package/dist/log/index.js +42 -33
  97. package/dist/markdown/CodeBlock.d.ts +19 -0
  98. package/dist/markdown/CodeBlock.js +86 -0
  99. package/dist/markdown/Deletion.d.ts +2 -0
  100. package/dist/markdown/Deletion.js +69 -0
  101. package/dist/markdown/icons.d.ts +14 -0
  102. package/dist/markdown/icons.js +16 -0
  103. package/dist/markdown/index.d.ts +17 -0
  104. package/dist/markdown/index.js +177 -0
  105. package/dist/node-graph/index.d.ts +3 -1
  106. package/dist/node-graph/index.js +63 -28
  107. package/dist/number-ticker/index.js +4 -1
  108. package/dist/number-ticker/precision.js +9 -2
  109. package/dist/overflow-list/index.js +9 -6
  110. package/dist/overlay/Overlay.js +8 -3
  111. package/dist/overlay/Popover.js +3 -3
  112. package/dist/overlay/themeBridge.js +14 -2
  113. package/dist/pagination/index.d.ts +10 -1
  114. package/dist/pagination/index.js +13 -9
  115. package/dist/pan-zoom/index.d.ts +2 -0
  116. package/dist/pan-zoom/index.js +12 -8
  117. package/dist/pencil.d.ts +1 -0
  118. package/dist/pencil.js +4 -4
  119. package/dist/radar/index.d.ts +2 -0
  120. package/dist/radar/index.js +44 -21
  121. package/dist/range-slider/index.js +4 -3
  122. package/dist/rating/index.d.ts +4 -0
  123. package/dist/rating/index.js +131 -24
  124. package/dist/resizable/index.d.ts +1 -1
  125. package/dist/resizable/index.js +5 -2
  126. package/dist/scroll-area/index.d.ts +1 -1
  127. package/dist/scroll-area/index.js +5 -2
  128. package/dist/select/MultipleSelect.js +5 -4
  129. package/dist/select/SearchableSelect.js +64 -7
  130. package/dist/select/Select.d.ts +1 -0
  131. package/dist/select/Select.js +6 -2
  132. package/dist/select/StepperSelect.d.ts +29 -0
  133. package/dist/select/StepperSelect.js +122 -0
  134. package/dist/select/index.d.ts +2 -0
  135. package/dist/select/index.js +1 -0
  136. package/dist/select/stepperModel.d.ts +7 -0
  137. package/dist/select/stepperModel.js +29 -0
  138. package/dist/selection/Checkbox.js +4 -3
  139. package/dist/selection/RadioGroup.js +29 -13
  140. package/dist/selection/Switch.js +4 -3
  141. package/dist/selection/useNativeChecked.d.ts +2 -1
  142. package/dist/selection/useNativeChecked.js +26 -8
  143. package/dist/slider/index.js +50 -6
  144. package/dist/sortable/index.js +17 -11
  145. package/dist/sparkline/index.js +21 -6
  146. package/dist/sparkline/model.js +11 -4
  147. package/dist/steps/index.js +4 -1
  148. package/dist/styles/accordion.css +228 -0
  149. package/dist/styles/alert-dialog.css +139 -0
  150. package/dist/styles/audio-player.css +22 -0
  151. package/dist/styles/base.css +638 -0
  152. package/dist/styles/breadcrumb.css +7 -0
  153. package/dist/styles/calendar.css +62 -0
  154. package/dist/styles/carousel.css +173 -0
  155. package/dist/styles/choice-grid.css +226 -0
  156. package/dist/styles/color-picker.css +281 -0
  157. package/dist/styles/color-swatch.css +47 -0
  158. package/dist/styles/command.css +52 -0
  159. package/dist/styles/content.css +79 -0
  160. package/dist/styles/context-menu.css +35 -0
  161. package/dist/styles/data-table.css +31 -0
  162. package/dist/styles/date-picker.css +107 -0
  163. package/dist/styles/day-agenda.css +30 -0
  164. package/dist/styles/display.css +304 -0
  165. package/dist/styles/dockable-panel.css +110 -0
  166. package/dist/styles/drawer.css +133 -0
  167. package/dist/styles/dropdown-menu.css +68 -0
  168. package/dist/styles/editable.css +145 -0
  169. package/dist/styles/empty.css +108 -0
  170. package/dist/styles/flip.css +101 -0
  171. package/dist/styles/form.css +634 -0
  172. package/dist/styles/heatmap.css +256 -0
  173. package/dist/styles/hotkey.css +189 -0
  174. package/dist/styles/hover-card.css +27 -0
  175. package/dist/styles/image-cropper.css +205 -0
  176. package/dist/styles/image-viewer.css +422 -0
  177. package/dist/styles/input-number.css +21 -0
  178. package/dist/styles/journey-map.css +19 -0
  179. package/dist/styles/keypad.css +92 -0
  180. package/dist/styles/layout.css +72 -0
  181. package/dist/styles/leaflet.css +661 -0
  182. package/dist/styles/log.css +203 -0
  183. package/dist/styles/markdown.css +379 -0
  184. package/dist/styles/node-graph.css +142 -0
  185. package/dist/styles/number-ticker.css +83 -0
  186. package/dist/styles/overflow-list.css +51 -0
  187. package/dist/styles/overlay.css +284 -0
  188. package/dist/styles/pagination.css +21 -0
  189. package/dist/styles/pan-zoom.css +136 -0
  190. package/dist/styles/radar.css +1 -0
  191. package/dist/styles/rating.css +121 -0
  192. package/dist/styles/resizable.css +108 -0
  193. package/dist/styles/scroll-area.css +122 -0
  194. package/dist/styles/select.css +554 -0
  195. package/dist/styles/selection.css +264 -0
  196. package/dist/styles/slider.css +145 -0
  197. package/dist/styles/slot.css +109 -0
  198. package/dist/styles/sortable.css +159 -0
  199. package/dist/styles/sparkline.css +24 -0
  200. package/dist/styles/steps.css +27 -0
  201. package/dist/styles/tabs.css +79 -0
  202. package/dist/styles/tag-input.css +213 -0
  203. package/dist/styles/time-picker.css +66 -0
  204. package/dist/styles/timeline.css +24 -0
  205. package/dist/styles/toast.css +160 -0
  206. package/dist/styles/toggle-group.css +107 -0
  207. package/dist/styles/tooltip.css +78 -0
  208. package/dist/styles/transfer.css +148 -0
  209. package/dist/styles/tree-select.css +131 -0
  210. package/dist/styles/tree.css +59 -0
  211. package/dist/styles/typewriter.css +62 -0
  212. package/dist/styles/upload.css +60 -0
  213. package/dist/styles/virtual-list.css +112 -0
  214. package/dist/styles.css +2091 -421
  215. package/dist/tabs/index.d.ts +9 -0
  216. package/dist/tabs/index.js +69 -16
  217. package/dist/tabs/useOverflow.d.ts +20 -0
  218. package/dist/tabs/useOverflow.js +145 -0
  219. package/dist/tag-input/index.d.ts +7 -2
  220. package/dist/tag-input/index.js +81 -42
  221. package/dist/theme/index.d.ts +11 -0
  222. package/dist/theme/index.js +23 -0
  223. package/dist/time-picker/index.js +45 -27
  224. package/dist/timeline/index.js +4 -1
  225. package/dist/toast/index.d.ts +19 -11
  226. package/dist/toast/index.js +51 -8
  227. package/dist/toast/manager.d.ts +42 -0
  228. package/dist/toast/manager.js +85 -0
  229. package/dist/toast/useToastNavigation.d.ts +3 -0
  230. package/dist/toast/useToastNavigation.js +75 -0
  231. package/dist/tokens.css +4 -0
  232. package/dist/tooltip/Tooltip.js +3 -3
  233. package/dist/transfer/index.d.ts +5 -0
  234. package/dist/transfer/index.js +120 -53
  235. package/dist/transfer/model.d.ts +4 -0
  236. package/dist/transfer/model.js +37 -11
  237. package/dist/tree/index.d.ts +5 -1
  238. package/dist/tree/index.js +11 -6
  239. package/dist/tree-select/index.js +70 -31
  240. package/dist/tree-select/model.d.ts +11 -5
  241. package/dist/tree-select/model.js +111 -83
  242. package/dist/typewriter/index.js +10 -10
  243. package/dist/upload/index.js +50 -33
  244. package/dist/virtual-list/index.js +11 -6
  245. package/package.json +423 -3
@@ -1,5 +1,7 @@
1
1
  import * as Primitive from '@radix-ui/react-tabs';
2
2
  import { type ReactNode } from 'react';
3
+ import { type TabsOverflow } from './useOverflow.js';
4
+ export type { TabsOverflow } from './useOverflow.js';
3
5
  export interface TabsProps extends Primitive.TabsProps {
4
6
  size?: 'sm' | 'md' | 'lg';
5
7
  animated?: boolean;
@@ -8,10 +10,17 @@ export interface TabsListProps extends Primitive.TabsListProps {
8
10
  align?: 'start' | 'center' | 'end';
9
11
  fullWidth?: boolean;
10
12
  divided?: boolean;
13
+ /** Horizontal overflow interaction. Vertical lists retain native scrolling. */
14
+ overflow?: TabsOverflow;
15
+ moreLabel?: string;
16
+ scrollPreviousLabel?: string;
17
+ scrollNextLabel?: string;
11
18
  }
12
19
  export interface TabsTriggerProps extends Omit<Primitive.TabsTriggerProps, 'asChild'> {
13
20
  startIcon?: ReactNode;
14
21
  endIcon?: ReactNode;
22
+ /** Plain text used by the overflow menu for a custom trigger label. */
23
+ overflowLabel?: string;
15
24
  }
16
25
  export type TabsContentProps = Primitive.TabsContentProps;
17
26
  export declare const Tabs: {
@@ -1,39 +1,92 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useInkraLocale } from '../i18n/index.js';
2
3
  import * as Primitive from '@radix-ui/react-tabs';
3
- import { createContext, forwardRef, useContext, useRef, useState } from 'react';
4
+ import { createContext, forwardRef, useCallback, useContext, useMemo, useRef, useState } from 'react';
5
+ import { __iconData as chevronLeft } from 'lucide-react/dist/esm/icons/chevron-left.mjs';
6
+ import { __iconData as chevronRight } from 'lucide-react/dist/esm/icons/chevron-right.mjs';
7
+ import { Button } from '../Button.js';
8
+ import { DropdownMenu } from '../dropdown-menu/index.js';
9
+ import { PencilIcon } from '../PencilIcon.js';
4
10
  import { PencilSurface } from '../PencilSurface.js';
5
11
  import { PencilText } from '../PencilText.js';
6
12
  import { PencilLine } from '../PencilLine.js';
7
13
  import { useSelectionDrawing } from '../internal/useSelectionDrawing.js';
14
+ import { revealTab, useTabsOverflow } from './useOverflow.js';
15
+ const leftPaths = chevronLeft.node.flatMap(([, attributes]) => attributes.d ? [{ d: attributes.d }] : []);
16
+ const rightPaths = chevronRight.node.flatMap(([, attributes]) => attributes.d ? [{ d: attributes.d }] : []);
17
+ function OverflowIcon({ left = false, down = false }) {
18
+ const paths = left ? leftPaths : rightPaths;
19
+ return _jsxs("span", { className: "inkra-tabs__overflow-icon", "data-down": down || undefined, "aria-hidden": "true", children: [_jsx(PencilIcon, { size: 32, sourceSize: 24, paths: paths }), _jsx("svg", { viewBox: "0 0 40 40", focusable: "false", children: _jsx("g", { transform: "translate(8 8)", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: paths.map((path, index) => _jsx("path", { d: path.d }, index)) }) })] });
20
+ }
8
21
  const ValueContext = createContext(undefined);
9
22
  const AnimatedContext = createContext(true);
10
23
  const OrientationContext = createContext('horizontal');
11
24
  const DividedContext = createContext(false);
25
+ const ChangeContext = createContext(() => { });
26
+ const HiddenContext = createContext(new Set());
12
27
  const Root = forwardRef(function TabsRoot({ value, defaultValue, onValueChange, className, size = 'md', animated = true, orientation = 'horizontal', ...props }, ref) {
13
28
  const [internal, setInternal] = useState(defaultValue);
14
29
  const current = value === undefined ? internal : value;
15
- const lastChange = useRef(current);
16
- lastChange.current = current;
17
- return _jsx(ValueContext.Provider, { value: current, children: _jsx(AnimatedContext.Provider, { value: animated, children: _jsx(OrientationContext.Provider, { value: orientation, children: _jsx(Primitive.Root, { ...props, orientation: orientation, ref: ref, "data-size": size, className: ['inkra-tabs', className].filter(Boolean).join(' '), value: current ?? '', onValueChange: next => {
18
- // Pointer focus and pointer down can request the same tab before React renders.
19
- if (lastChange.current === next)
20
- return;
21
- lastChange.current = next;
22
- if (value === undefined)
23
- setInternal(next);
24
- onValueChange?.(next);
25
- } }) }) }) });
30
+ const pendingRequest = useRef(null);
31
+ const change = (next) => {
32
+ // Radix pointer activation can request once from focus and once from pointer down.
33
+ // Deduplicate only that synchronous interaction so a rejected controlled value can retry.
34
+ if (pendingRequest.current === next)
35
+ return;
36
+ pendingRequest.current = next;
37
+ queueMicrotask(() => {
38
+ if (pendingRequest.current === next)
39
+ pendingRequest.current = null;
40
+ });
41
+ if (value === undefined)
42
+ setInternal(next);
43
+ onValueChange?.(next);
44
+ };
45
+ return _jsx(ValueContext.Provider, { value: current, children: _jsx(ChangeContext.Provider, { value: change, children: _jsx(AnimatedContext.Provider, { value: animated, children: _jsx(OrientationContext.Provider, { value: orientation, children: _jsx(Primitive.Root, { ...props, orientation: orientation, ref: ref, "data-size": size, className: ['inkra-tabs', className].filter(Boolean).join(' '), value: current ?? '', onValueChange: change }) }) }) }) });
26
46
  });
27
- const List = forwardRef(function TabsList({ className, align = 'start', fullWidth = false, divided = false, ...props }, ref) {
28
- return _jsx(DividedContext.Provider, { value: divided, children: _jsx(Primitive.List, { ...props, ref: ref, "data-align": align, "data-full-width": fullWidth || undefined, "data-divided": divided || undefined, className: ['inkra-tabs__list', className].filter(Boolean).join(' ') }) });
47
+ const List = forwardRef(function TabsList(inkraProps, ref) {
48
+ const { t } = useInkraLocale();
49
+ const { className, align = 'start', fullWidth = false, divided = false, overflow = 'scroll', moreLabel = t('tabs.more'), scrollPreviousLabel = t('tabs.previous'), scrollNextLabel = t('tabs.next'), onFocusCapture, ...props } = inkraProps;
50
+ const current = useContext(ValueContext);
51
+ const change = useContext(ChangeContext);
52
+ const mode = useContext(OrientationContext) === 'horizontal' ? overflow : 'scroll';
53
+ const state = useTabsOverflow(mode, current);
54
+ const { listRef } = state;
55
+ const hidden = useMemo(() => new Set(mode === 'menu' ? state.items.map(item => item.value) : []), [mode, state.items]);
56
+ const selectionPending = useRef(false);
57
+ const setRef = useCallback((node) => {
58
+ listRef.current = node;
59
+ if (typeof ref === 'function')
60
+ ref(node);
61
+ else if (ref)
62
+ ref.current = node;
63
+ }, [listRef, ref]);
64
+ const list = _jsx(Primitive.List, { ...props, ref: setRef, "data-overflow": mode, "data-align": align, "data-full-width": fullWidth || undefined, "data-divided": divided || undefined, className: ['inkra-tabs__list', className].filter(Boolean).join(' '), onFocusCapture: event => {
65
+ onFocusCapture?.(event);
66
+ if (!event.defaultPrevented && mode !== 'menu')
67
+ revealTab(listRef.current, event.target.closest('[data-tabs-value]'));
68
+ } });
69
+ const directionIcon = (previous) => _jsx(OverflowIcon, { left: previous !== state.rtl });
70
+ return _jsx(DividedContext.Provider, { value: divided, children: _jsx(HiddenContext.Provider, { value: hidden, children: mode === 'scroll' ? list : _jsxs("div", { className: "inkra-tabs__overflow", "data-overflow": mode, "data-has-overflow": state.overflowing || undefined, ref: state.containerRef, children: [mode === 'buttons' && state.overflowing && _jsx(Button, { size: "icon", variant: "ghost", className: "inkra-tabs__scroll-button", "aria-label": scrollPreviousLabel, title: scrollPreviousLabel, disabled: !state.previous, onClick: () => state.scroll(-1), children: directionIcon(true) }), list, mode === 'buttons' && state.overflowing && _jsx(Button, { size: "icon", variant: "ghost", className: "inkra-tabs__scroll-button", "aria-label": scrollNextLabel, title: scrollNextLabel, disabled: !state.next, onClick: () => state.scroll(1), children: directionIcon(false) }), mode === 'menu' && state.overflowing && _jsxs(DropdownMenu.Root, { dir: state.rtl ? 'rtl' : 'ltr', children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsx(Button, { ref: state.moreRef, size: "icon", variant: "ghost", className: "inkra-tabs__more", "aria-label": moreLabel, title: moreLabel, children: _jsx(OverflowIcon, { down: true }) }) }), _jsx(DropdownMenu.Portal, { children: _jsx(DropdownMenu.Content, { align: "end", "aria-label": moreLabel, onCloseAutoFocus: event => {
71
+ if (!selectionPending.current)
72
+ return;
73
+ selectionPending.current = false;
74
+ const selected = Array.from(listRef.current?.querySelectorAll('[data-tabs-value]') ?? [])
75
+ .find(node => node.dataset.tabsValue === current && !node.hasAttribute('data-overflow-hidden') && !node.disabled);
76
+ if (selected) {
77
+ event.preventDefault();
78
+ selected.focus({ preventScroll: true });
79
+ }
80
+ }, children: _jsx(DropdownMenu.RadioGroup, { value: current ?? '', onValueChange: next => { selectionPending.current = true; change(next); }, children: state.items.map(item => _jsx(DropdownMenu.RadioItem, { value: item.value, disabled: item.disabled, children: item.label }, item.value)) }) }) })] })] }) }) });
29
81
  });
30
- const Trigger = forwardRef(function TabsTrigger({ value, className, children, startIcon, endIcon, ...props }, ref) {
82
+ const Trigger = forwardRef(function TabsTrigger({ value, className, children, startIcon, endIcon, overflowLabel, ...props }, ref) {
31
83
  const current = useContext(ValueContext);
32
84
  const divided = useContext(DividedContext);
33
85
  const orientation = useContext(OrientationContext);
86
+ const hidden = useContext(HiddenContext).has(value);
34
87
  const surface = useRef(null);
35
88
  useSelectionDrawing(surface, current === value, useContext(AnimatedContext));
36
- return _jsxs(Primitive.Trigger, { ...props, value: value, ref: ref, className: ['inkra-tabs__trigger', className].filter(Boolean).join(' '), children: [divided && _jsx(PencilLine, { className: "inkra-tabs__divider", orientation: orientation === 'horizontal' ? 'vertical' : 'horizontal', seed: `tab-divider-${value}` }), _jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-tabs__selection", variant: "selection", seed: `tab-${value}`, ref: surface }), _jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-tabs__focus-ring", variant: "outline", seed: `tab-${value}-focus` }), startIcon && _jsx("span", { className: "inkra-tabs__icon", "aria-hidden": "true", children: startIcon }), _jsx("span", { className: "inkra-tabs__label", children: typeof children === 'string' ? _jsx(PencilText, { children: children }) : children }), endIcon && _jsx("span", { className: "inkra-tabs__icon", "aria-hidden": "true", children: endIcon })] });
89
+ return _jsxs(Primitive.Trigger, { ...props, value: value, ref: ref, "data-tabs-value": value, "data-overflow-label": overflowLabel, "data-overflow-hidden": hidden || undefined, "aria-hidden": hidden || props['aria-hidden'], inert: hidden || undefined, className: ['inkra-tabs__trigger', className].filter(Boolean).join(' '), children: [divided && _jsx(PencilLine, { className: "inkra-tabs__divider", orientation: orientation === 'horizontal' ? 'vertical' : 'horizontal', seed: `tab-divider-${value}` }), _jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-tabs__selection", variant: "selection", seed: `tab-${value}`, ref: surface }), _jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-tabs__focus-ring", variant: "outline", seed: `tab-${value}-focus` }), startIcon && _jsx("span", { className: "inkra-tabs__icon", "aria-hidden": "true", children: startIcon }), _jsx("span", { className: "inkra-tabs__label", children: typeof children === 'string' ? _jsx(PencilText, { children: children }) : children }), endIcon && _jsx("span", { className: "inkra-tabs__icon", "aria-hidden": "true", children: endIcon })] });
37
90
  });
38
91
  const Content = forwardRef(function TabsContent({ className, value, ...props }, ref) {
39
92
  const current = useContext(ValueContext);
@@ -0,0 +1,20 @@
1
+ export type TabsOverflow = 'scroll' | 'menu' | 'buttons';
2
+ interface OverflowItem {
3
+ value: string;
4
+ label: string;
5
+ disabled: boolean;
6
+ }
7
+ export declare function revealTab(list: HTMLDivElement | null, tab: HTMLElement | null): void;
8
+ export declare function useTabsOverflow(mode: TabsOverflow, current: string | undefined): {
9
+ listRef: import("react").RefObject<HTMLDivElement | null>;
10
+ containerRef: import("react").RefObject<HTMLDivElement | null>;
11
+ moreRef: import("react").RefObject<HTMLButtonElement | null>;
12
+ scroll: (direction: -1 | 1) => void;
13
+ measure: () => void;
14
+ overflowing: boolean;
15
+ previous: boolean;
16
+ next: boolean;
17
+ rtl: boolean;
18
+ items: OverflowItem[];
19
+ };
20
+ export {};
@@ -0,0 +1,145 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useBrowserLayoutEffect } from '../internal/useBrowserLayoutEffect.js';
3
+ import { calculateOverflowListLayout } from '../overflow-list/layout.js';
4
+ const initialState = { overflowing: false, previous: false, next: false, rtl: false, items: [] };
5
+ function triggers(list) {
6
+ return Array.from(list.querySelectorAll('[data-tabs-value]'))
7
+ .filter(node => node.closest('[role="tablist"]') === list);
8
+ }
9
+ export function revealTab(list, tab) {
10
+ if (!list || !tab || tab.hasAttribute('data-overflow-hidden'))
11
+ return;
12
+ const view = list.getBoundingClientRect();
13
+ const item = tab.getBoundingClientRect();
14
+ const delta = item.left < view.left ? item.left - view.left : item.right > view.right ? item.right - view.right : 0;
15
+ if (delta)
16
+ list.scrollBy({ left: delta, behavior: 'instant' });
17
+ }
18
+ export function useTabsOverflow(mode, current) {
19
+ const listRef = useRef(null);
20
+ const containerRef = useRef(null);
21
+ const moreRef = useRef(null);
22
+ const focusAfterLayout = useRef(null);
23
+ const [state, setState] = useState(initialState);
24
+ const measure = useCallback(() => {
25
+ const list = listRef.current;
26
+ const container = containerRef.current;
27
+ if (mode === 'scroll' || !list || !container || container.clientWidth <= 0)
28
+ return;
29
+ const nodes = triggers(list);
30
+ const rtl = getComputedStyle(list).direction === 'rtl';
31
+ let next;
32
+ if (mode === 'menu') {
33
+ const gap = parseFloat(getComputedStyle(list).columnGap) || 0;
34
+ const controlWidth = moreRef.current?.getBoundingClientRect().width || 44;
35
+ // Use the layout box, not scrollWidth: pencil canvases include decorative paint bleed.
36
+ const items = nodes.map(node => {
37
+ const style = getComputedStyle(node);
38
+ const label = node.querySelector('.inkra-tabs__label');
39
+ const labelWidth = label ? parseFloat(getComputedStyle(label).width) || label.offsetWidth : 0;
40
+ const icons = Array.from(node.querySelectorAll(':scope > .inkra-tabs__icon'));
41
+ const width = Math.max(parseFloat(style.minWidth) || 44, labelWidth + icons.reduce((sum, icon) => sum + icon.offsetWidth, 0)
42
+ + icons.length * (parseFloat(style.columnGap) || 0)
43
+ + (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0));
44
+ return { key: node.dataset.tabsValue, width, priority: node.dataset.tabsValue === current ? 1 : 0 };
45
+ });
46
+ const layout = calculateOverflowListLayout({ items, containerWidth: container.clientWidth, gap, strategy: 'priority', triggerWidth: controlWidth });
47
+ const hidden = new Set(layout.overflowKeys);
48
+ next = {
49
+ overflowing: hidden.size > 0, previous: false, next: false, rtl,
50
+ items: nodes.filter(node => hidden.has(node.dataset.tabsValue)).map(node => ({
51
+ value: node.dataset.tabsValue,
52
+ label: node.dataset.overflowLabel ?? node.getAttribute('aria-label') ?? node.querySelector('.inkra-tabs__label')?.textContent ?? '',
53
+ disabled: node.disabled,
54
+ })),
55
+ };
56
+ }
57
+ else {
58
+ const max = Math.max(0, list.scrollWidth - list.clientWidth);
59
+ const position = Math.min(max, Math.max(0, rtl ? -list.scrollLeft : list.scrollLeft));
60
+ next = { overflowing: list.scrollWidth > container.clientWidth + 1, previous: position > 1, next: position < max - 1, rtl, items: [] };
61
+ }
62
+ const active = list.ownerDocument.activeElement;
63
+ if (mode === 'menu' && active && nodes.some(node => node === active && next.items.some(item => item.value === node.dataset.tabsValue))) {
64
+ focusAfterLayout.current = 'more';
65
+ }
66
+ else if (!next.overflowing && moreRef.current && (active === moreRef.current || moreRef.current.getAttribute('aria-expanded') === 'true')) {
67
+ focusAfterLayout.current = 'selected';
68
+ }
69
+ setState(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);
70
+ }, [mode, current]);
71
+ useBrowserLayoutEffect(measure);
72
+ useBrowserLayoutEffect(() => {
73
+ if (!focusAfterLayout.current || !listRef.current)
74
+ return;
75
+ const nodes = triggers(listRef.current);
76
+ const target = focusAfterLayout.current === 'more' ? moreRef.current
77
+ : nodes.find(node => node.dataset.tabsValue === current && !node.disabled) ?? nodes.find(node => !node.disabled);
78
+ if (target) {
79
+ target.focus({ preventScroll: true });
80
+ focusAfterLayout.current = null;
81
+ }
82
+ }, [state, current]);
83
+ useEffect(() => {
84
+ const list = listRef.current;
85
+ if (!list || mode === 'scroll')
86
+ return;
87
+ let frame = 0;
88
+ const schedule = () => {
89
+ if (!frame)
90
+ frame = requestAnimationFrame(() => { frame = 0; measure(); });
91
+ };
92
+ const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
93
+ const observe = () => {
94
+ resize?.disconnect();
95
+ resize?.observe(list);
96
+ if (containerRef.current)
97
+ resize?.observe(containerRef.current);
98
+ for (const node of triggers(list)) {
99
+ resize?.observe(node);
100
+ const label = node.querySelector('.inkra-tabs__label');
101
+ if (label)
102
+ resize?.observe(label);
103
+ }
104
+ };
105
+ observe();
106
+ const mutation = new MutationObserver(records => {
107
+ if (records.some(record => record.type === 'childList'))
108
+ observe();
109
+ schedule();
110
+ });
111
+ mutation.observe(list, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['disabled', 'data-tabs-value', 'data-overflow-label'] });
112
+ const direction = new MutationObserver(schedule);
113
+ for (let node = list; node; node = node.parentElement) {
114
+ direction.observe(node, { attributes: true, attributeFilter: ['dir', 'style', 'class'] });
115
+ }
116
+ list.addEventListener('scroll', schedule, { passive: true });
117
+ if (!resize)
118
+ window.addEventListener('resize', schedule);
119
+ document.fonts?.addEventListener('loadingdone', schedule);
120
+ return () => {
121
+ cancelAnimationFrame(frame);
122
+ resize?.disconnect();
123
+ mutation.disconnect();
124
+ direction.disconnect();
125
+ list.removeEventListener('scroll', schedule);
126
+ window.removeEventListener('resize', schedule);
127
+ document.fonts?.removeEventListener('loadingdone', schedule);
128
+ };
129
+ }, [measure, mode]);
130
+ useBrowserLayoutEffect(() => {
131
+ const list = listRef.current;
132
+ if (list && mode !== 'menu')
133
+ revealTab(list, triggers(list).find(node => node.dataset.tabsValue === current) ?? null);
134
+ }, [current, mode]);
135
+ const scroll = (direction) => {
136
+ const list = listRef.current;
137
+ if (!list)
138
+ return;
139
+ list.scrollBy({
140
+ left: direction * (state.rtl ? -1 : 1) * Math.max(44, list.clientWidth * 0.8),
141
+ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth',
142
+ });
143
+ };
144
+ return { ...state, listRef, containerRef, moreRef, scroll, measure };
145
+ }
@@ -1,6 +1,6 @@
1
1
  import type { HTMLAttributes, InputHTMLAttributes } from 'react';
2
2
  import type { FieldControlSize, FieldControlStateProps } from '../form/types.js';
3
- import { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, hasTagValue, normalizeTagInputValue, splitTagInputValue } from './model.js';
3
+ import { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, normalizeTagInputValue, splitTagInputValue } from './model.js';
4
4
  import type { TagInputIdentity } from './model.js';
5
5
  export interface TagInputTag extends TagInputIdentity {
6
6
  label?: string;
@@ -51,6 +51,10 @@ export interface TagInputProps extends Omit<HTMLAttributes<HTMLDivElement>, 'chi
51
51
  getTagValueKey?: (value: string) => string;
52
52
  delimiters?: readonly string[];
53
53
  maxTags?: number;
54
+ /** Opt-in initial display bound. Every value remains submitted and can be expanded. */
55
+ maxVisibleTags?: number;
56
+ showAllTagsLabel?: (count: number) => string;
57
+ showFewerTagsLabel?: string;
54
58
  disabled?: boolean;
55
59
  readOnly?: boolean;
56
60
  required?: boolean;
@@ -65,5 +69,6 @@ export interface TagInputProps extends Omit<HTMLAttributes<HTMLDivElement>, 'chi
65
69
  classNames?: TagInputClassNames;
66
70
  }
67
71
  export declare const TagInput: import("react").ForwardRefExoticComponent<TagInputProps & import("react").RefAttributes<HTMLInputElement>>;
68
- export { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, hasTagValue, normalizeTagInputValue, splitTagInputValue, };
72
+ export { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, normalizeTagInputValue, splitTagInputValue, };
73
+ export { hasTagValue } from './model.js';
69
74
  export type { FieldControlSize, TagInputIdentity };
@@ -1,34 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from 'react';
2
+ import { useInkraLocale } from '../i18n/index.js';
3
+ import { forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from 'react';
3
4
  import { PencilIcon } from '../PencilIcon.js';
5
+ import { Button } from '../Button.js';
4
6
  import { PencilLine } from '../PencilLine.js';
5
7
  import { PencilSurface } from '../PencilSurface.js';
6
8
  import { PencilText } from '../PencilText.js';
7
9
  import { useResolvedControlProps } from '../form/controlProps.js';
8
- import { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, hasTagValue, normalizeTagInputValue, splitTagInputValue, } from './model.js';
10
+ import { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, normalizeTagInputValue, splitTagInputValue, } from './model.js';
9
11
  const removePaths = [{ d: 'M18 6 6 18' }, { d: 'm6 6 12 12' }];
10
12
  const clearPaths = [{ d: 'M4 7h16' }, { d: 'M9 7V4h6v3' }, { d: 'm7 7 1 13h8l1-13' }];
11
13
  const classes = (...values) => values.filter(Boolean).join(' ');
12
- const defaultLabels = {
13
- tagList: 'Selected tags',
14
- removeTag: tag => `Remove ${tag.label ?? tag.value}`,
15
- clearAll: 'Clear all tags',
16
- duplicate: candidate => `${candidate} is already added.`,
17
- maxTags: maximum => `You can add up to ${maximum} tags.`,
18
- invalid: candidate => `${candidate} is not a valid tag.`,
19
- };
20
14
  function joinAriaIds(...values) {
21
15
  const ids = values.flatMap(value => value?.split(/\s+/).filter(Boolean) ?? []);
22
16
  return [...new Set(ids)].join(' ') || undefined;
23
17
  }
24
- function resolvedLabels(labels) {
18
+ function resolvedLabels(labels, t) {
25
19
  return {
26
- tagList: labels?.tagList ?? defaultLabels.tagList,
27
- removeTag: labels?.removeTag ?? defaultLabels.removeTag,
28
- clearAll: labels?.clearAll ?? defaultLabels.clearAll,
29
- duplicate: labels?.duplicate ?? defaultLabels.duplicate,
30
- maxTags: labels?.maxTags ?? defaultLabels.maxTags,
31
- invalid: labels?.invalid ?? defaultLabels.invalid,
20
+ tagList: labels?.tagList ?? t('tagInput.list'),
21
+ removeTag: labels?.removeTag ?? (tag => t('tagInput.remove', { label: tag.label ?? tag.value })),
22
+ clearAll: labels?.clearAll ?? t('tagInput.clear'),
23
+ duplicate: labels?.duplicate ?? (label => t('tagInput.duplicate', { label })),
24
+ maxTags: labels?.maxTags ?? (count => t('tagInput.maximum', { count })),
25
+ invalid: labels?.invalid ?? (label => t('tagInput.invalid', { label })),
32
26
  };
33
27
  }
34
28
  function validationText(result, fallback) {
@@ -38,7 +32,30 @@ function validationText(result, fallback) {
38
32
  return fallback;
39
33
  return undefined;
40
34
  }
41
- export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaDescribedBy, 'aria-errormessage': ariaErrorMessage, 'aria-invalid': ariaInvalid, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, classNames = {}, clearable = true, createTag, defaultInputValue = '', defaultValue = [], delimiters = defaultTagInputDelimiters, disabled, form, fullWidth = true, getTagValueKey = defaultTagValueKey, id, inputProps = {}, inputValue, invalid = false, labels, maxTags, name, onClick, onInputValueChange, onValidationError, onValueChange, placeholder, readOnly, required, seed, size = 'md', validationMessage, validateTag, value, variant = 'outline', ...rootProps }, forwardedRef) {
35
+ // Rows contain only their own display/ref inputs. Events bubble to the list,
36
+ // whose current handlers own selection, callbacks, guards and tag positions.
37
+ const TagInputTagRow = memo(function TagInputTagRow({ tag, index, disabled, readOnly, className, seed, removeLabel, removeRefs, }) {
38
+ const removeText = readOnly ? undefined : removeLabel(tag);
39
+ return _jsxs(PencilSurface, { as: "li", className: classes('inkra-tag-input__tag', className), "data-tag-id": tag.id, radius: 6, seed: `${seed}-tag-${tag.id}`, variant: "quiet", children: [_jsx("span", { className: "inkra-tag-input__tag-label", children: tag.label ?? tag.value }), !readOnly ? _jsx("button", { "aria-label": removeText, className: "inkra-tag-input__remove", "data-inkra-tag-index": index, disabled: disabled, ref: node => {
40
+ if (node)
41
+ removeRefs.current.set(tag.id, node);
42
+ else
43
+ removeRefs.current.delete(tag.id);
44
+ }, title: removeText, type: "button", children: _jsx(PencilIcon, { decorative: true, paths: removePaths, seed: `${seed}-remove-${tag.id}`, size: 18, sourceSize: 24 }) }) : null] });
45
+ });
46
+ function tagRemoveTarget(event, length) {
47
+ const target = event.target;
48
+ const button = typeof target.closest === 'function'
49
+ ? target.closest('button.inkra-tag-input__remove[data-inkra-tag-index]')
50
+ : null;
51
+ if (!button || !event.currentTarget.contains(button))
52
+ return undefined;
53
+ const index = Number(button.getAttribute('data-inkra-tag-index'));
54
+ return Number.isInteger(index) && index >= 0 && index < length ? { button, index } : undefined;
55
+ }
56
+ export const TagInput = forwardRef(function TagInput(inkraProps, forwardedRef) {
57
+ const { t } = useInkraLocale();
58
+ const { 'aria-describedby': ariaDescribedBy, 'aria-errormessage': ariaErrorMessage, 'aria-invalid': ariaInvalid, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, classNames = {}, clearable = true, createTag, defaultInputValue = '', defaultValue = [], delimiters = defaultTagInputDelimiters, disabled, form, fullWidth = true, getTagValueKey = defaultTagValueKey, id, inputProps = {}, inputValue, invalid = false, labels, maxTags, maxVisibleTags, showAllTagsLabel = count => t('tagInput.all', { count }), showFewerTagsLabel = t('tagInput.fewer'), name, onClick, onInputValueChange, onValidationError, onValueChange, placeholder, readOnly, required, seed, size = 'md', validationMessage, validateTag, value, variant = 'outline', ...rootProps } = inkraProps;
42
59
  const generatedId = useId();
43
60
  const inputRef = useRef(null);
44
61
  const removeRefs = useRef(new Map());
@@ -50,11 +67,13 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
50
67
  const [localValidationMessage, setLocalValidationMessage] = useState();
51
68
  const [focusWithin, setFocusWithin] = useState(false);
52
69
  const [pendingFocus, setPendingFocus] = useState(null);
70
+ const [expandedTags, setExpandedTags] = useState(false);
71
+ const tagDisplayLimit = maxVisibleTags !== undefined && Number.isFinite(maxVisibleTags) ? Math.max(0, Math.floor(maxVisibleTags)) : Infinity;
53
72
  const tagsControlled = value !== undefined;
54
73
  const draftControlled = inputValue !== undefined;
55
74
  const currentTags = tagsControlled ? value : uncontrolledTags;
56
75
  const currentDraft = draftControlled ? inputValue : uncontrolledDraft;
57
- const copy = useMemo(() => resolvedLabels(labels), [labels]);
76
+ const copy = useMemo(() => resolvedLabels(labels, t), [labels, t]);
58
77
  const activeValidationMessage = validationMessage ?? localValidationMessage;
59
78
  const messageId = activeValidationMessage ? `inkra-tag-input-${generatedId}-message` : undefined;
60
79
  const state = useResolvedControlProps({
@@ -86,12 +105,16 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
86
105
  useEffect(() => {
87
106
  if (!pendingFocus)
88
107
  return;
108
+ if ('tagId' in pendingFocus && !expandedTags && currentTags.findIndex(tag => tag.id === pendingFocus.tagId) >= tagDisplayLimit) {
109
+ setExpandedTags(true);
110
+ return;
111
+ }
89
112
  const target = 'input' in pendingFocus
90
113
  ? inputRef.current
91
114
  : removeRefs.current.get(pendingFocus.tagId) ?? inputRef.current;
92
115
  target?.focus({ preventScroll: true });
93
116
  setPendingFocus(null);
94
- }, [currentTags, pendingFocus]);
117
+ }, [currentTags, pendingFocus, expandedTags, tagDisplayLimit]);
95
118
  useEffect(() => {
96
119
  const input = inputRef.current;
97
120
  const ownerForm = input?.form;
@@ -121,11 +144,11 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
121
144
  setUncontrolledDraft(next);
122
145
  onInputValueChange?.(next, details);
123
146
  };
124
- const commitTags = (next, details) => {
147
+ const commitTags = useCallback((next, details) => {
125
148
  if (!tagsControlled)
126
149
  setUncontrolledTags([...next]);
127
150
  onValueChange?.(next, details);
128
- };
151
+ }, [tagsControlled, onValueChange]);
129
152
  const reportValidation = (candidate, reason, code, message, tags) => {
130
153
  setLocalValidationMessage(message);
131
154
  onValidationError?.(message, { candidate, code, reason, tags });
@@ -134,6 +157,8 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
134
157
  if (!canEdit)
135
158
  return false;
136
159
  const working = [...currentTags];
160
+ const valueKeys = new Set(currentTags.map(tag => getTagValueKey(tag.value)));
161
+ const ids = new Set(currentTags.map(tag => tag.id));
137
162
  const added = [];
138
163
  let rejected;
139
164
  for (const rawCandidate of candidates) {
@@ -144,7 +169,7 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
144
169
  rejected = { candidate, code: 'max-tags', message: copy.maxTags(limit) };
145
170
  break;
146
171
  }
147
- if (hasTagValue(working, candidate, getTagValueKey)) {
172
+ if (valueKeys.has(getTagValueKey(candidate))) {
148
173
  rejected = { candidate, code: 'duplicate', message: copy.duplicate(candidate) };
149
174
  break;
150
175
  }
@@ -159,11 +184,13 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
159
184
  rejected = { candidate, code: 'invalid', message: copy.invalid(candidate) };
160
185
  break;
161
186
  }
162
- if (working.some(current => current.id === tag.id) || hasTagValue(working, tag.value, getTagValueKey)) {
187
+ if (ids.has(tag.id) || valueKeys.has(getTagValueKey(tag.value))) {
163
188
  rejected = { candidate, code: 'duplicate', message: copy.duplicate(candidate) };
164
189
  break;
165
190
  }
166
191
  working.push(tag);
192
+ ids.add(tag.id);
193
+ valueKeys.add(getTagValueKey(tag.value));
167
194
  added.push(tag);
168
195
  }
169
196
  if (added.length > 0)
@@ -183,7 +210,7 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
183
210
  const parsed = splitTagInputValue(currentDraft, delimiters);
184
211
  return commitCandidates(parsed.parts, reason);
185
212
  };
186
- const removeTag = (index, focus = null) => {
213
+ const removeTag = useCallback((index, focus = null) => {
187
214
  if (!canEdit)
188
215
  return;
189
216
  const tag = currentTags[index];
@@ -198,7 +225,7 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
198
225
  setLocalValidationMessage(undefined);
199
226
  commitTags(next, { reason: 'remove', tags: [tag] });
200
227
  setPendingFocus(nextFocus);
201
- };
228
+ }, [canEdit, currentTags, commitTags]);
202
229
  const clearAll = () => {
203
230
  if (!canEdit || currentTags.length === 0)
204
231
  return;
@@ -208,11 +235,15 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
208
235
  updateDraft('', { reason: 'clear' });
209
236
  setPendingFocus({ input: true });
210
237
  };
211
- const focusRemoveButton = (index) => {
238
+ const focusRemoveButton = useCallback((index) => {
212
239
  const tag = currentTags[index];
213
- if (tag)
214
- removeRefs.current.get(tag.id)?.focus({ preventScroll: true });
215
- };
240
+ if (tag) {
241
+ if (!expandedTags && index >= tagDisplayLimit)
242
+ setPendingFocus({ tagId: tag.id });
243
+ else
244
+ removeRefs.current.get(tag.id)?.focus({ preventScroll: true });
245
+ }
246
+ }, [currentTags, expandedTags, tagDisplayLimit]);
216
247
  const handleInputChange = (event) => {
217
248
  inputProps.onChange?.(event);
218
249
  if (event.defaultPrevented)
@@ -265,10 +296,10 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
265
296
  commitDraft('delimiter');
266
297
  }
267
298
  };
268
- const handleTagKeyDown = (event, index) => {
299
+ const handleTagKeyDown = useCallback((event, index, button) => {
269
300
  if (event.defaultPrevented || !canEdit)
270
301
  return;
271
- const direction = getComputedStyle(event.currentTarget).direction;
302
+ const direction = getComputedStyle(button).direction;
272
303
  const previousKey = direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
273
304
  const nextKey = direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
274
305
  if (event.key === previousKey) {
@@ -294,8 +325,12 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
294
325
  event.preventDefault();
295
326
  removeTag(index);
296
327
  }
297
- };
298
- return (_jsxs("div", { ...rootProps, className: classes('inkra-field-control-hit', 'inkra-tag-input', className, classNames.root), "data-disabled": state.disabled || undefined, "data-focus-within": focusWithin || undefined, "data-full-width": fullWidth, "data-invalid": state.invalid || undefined, "data-max-reached": atLimit || undefined, "data-readonly": state.readOnly || undefined, "data-size": size, "data-variant": variant, onBlurCapture: event => {
328
+ }, [canEdit, currentTags.length, focusRemoveButton, removeTag]);
329
+ // Stable rows survive immutable edits to other tags. Parent event handlers
330
+ // remain fresh instead of hiding a changing callback behind a memo comparator.
331
+ const tagRows = useMemo(() => (expandedTags || tagDisplayLimit >= currentTags.length ? currentTags : currentTags.slice(0, tagDisplayLimit)).map((tag, index) => _jsx(TagInputTagRow, { className: classNames.tag, disabled: state.disabled, index: index, readOnly: state.readOnly, removeLabel: copy.removeTag, removeRefs: removeRefs, seed: resolvedSeed, tag: tag }, `${tag.id}-${index}`)), [currentTags, state.disabled, state.readOnly, copy.removeTag, classNames.tag, resolvedSeed, expandedTags, tagDisplayLimit]);
332
+ const formValues = useMemo(() => name ? currentTags.map((tag, index) => (_jsx("input", { disabled: state.disabled, form: form, name: name, type: "hidden", value: tag.value }, `${tag.id}-${index}`))) : null, [currentTags, state.disabled, form, name]);
333
+ return (_jsxs("div", { ...rootProps, className: classes('inkra-field-control-hit', 'inkra-tag-input', className, classNames.root), "data-disabled": state.disabled || undefined, "data-draft-empty": currentDraft.length === 0 || undefined, "data-focus-within": focusWithin || undefined, "data-full-width": fullWidth, "data-has-placeholder": Boolean(placeholder) || undefined, "data-has-tags": currentTags.length > 0 || undefined, "data-invalid": state.invalid || undefined, "data-max-reached": atLimit || undefined, "data-readonly": state.readOnly || undefined, "data-size": size, "data-variant": variant, onBlurCapture: event => {
299
334
  if (!event.currentTarget.contains(event.relatedTarget))
300
335
  setFocusWithin(false);
301
336
  rootProps.onBlurCapture?.(event);
@@ -309,17 +344,21 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
309
344
  }, onFocusCapture: event => {
310
345
  setFocusWithin(true);
311
346
  rootProps.onFocusCapture?.(event);
312
- }, children: [_jsxs(PencilSurface, { as: "div", className: classes('inkra-field-control', 'inkra-tag-input__control', classNames.control), radius: 8, seed: resolvedSeed, variant: surfaceVariant, children: [_jsx("ul", { "aria-label": copy.tagList, className: classes('inkra-tag-input__list', classNames.list), children: currentTags.map((tag, index) => (_jsxs(PencilSurface, { as: "li", className: classes('inkra-tag-input__tag', classNames.tag), clipContent: true, "data-tag-id": tag.id, radius: 6, seed: `${resolvedSeed}-tag-${tag.id}`, variant: "quiet", children: [_jsx("span", { className: "inkra-tag-input__tag-label", children: tag.label ?? tag.value }), !state.readOnly ? (_jsx("button", { "aria-label": copy.removeTag(tag), className: "inkra-tag-input__remove", disabled: state.disabled, onClick: () => removeTag(index), onKeyDown: event => handleTagKeyDown(event, index), ref: node => {
313
- if (node)
314
- removeRefs.current.set(tag.id, node);
315
- else
316
- removeRefs.current.delete(tag.id);
317
- }, title: copy.removeTag(tag), type: "button", children: _jsx(PencilIcon, { decorative: true, paths: removePaths, seed: `${resolvedSeed}-remove-${tag.id}`, size: 18, sourceSize: 24 }) })) : null] }, `${tag.id}-${index}`))) }), _jsx("input", { ...inputProps, "aria-describedby": state.describedBy, "aria-errormessage": state.errorMessage, "aria-invalid": state.ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-readonly": state.readOnly || undefined, "aria-required": state.required || undefined, autoComplete: inputProps.autoComplete ?? 'off', className: classes('inkra-input', 'inkra-tag-input__input', inputProps.className, classNames.input), disabled: state.disabled, form: form, id: state.id, onChange: handleInputChange, onCompositionEnd: event => {
347
+ }, children: [_jsxs(PencilSurface, { as: "div", className: classes('inkra-field-control', 'inkra-tag-input__control', classNames.control), radius: 8, seed: resolvedSeed, variant: surfaceVariant, children: [_jsxs("ul", { "aria-label": copy.tagList, className: classes('inkra-tag-input__list', classNames.list), onClick: event => {
348
+ const target = tagRemoveTarget(event, currentTags.length);
349
+ if (target)
350
+ removeTag(target.index);
351
+ }, onKeyDown: event => {
352
+ const target = tagRemoveTarget(event, currentTags.length);
353
+ if (target)
354
+ handleTagKeyDown(event, target.index, target.button);
355
+ }, children: [tagRows, currentTags.length > tagDisplayLimit && _jsx("li", { children: _jsx(Button, { type: "button", size: "sm", variant: "ghost", "aria-expanded": expandedTags, onClick: () => setExpandedTags(current => !current), children: expandedTags ? showFewerTagsLabel : showAllTagsLabel(currentTags.length) }) })] }), _jsx("input", { ...inputProps, "aria-describedby": state.describedBy, "aria-errormessage": state.errorMessage, "aria-invalid": state.ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-readonly": state.readOnly || undefined, "aria-required": state.required || undefined, autoComplete: inputProps.autoComplete ?? 'off', className: classes('inkra-input', 'inkra-tag-input__input', inputProps.className, classNames.input), disabled: state.disabled, form: form, id: state.id, onChange: handleInputChange, onCompositionEnd: event => {
318
356
  composingRef.current = false;
319
357
  inputProps.onCompositionEnd?.(event);
320
358
  }, onCompositionStart: event => {
321
359
  composingRef.current = true;
322
360
  inputProps.onCompositionStart?.(event);
323
- }, onKeyDown: handleInputKeyDown, onPaste: handleInputPaste, placeholder: placeholder, readOnly: state.readOnly, ref: inputRef, required: state.required && currentTags.length === 0, spellCheck: inputProps.spellCheck ?? false, type: "text", value: currentDraft }), clearable && currentTags.length > 0 && !state.readOnly ? (_jsx("button", { "aria-label": copy.clearAll, className: classes('inkra-tag-input__clear', classNames.clear), disabled: state.disabled, onClick: clearAll, onPointerDown: (event) => event.preventDefault(), title: copy.clearAll, type: "button", children: _jsx(PencilIcon, { decorative: true, paths: clearPaths, seed: `${resolvedSeed}-clear`, size: 20, sourceSize: 24 }) })) : null, variant === 'underline' ? (_jsx(PencilLine, { className: "inkra-tag-input__underline", seed: `${resolvedSeed}-underline`, weight: (focusWithin ? 1.5 : 1) + (state.invalid ? 0.1 : 0) })) : null] }), name ? currentTags.map((tag, index) => (_jsx("input", { disabled: state.disabled, form: form, name: name, type: "hidden", value: tag.value }, `${tag.id}-${index}`))) : null, activeValidationMessage ? (_jsx(PencilText, { as: "span", className: classes('inkra-tag-input__message', classNames.message), id: messageId, role: "alert", seed: `${resolvedSeed}-message`, children: activeValidationMessage })) : null] }));
361
+ }, onKeyDown: handleInputKeyDown, onPaste: handleInputPaste, placeholder: placeholder, readOnly: state.readOnly, ref: inputRef, required: state.required && currentTags.length === 0, spellCheck: inputProps.spellCheck ?? false, type: "text", value: currentDraft }), clearable && currentTags.length > 0 && !state.readOnly ? (_jsx("button", { "aria-label": copy.clearAll, className: classes('inkra-tag-input__clear', classNames.clear), disabled: state.disabled, onClick: clearAll, onPointerDown: (event) => event.preventDefault(), title: copy.clearAll, type: "button", children: _jsx(PencilIcon, { decorative: true, paths: clearPaths, seed: `${resolvedSeed}-clear`, size: 20, sourceSize: 24 }) })) : null, variant === 'underline' ? (_jsx(PencilLine, { className: "inkra-tag-input__underline", seed: `${resolvedSeed}-underline`, weight: (focusWithin ? 1.5 : 1) + (state.invalid ? 0.1 : 0) })) : null] }), formValues, activeValidationMessage ? (_jsx(PencilText, { as: "span", className: classes('inkra-tag-input__message', classNames.message), id: messageId, role: "alert", seed: `${resolvedSeed}-message`, children: activeValidationMessage })) : null] }));
324
362
  });
325
- export { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, hasTagValue, normalizeTagInputValue, splitTagInputValue, };
363
+ export { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, normalizeTagInputValue, splitTagInputValue, };
364
+ export { hasTagValue } from './model.js';
@@ -0,0 +1,11 @@
1
+ import type { HTMLAttributes } from 'react';
2
+ export type InkraTheme = 'light' | 'dark' | 'auto';
3
+ export type InkraThemeTokens = Partial<Record<`--inkra-${string}`, string | number>>;
4
+ export interface ThemeProviderProps extends HTMLAttributes<HTMLDivElement> {
5
+ /** Omit to inherit the surrounding theme. auto follows the system through CSS. */
6
+ theme?: InkraTheme;
7
+ /** Scoped CSS variables. Lengths must include units, for example "1.5px". */
8
+ tokens?: InkraThemeTokens;
9
+ }
10
+ /** A CSS scope: never modifies document attributes, storage or sibling themes. */
11
+ export declare const ThemeProvider: import("react").ForwardRefExoticComponent<ThemeProviderProps & import("react").RefAttributes<HTMLDivElement>>;