@inkra/react 0.2.0 → 0.4.3

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 (246) hide show
  1. package/README.md +101 -7
  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.d.ts +1 -1
  9. package/dist/alert-dialog/index.js +18 -6
  10. package/dist/audio-player/index.d.ts +2 -0
  11. package/dist/audio-player/index.js +126 -31
  12. package/dist/calendar/index.d.ts +4 -1
  13. package/dist/calendar/index.js +31 -12
  14. package/dist/carousel/index.js +13 -6
  15. package/dist/choice-grid/index.d.ts +6 -1
  16. package/dist/choice-grid/index.js +61 -33
  17. package/dist/color-picker/GradientEditor.d.ts +39 -0
  18. package/dist/color-picker/GradientEditor.js +63 -0
  19. package/dist/color-picker/gradient.d.ts +25 -0
  20. package/dist/color-picker/gradient.js +60 -0
  21. package/dist/color-picker/index.d.ts +27 -7
  22. package/dist/color-picker/index.js +134 -32
  23. package/dist/color-swatch/index.d.ts +10 -0
  24. package/dist/color-swatch/index.js +9 -0
  25. package/dist/command/index.d.ts +24 -2
  26. package/dist/command/index.js +29 -6
  27. package/dist/content/index.d.ts +5 -0
  28. package/dist/content/index.js +12 -3
  29. package/dist/context-menu/index.d.ts +2 -2
  30. package/dist/context-menu/index.js +4 -3
  31. package/dist/data-table/index.d.ts +7 -1
  32. package/dist/data-table/index.js +64 -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 +10 -9
  37. package/dist/day-agenda/index.d.ts +6 -1
  38. package/dist/day-agenda/index.js +46 -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 +5 -5
  44. package/dist/drawer/index.d.ts +1 -1
  45. package/dist/drawer/index.js +9 -6
  46. package/dist/dropdown-menu/index.d.ts +1 -1
  47. package/dist/dropdown-menu/index.js +3 -2
  48. package/dist/editable/index.d.ts +24 -2
  49. package/dist/editable/index.js +142 -37
  50. package/dist/empty/illustrations.d.ts +8 -0
  51. package/dist/empty/illustrations.js +78 -0
  52. package/dist/empty/index.d.ts +6 -1
  53. package/dist/empty/index.js +11 -3
  54. package/dist/flip/index.js +28 -8
  55. package/dist/form/EditablePencilText.js +34 -0
  56. package/dist/form/Field.js +11 -10
  57. package/dist/form/Input.js +1 -1
  58. package/dist/form/Textarea.d.ts +3 -1
  59. package/dist/form/Textarea.js +6 -4
  60. package/dist/hotkey/index.d.ts +1 -0
  61. package/dist/hotkey/index.js +6 -4
  62. package/dist/hover-card/index.js +1 -1
  63. package/dist/image-cropper/index.js +4 -2
  64. package/dist/image-viewer/VideoResource.d.ts +21 -0
  65. package/dist/image-viewer/VideoResource.js +194 -0
  66. package/dist/image-viewer/index.d.ts +14 -0
  67. package/dist/image-viewer/index.js +118 -65
  68. package/dist/image-viewer/lucideGeometry.d.ts +3 -0
  69. package/dist/image-viewer/lucideGeometry.js +19 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +2 -0
  72. package/dist/input-number/index.d.ts +6 -0
  73. package/dist/input-number/index.js +131 -15
  74. package/dist/input-number/model.d.ts +13 -0
  75. package/dist/input-number/model.js +50 -0
  76. package/dist/internal/cache.d.ts +4 -0
  77. package/dist/internal/cache.js +18 -1
  78. package/dist/internal/iconGeometry.d.ts +2 -0
  79. package/dist/internal/iconGeometry.js +32 -0
  80. package/dist/internal/renderTokens.d.ts +4 -0
  81. package/dist/internal/renderTokens.js +4 -0
  82. package/dist/internal/stroke.d.ts +7 -0
  83. package/dist/internal/stroke.js +63 -7
  84. package/dist/internal/surfaceBudget.d.ts +20 -0
  85. package/dist/internal/surfaceBudget.js +69 -0
  86. package/dist/internal/surfaceRenderer.js +87 -28
  87. package/dist/internal/surfaceVisibility.d.ts +3 -0
  88. package/dist/internal/surfaceVisibility.js +60 -0
  89. package/dist/internal/textRenderer.d.ts +2 -0
  90. package/dist/internal/textRenderer.js +3 -3
  91. package/dist/internal/usePencilSurface.js +17 -1
  92. package/dist/internal/wash.js +4 -3
  93. package/dist/journey-map/terrain.js +10 -5
  94. package/dist/layout/index.js +1 -1
  95. package/dist/log/index.js +29 -22
  96. package/dist/markdown/CodeBlock.d.ts +19 -0
  97. package/dist/markdown/CodeBlock.js +86 -0
  98. package/dist/markdown/Deletion.d.ts +2 -0
  99. package/dist/markdown/Deletion.js +69 -0
  100. package/dist/markdown/icons.d.ts +14 -0
  101. package/dist/markdown/icons.js +16 -0
  102. package/dist/markdown/index.d.ts +17 -0
  103. package/dist/markdown/index.js +175 -0
  104. package/dist/node-graph/index.d.ts +2 -0
  105. package/dist/node-graph/index.js +49 -25
  106. package/dist/number-ticker/index.d.ts +11 -9
  107. package/dist/number-ticker/index.js +14 -11
  108. package/dist/number-ticker/model.d.ts +38 -5
  109. package/dist/number-ticker/model.js +139 -10
  110. package/dist/number-ticker/precision.d.ts +7 -0
  111. package/dist/number-ticker/precision.js +141 -0
  112. package/dist/overflow-list/index.js +5 -2
  113. package/dist/overlay/Overlay.d.ts +1 -1
  114. package/dist/overlay/Overlay.js +5 -3
  115. package/dist/overlay/Popover.d.ts +1 -1
  116. package/dist/overlay/Popover.js +6 -5
  117. package/dist/overlay/themeBridge.d.ts +12 -1
  118. package/dist/overlay/themeBridge.js +41 -3
  119. package/dist/pagination/index.d.ts +10 -1
  120. package/dist/pagination/index.js +6 -4
  121. package/dist/pan-zoom/index.d.ts +2 -0
  122. package/dist/pan-zoom/index.js +7 -6
  123. package/dist/pencil.d.ts +1 -0
  124. package/dist/pencil.js +4 -4
  125. package/dist/radar/index.d.ts +4 -0
  126. package/dist/radar/index.js +41 -21
  127. package/dist/rating/index.d.ts +4 -0
  128. package/dist/rating/index.js +128 -24
  129. package/dist/resizable/index.js +1 -1
  130. package/dist/scroll-area/index.d.ts +1 -1
  131. package/dist/scroll-area/index.js +5 -2
  132. package/dist/select/MultipleSelect.js +5 -4
  133. package/dist/select/SearchableSelect.js +61 -6
  134. package/dist/select/Select.d.ts +1 -0
  135. package/dist/select/Select.js +3 -1
  136. package/dist/select/StepperSelect.d.ts +29 -0
  137. package/dist/select/StepperSelect.js +119 -0
  138. package/dist/select/index.d.ts +2 -0
  139. package/dist/select/index.js +1 -0
  140. package/dist/select/stepperModel.d.ts +7 -0
  141. package/dist/select/stepperModel.js +29 -0
  142. package/dist/selection/Checkbox.js +4 -3
  143. package/dist/selection/RadioGroup.js +29 -13
  144. package/dist/selection/Switch.js +4 -3
  145. package/dist/selection/useNativeChecked.d.ts +2 -1
  146. package/dist/selection/useNativeChecked.js +26 -8
  147. package/dist/slider/index.js +50 -6
  148. package/dist/sortable/index.js +15 -6
  149. package/dist/sparkline/index.js +17 -2
  150. package/dist/sparkline/model.js +11 -4
  151. package/dist/styles/accordion.css +228 -0
  152. package/dist/styles/alert-dialog.css +139 -0
  153. package/dist/styles/audio-player.css +22 -0
  154. package/dist/styles/base.css +638 -0
  155. package/dist/styles/breadcrumb.css +7 -0
  156. package/dist/styles/calendar.css +62 -0
  157. package/dist/styles/carousel.css +173 -0
  158. package/dist/styles/choice-grid.css +226 -0
  159. package/dist/styles/color-picker.css +281 -0
  160. package/dist/styles/color-swatch.css +47 -0
  161. package/dist/styles/command.css +52 -0
  162. package/dist/styles/content.css +79 -0
  163. package/dist/styles/context-menu.css +35 -0
  164. package/dist/styles/data-table.css +31 -0
  165. package/dist/styles/date-picker.css +107 -0
  166. package/dist/styles/day-agenda.css +30 -0
  167. package/dist/styles/display.css +304 -0
  168. package/dist/styles/dockable-panel.css +110 -0
  169. package/dist/styles/drawer.css +133 -0
  170. package/dist/styles/dropdown-menu.css +68 -0
  171. package/dist/styles/editable.css +145 -0
  172. package/dist/styles/empty.css +108 -0
  173. package/dist/styles/flip.css +101 -0
  174. package/dist/styles/form.css +634 -0
  175. package/dist/styles/heatmap.css +256 -0
  176. package/dist/styles/hotkey.css +189 -0
  177. package/dist/styles/hover-card.css +27 -0
  178. package/dist/styles/image-cropper.css +205 -0
  179. package/dist/styles/image-viewer.css +422 -0
  180. package/dist/styles/input-number.css +21 -0
  181. package/dist/styles/journey-map.css +19 -0
  182. package/dist/styles/keypad.css +92 -0
  183. package/dist/styles/layout.css +72 -0
  184. package/dist/styles/leaflet.css +661 -0
  185. package/dist/styles/log.css +203 -0
  186. package/dist/styles/markdown.css +379 -0
  187. package/dist/styles/node-graph.css +142 -0
  188. package/dist/styles/number-ticker.css +83 -0
  189. package/dist/styles/overflow-list.css +51 -0
  190. package/dist/styles/overlay.css +284 -0
  191. package/dist/styles/pagination.css +21 -0
  192. package/dist/styles/pan-zoom.css +136 -0
  193. package/dist/styles/radar.css +1 -0
  194. package/dist/styles/rating.css +121 -0
  195. package/dist/styles/resizable.css +108 -0
  196. package/dist/styles/scroll-area.css +122 -0
  197. package/dist/styles/select.css +557 -0
  198. package/dist/styles/selection.css +264 -0
  199. package/dist/styles/slider.css +145 -0
  200. package/dist/styles/slot.css +109 -0
  201. package/dist/styles/sortable.css +159 -0
  202. package/dist/styles/sparkline.css +24 -0
  203. package/dist/styles/steps.css +27 -0
  204. package/dist/styles/tabs.css +79 -0
  205. package/dist/styles/tag-input.css +213 -0
  206. package/dist/styles/time-picker.css +66 -0
  207. package/dist/styles/timeline.css +24 -0
  208. package/dist/styles/toast.css +160 -0
  209. package/dist/styles/toggle-group.css +107 -0
  210. package/dist/styles/tooltip.css +78 -0
  211. package/dist/styles/transfer.css +148 -0
  212. package/dist/styles/tree-select.css +131 -0
  213. package/dist/styles/tree.css +59 -0
  214. package/dist/styles/typewriter.css +62 -0
  215. package/dist/styles/upload.css +60 -0
  216. package/dist/styles/virtual-list.css +112 -0
  217. package/dist/styles.css +2241 -443
  218. package/dist/tabs/index.d.ts +9 -0
  219. package/dist/tabs/index.js +66 -16
  220. package/dist/tabs/useOverflow.d.ts +20 -0
  221. package/dist/tabs/useOverflow.js +145 -0
  222. package/dist/tag-input/index.d.ts +7 -2
  223. package/dist/tag-input/index.js +70 -26
  224. package/dist/time-picker/index.js +38 -20
  225. package/dist/toast/index.d.ts +19 -11
  226. package/dist/toast/index.js +48 -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.d.ts +1 -1
  233. package/dist/tooltip/Tooltip.js +6 -5
  234. package/dist/transfer/index.d.ts +5 -0
  235. package/dist/transfer/index.js +93 -29
  236. package/dist/transfer/model.d.ts +4 -0
  237. package/dist/transfer/model.js +37 -11
  238. package/dist/tree/index.d.ts +5 -1
  239. package/dist/tree/index.js +5 -5
  240. package/dist/tree-select/index.js +67 -30
  241. package/dist/tree-select/model.d.ts +11 -5
  242. package/dist/tree-select/model.js +111 -83
  243. package/dist/typewriter/index.js +10 -10
  244. package/dist/upload/index.js +30 -16
  245. package/dist/virtual-list/index.js +10 -7
  246. package/package.json +416 -5
@@ -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,89 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as Primitive from '@radix-ui/react-tabs';
3
- import { createContext, forwardRef, useContext, useRef, useState } from 'react';
3
+ import { createContext, forwardRef, useCallback, useContext, useMemo, useRef, useState } from 'react';
4
+ import { __iconData as chevronLeft } from 'lucide-react/dist/esm/icons/chevron-left.mjs';
5
+ import { __iconData as chevronRight } from 'lucide-react/dist/esm/icons/chevron-right.mjs';
6
+ import { Button } from '../Button.js';
7
+ import { DropdownMenu } from '../dropdown-menu/index.js';
8
+ import { PencilIcon } from '../PencilIcon.js';
4
9
  import { PencilSurface } from '../PencilSurface.js';
5
10
  import { PencilText } from '../PencilText.js';
6
11
  import { PencilLine } from '../PencilLine.js';
7
12
  import { useSelectionDrawing } from '../internal/useSelectionDrawing.js';
13
+ import { revealTab, useTabsOverflow } from './useOverflow.js';
14
+ const leftPaths = chevronLeft.node.flatMap(([, attributes]) => attributes.d ? [{ d: attributes.d }] : []);
15
+ const rightPaths = chevronRight.node.flatMap(([, attributes]) => attributes.d ? [{ d: attributes.d }] : []);
16
+ function OverflowIcon({ left = false, down = false }) {
17
+ const paths = left ? leftPaths : rightPaths;
18
+ 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)) }) })] });
19
+ }
8
20
  const ValueContext = createContext(undefined);
9
21
  const AnimatedContext = createContext(true);
10
22
  const OrientationContext = createContext('horizontal');
11
23
  const DividedContext = createContext(false);
24
+ const ChangeContext = createContext(() => { });
25
+ const HiddenContext = createContext(new Set());
12
26
  const Root = forwardRef(function TabsRoot({ value, defaultValue, onValueChange, className, size = 'md', animated = true, orientation = 'horizontal', ...props }, ref) {
13
27
  const [internal, setInternal] = useState(defaultValue);
14
28
  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
- } }) }) }) });
29
+ const pendingRequest = useRef(null);
30
+ const change = (next) => {
31
+ // Radix pointer activation can request once from focus and once from pointer down.
32
+ // Deduplicate only that synchronous interaction so a rejected controlled value can retry.
33
+ if (pendingRequest.current === next)
34
+ return;
35
+ pendingRequest.current = next;
36
+ queueMicrotask(() => {
37
+ if (pendingRequest.current === next)
38
+ pendingRequest.current = null;
39
+ });
40
+ if (value === undefined)
41
+ setInternal(next);
42
+ onValueChange?.(next);
43
+ };
44
+ 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
45
  });
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(' ') }) });
46
+ const List = forwardRef(function TabsList({ className, align = 'start', fullWidth = false, divided = false, overflow = 'scroll', moreLabel = 'More tabs', scrollPreviousLabel = 'Scroll to previous tabs', scrollNextLabel = 'Scroll to next tabs', onFocusCapture, ...props }, ref) {
47
+ const current = useContext(ValueContext);
48
+ const change = useContext(ChangeContext);
49
+ const mode = useContext(OrientationContext) === 'horizontal' ? overflow : 'scroll';
50
+ const state = useTabsOverflow(mode, current);
51
+ const { listRef } = state;
52
+ const hidden = useMemo(() => new Set(mode === 'menu' ? state.items.map(item => item.value) : []), [mode, state.items]);
53
+ const selectionPending = useRef(false);
54
+ const setRef = useCallback((node) => {
55
+ listRef.current = node;
56
+ if (typeof ref === 'function')
57
+ ref(node);
58
+ else if (ref)
59
+ ref.current = node;
60
+ }, [listRef, ref]);
61
+ 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 => {
62
+ onFocusCapture?.(event);
63
+ if (!event.defaultPrevented && mode !== 'menu')
64
+ revealTab(listRef.current, event.target.closest('[data-tabs-value]'));
65
+ } });
66
+ const directionIcon = (previous) => _jsx(OverflowIcon, { left: previous !== state.rtl });
67
+ 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 => {
68
+ if (!selectionPending.current)
69
+ return;
70
+ selectionPending.current = false;
71
+ const selected = Array.from(listRef.current?.querySelectorAll('[data-tabs-value]') ?? [])
72
+ .find(node => node.dataset.tabsValue === current && !node.hasAttribute('data-overflow-hidden') && !node.disabled);
73
+ if (selected) {
74
+ event.preventDefault();
75
+ selected.focus({ preventScroll: true });
76
+ }
77
+ }, 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
78
  });
30
- const Trigger = forwardRef(function TabsTrigger({ value, className, children, startIcon, endIcon, ...props }, ref) {
79
+ const Trigger = forwardRef(function TabsTrigger({ value, className, children, startIcon, endIcon, overflowLabel, ...props }, ref) {
31
80
  const current = useContext(ValueContext);
32
81
  const divided = useContext(DividedContext);
33
82
  const orientation = useContext(OrientationContext);
83
+ const hidden = useContext(HiddenContext).has(value);
34
84
  const surface = useRef(null);
35
85
  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 })] });
86
+ 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
87
  });
38
88
  const Content = forwardRef(function TabsContent({ className, value, ...props }, ref) {
39
89
  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,11 +1,12 @@
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 { forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from 'react';
3
3
  import { PencilIcon } from '../PencilIcon.js';
4
+ import { Button } from '../Button.js';
4
5
  import { PencilLine } from '../PencilLine.js';
5
6
  import { PencilSurface } from '../PencilSurface.js';
6
7
  import { PencilText } from '../PencilText.js';
7
8
  import { useResolvedControlProps } from '../form/controlProps.js';
8
- import { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, hasTagValue, normalizeTagInputValue, splitTagInputValue, } from './model.js';
9
+ import { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, normalizeTagInputValue, splitTagInputValue, } from './model.js';
9
10
  const removePaths = [{ d: 'M18 6 6 18' }, { d: 'm6 6 12 12' }];
10
11
  const clearPaths = [{ d: 'M4 7h16' }, { d: 'M9 7V4h6v3' }, { d: 'm7 7 1 13h8l1-13' }];
11
12
  const classes = (...values) => values.filter(Boolean).join(' ');
@@ -38,7 +39,28 @@ function validationText(result, fallback) {
38
39
  return fallback;
39
40
  return undefined;
40
41
  }
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) {
42
+ // Rows contain only their own display/ref inputs. Events bubble to the list,
43
+ // whose current handlers own selection, callbacks, guards and tag positions.
44
+ const TagInputTagRow = memo(function TagInputTagRow({ tag, index, disabled, readOnly, className, seed, removeLabel, removeRefs, }) {
45
+ const removeText = readOnly ? undefined : removeLabel(tag);
46
+ 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 => {
47
+ if (node)
48
+ removeRefs.current.set(tag.id, node);
49
+ else
50
+ removeRefs.current.delete(tag.id);
51
+ }, title: removeText, type: "button", children: _jsx(PencilIcon, { decorative: true, paths: removePaths, seed: `${seed}-remove-${tag.id}`, size: 18, sourceSize: 24 }) }) : null] });
52
+ });
53
+ function tagRemoveTarget(event, length) {
54
+ const target = event.target;
55
+ const button = typeof target.closest === 'function'
56
+ ? target.closest('button.inkra-tag-input__remove[data-inkra-tag-index]')
57
+ : null;
58
+ if (!button || !event.currentTarget.contains(button))
59
+ return undefined;
60
+ const index = Number(button.getAttribute('data-inkra-tag-index'));
61
+ return Number.isInteger(index) && index >= 0 && index < length ? { button, index } : undefined;
62
+ }
63
+ 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, maxVisibleTags, showAllTagsLabel = count => `Show all ${count} tags`, showFewerTagsLabel = 'Show fewer tags', name, onClick, onInputValueChange, onValidationError, onValueChange, placeholder, readOnly, required, seed, size = 'md', validationMessage, validateTag, value, variant = 'outline', ...rootProps }, forwardedRef) {
42
64
  const generatedId = useId();
43
65
  const inputRef = useRef(null);
44
66
  const removeRefs = useRef(new Map());
@@ -50,6 +72,8 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
50
72
  const [localValidationMessage, setLocalValidationMessage] = useState();
51
73
  const [focusWithin, setFocusWithin] = useState(false);
52
74
  const [pendingFocus, setPendingFocus] = useState(null);
75
+ const [expandedTags, setExpandedTags] = useState(false);
76
+ const tagDisplayLimit = maxVisibleTags !== undefined && Number.isFinite(maxVisibleTags) ? Math.max(0, Math.floor(maxVisibleTags)) : Infinity;
53
77
  const tagsControlled = value !== undefined;
54
78
  const draftControlled = inputValue !== undefined;
55
79
  const currentTags = tagsControlled ? value : uncontrolledTags;
@@ -86,12 +110,16 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
86
110
  useEffect(() => {
87
111
  if (!pendingFocus)
88
112
  return;
113
+ if ('tagId' in pendingFocus && !expandedTags && currentTags.findIndex(tag => tag.id === pendingFocus.tagId) >= tagDisplayLimit) {
114
+ setExpandedTags(true);
115
+ return;
116
+ }
89
117
  const target = 'input' in pendingFocus
90
118
  ? inputRef.current
91
119
  : removeRefs.current.get(pendingFocus.tagId) ?? inputRef.current;
92
120
  target?.focus({ preventScroll: true });
93
121
  setPendingFocus(null);
94
- }, [currentTags, pendingFocus]);
122
+ }, [currentTags, pendingFocus, expandedTags, tagDisplayLimit]);
95
123
  useEffect(() => {
96
124
  const input = inputRef.current;
97
125
  const ownerForm = input?.form;
@@ -121,11 +149,11 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
121
149
  setUncontrolledDraft(next);
122
150
  onInputValueChange?.(next, details);
123
151
  };
124
- const commitTags = (next, details) => {
152
+ const commitTags = useCallback((next, details) => {
125
153
  if (!tagsControlled)
126
154
  setUncontrolledTags([...next]);
127
155
  onValueChange?.(next, details);
128
- };
156
+ }, [tagsControlled, onValueChange]);
129
157
  const reportValidation = (candidate, reason, code, message, tags) => {
130
158
  setLocalValidationMessage(message);
131
159
  onValidationError?.(message, { candidate, code, reason, tags });
@@ -134,6 +162,8 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
134
162
  if (!canEdit)
135
163
  return false;
136
164
  const working = [...currentTags];
165
+ const valueKeys = new Set(currentTags.map(tag => getTagValueKey(tag.value)));
166
+ const ids = new Set(currentTags.map(tag => tag.id));
137
167
  const added = [];
138
168
  let rejected;
139
169
  for (const rawCandidate of candidates) {
@@ -144,7 +174,7 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
144
174
  rejected = { candidate, code: 'max-tags', message: copy.maxTags(limit) };
145
175
  break;
146
176
  }
147
- if (hasTagValue(working, candidate, getTagValueKey)) {
177
+ if (valueKeys.has(getTagValueKey(candidate))) {
148
178
  rejected = { candidate, code: 'duplicate', message: copy.duplicate(candidate) };
149
179
  break;
150
180
  }
@@ -159,11 +189,13 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
159
189
  rejected = { candidate, code: 'invalid', message: copy.invalid(candidate) };
160
190
  break;
161
191
  }
162
- if (working.some(current => current.id === tag.id) || hasTagValue(working, tag.value, getTagValueKey)) {
192
+ if (ids.has(tag.id) || valueKeys.has(getTagValueKey(tag.value))) {
163
193
  rejected = { candidate, code: 'duplicate', message: copy.duplicate(candidate) };
164
194
  break;
165
195
  }
166
196
  working.push(tag);
197
+ ids.add(tag.id);
198
+ valueKeys.add(getTagValueKey(tag.value));
167
199
  added.push(tag);
168
200
  }
169
201
  if (added.length > 0)
@@ -183,7 +215,7 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
183
215
  const parsed = splitTagInputValue(currentDraft, delimiters);
184
216
  return commitCandidates(parsed.parts, reason);
185
217
  };
186
- const removeTag = (index, focus = null) => {
218
+ const removeTag = useCallback((index, focus = null) => {
187
219
  if (!canEdit)
188
220
  return;
189
221
  const tag = currentTags[index];
@@ -198,7 +230,7 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
198
230
  setLocalValidationMessage(undefined);
199
231
  commitTags(next, { reason: 'remove', tags: [tag] });
200
232
  setPendingFocus(nextFocus);
201
- };
233
+ }, [canEdit, currentTags, commitTags]);
202
234
  const clearAll = () => {
203
235
  if (!canEdit || currentTags.length === 0)
204
236
  return;
@@ -208,11 +240,15 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
208
240
  updateDraft('', { reason: 'clear' });
209
241
  setPendingFocus({ input: true });
210
242
  };
211
- const focusRemoveButton = (index) => {
243
+ const focusRemoveButton = useCallback((index) => {
212
244
  const tag = currentTags[index];
213
- if (tag)
214
- removeRefs.current.get(tag.id)?.focus({ preventScroll: true });
215
- };
245
+ if (tag) {
246
+ if (!expandedTags && index >= tagDisplayLimit)
247
+ setPendingFocus({ tagId: tag.id });
248
+ else
249
+ removeRefs.current.get(tag.id)?.focus({ preventScroll: true });
250
+ }
251
+ }, [currentTags, expandedTags, tagDisplayLimit]);
216
252
  const handleInputChange = (event) => {
217
253
  inputProps.onChange?.(event);
218
254
  if (event.defaultPrevented)
@@ -265,10 +301,10 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
265
301
  commitDraft('delimiter');
266
302
  }
267
303
  };
268
- const handleTagKeyDown = (event, index) => {
304
+ const handleTagKeyDown = useCallback((event, index, button) => {
269
305
  if (event.defaultPrevented || !canEdit)
270
306
  return;
271
- const direction = getComputedStyle(event.currentTarget).direction;
307
+ const direction = getComputedStyle(button).direction;
272
308
  const previousKey = direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
273
309
  const nextKey = direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
274
310
  if (event.key === previousKey) {
@@ -294,8 +330,12 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
294
330
  event.preventDefault();
295
331
  removeTag(index);
296
332
  }
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 => {
333
+ }, [canEdit, currentTags.length, focusRemoveButton, removeTag]);
334
+ // Stable rows survive immutable edits to other tags. Parent event handlers
335
+ // remain fresh instead of hiding a changing callback behind a memo comparator.
336
+ 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]);
337
+ 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]);
338
+ 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
339
  if (!event.currentTarget.contains(event.relatedTarget))
300
340
  setFocusWithin(false);
301
341
  rootProps.onBlurCapture?.(event);
@@ -309,17 +349,21 @@ export const TagInput = forwardRef(function TagInput({ 'aria-describedby': ariaD
309
349
  }, onFocusCapture: event => {
310
350
  setFocusWithin(true);
311
351
  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 => {
352
+ }, 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 => {
353
+ const target = tagRemoveTarget(event, currentTags.length);
354
+ if (target)
355
+ removeTag(target.index);
356
+ }, onKeyDown: event => {
357
+ const target = tagRemoveTarget(event, currentTags.length);
358
+ if (target)
359
+ handleTagKeyDown(event, target.index, target.button);
360
+ }, 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
361
  composingRef.current = false;
319
362
  inputProps.onCompositionEnd?.(event);
320
363
  }, onCompositionStart: event => {
321
364
  composingRef.current = true;
322
365
  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] }));
366
+ }, 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
367
  });
325
- export { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, hasTagValue, normalizeTagInputValue, splitTagInputValue, };
368
+ export { createDefaultTag, defaultTagInputDelimiters, defaultTagValueKey, normalizeTagInputValue, splitTagInputValue, };
369
+ export { hasTagValue } from './model.js';