@react-x11/components 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 (314) hide show
  1. package/README.md +131 -4
  2. package/dist/charts/components.d.ts +3 -1
  3. package/dist/charts/components.d.ts.map +1 -1
  4. package/dist/charts/components.js +8 -4
  5. package/dist/charts/components.js.map +1 -1
  6. package/dist/charts/node.d.ts +13 -6
  7. package/dist/charts/node.d.ts.map +1 -1
  8. package/dist/charts/node.js +75 -24
  9. package/dist/charts/node.js.map +1 -1
  10. package/dist/charts/render.d.ts +4 -0
  11. package/dist/charts/render.d.ts.map +1 -1
  12. package/dist/charts/render.js +11 -6
  13. package/dist/charts/render.js.map +1 -1
  14. package/dist/code-editor/node.d.ts +47 -16
  15. package/dist/code-editor/node.d.ts.map +1 -1
  16. package/dist/code-editor/node.js +121 -51
  17. package/dist/code-editor/node.js.map +1 -1
  18. package/dist/flow/draw.d.ts +19 -1
  19. package/dist/flow/draw.d.ts.map +1 -1
  20. package/dist/flow/draw.js +64 -35
  21. package/dist/flow/draw.js.map +1 -1
  22. package/dist/flow/index.d.ts.map +1 -1
  23. package/dist/flow/index.js +24 -8
  24. package/dist/flow/index.js.map +1 -1
  25. package/dist/flow/model.d.ts +4 -2
  26. package/dist/flow/model.d.ts.map +1 -1
  27. package/dist/flow/model.js +4 -2
  28. package/dist/flow/model.js.map +1 -1
  29. package/dist/flow/node.d.ts +40 -9
  30. package/dist/flow/node.d.ts.map +1 -1
  31. package/dist/flow/node.js +142 -53
  32. package/dist/flow/node.js.map +1 -1
  33. package/dist/flow/types.d.ts +46 -21
  34. package/dist/flow/types.d.ts.map +1 -1
  35. package/dist/formula/index.d.ts +4 -2
  36. package/dist/formula/index.d.ts.map +1 -1
  37. package/dist/formula/index.js.map +1 -1
  38. package/dist/formula/node.d.ts +5 -1
  39. package/dist/formula/node.d.ts.map +1 -1
  40. package/dist/formula/node.js +19 -3
  41. package/dist/formula/node.js.map +1 -1
  42. package/dist/html/controls.d.ts +4 -1
  43. package/dist/html/controls.d.ts.map +1 -1
  44. package/dist/html/controls.js.map +1 -1
  45. package/dist/html/css/cascade.d.ts +11 -5
  46. package/dist/html/css/cascade.d.ts.map +1 -1
  47. package/dist/html/css/cascade.js +18 -8
  48. package/dist/html/css/cascade.js.map +1 -1
  49. package/dist/html/css/parse.d.ts +15 -10
  50. package/dist/html/css/parse.d.ts.map +1 -1
  51. package/dist/html/css/parse.js +41 -16
  52. package/dist/html/css/parse.js.map +1 -1
  53. package/dist/html/css/style.d.ts +3 -0
  54. package/dist/html/css/style.d.ts.map +1 -1
  55. package/dist/html/css/style.js +3 -1
  56. package/dist/html/css/style.js.map +1 -1
  57. package/dist/html/css/values.d.ts +14 -1
  58. package/dist/html/css/values.d.ts.map +1 -1
  59. package/dist/html/css/values.js +11 -8
  60. package/dist/html/css/values.js.map +1 -1
  61. package/dist/html/index.d.ts.map +1 -1
  62. package/dist/html/index.js +1 -0
  63. package/dist/html/index.js.map +1 -1
  64. package/dist/html/layout/boxes.d.ts +12 -5
  65. package/dist/html/layout/boxes.d.ts.map +1 -1
  66. package/dist/html/layout/boxes.js +7 -4
  67. package/dist/html/layout/boxes.js.map +1 -1
  68. package/dist/html/layout/inline.d.ts.map +1 -1
  69. package/dist/html/layout/inline.js +22 -1
  70. package/dist/html/layout/inline.js.map +1 -1
  71. package/dist/html/node.d.ts +22 -8
  72. package/dist/html/node.d.ts.map +1 -1
  73. package/dist/html/node.js +97 -15
  74. package/dist/html/node.js.map +1 -1
  75. package/dist/html/paint.d.ts +3 -0
  76. package/dist/html/paint.d.ts.map +1 -1
  77. package/dist/html/paint.js +2 -2
  78. package/dist/html/paint.js.map +1 -1
  79. package/dist/index.d.ts +11 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +26 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/maps/clip.d.ts +43 -0
  84. package/dist/maps/clip.d.ts.map +1 -0
  85. package/dist/maps/clip.js +171 -0
  86. package/dist/maps/clip.js.map +1 -0
  87. package/dist/maps/gzip.d.ts +15 -0
  88. package/dist/maps/gzip.d.ts.map +1 -0
  89. package/dist/maps/gzip.js +56 -0
  90. package/dist/maps/gzip.js.map +1 -0
  91. package/dist/maps/index.d.ts +55 -0
  92. package/dist/maps/index.d.ts.map +1 -0
  93. package/dist/maps/index.js +141 -0
  94. package/dist/maps/index.js.map +1 -0
  95. package/dist/maps/labels.d.ts +123 -0
  96. package/dist/maps/labels.d.ts.map +1 -0
  97. package/dist/maps/labels.js +323 -0
  98. package/dist/maps/labels.js.map +1 -0
  99. package/dist/maps/mvt.d.ts +244 -0
  100. package/dist/maps/mvt.d.ts.map +1 -0
  101. package/dist/maps/mvt.js +820 -0
  102. package/dist/maps/mvt.js.map +1 -0
  103. package/dist/maps/node.d.ts +278 -0
  104. package/dist/maps/node.d.ts.map +1 -0
  105. package/dist/maps/node.js +1459 -0
  106. package/dist/maps/node.js.map +1 -0
  107. package/dist/maps/overlay.d.ts +157 -0
  108. package/dist/maps/overlay.d.ts.map +1 -0
  109. package/dist/maps/overlay.js +472 -0
  110. package/dist/maps/overlay.js.map +1 -0
  111. package/dist/maps/paint.d.ts +226 -0
  112. package/dist/maps/paint.d.ts.map +1 -0
  113. package/dist/maps/paint.js +705 -0
  114. package/dist/maps/paint.js.map +1 -0
  115. package/dist/maps/proj.d.ts +281 -0
  116. package/dist/maps/proj.d.ts.map +1 -0
  117. package/dist/maps/proj.js +451 -0
  118. package/dist/maps/proj.js.map +1 -0
  119. package/dist/maps/sources.d.ts +256 -0
  120. package/dist/maps/sources.d.ts.map +1 -0
  121. package/dist/maps/sources.js +210 -0
  122. package/dist/maps/sources.js.map +1 -0
  123. package/dist/maps/style.d.ts +141 -0
  124. package/dist/maps/style.d.ts.map +1 -0
  125. package/dist/maps/style.js +147 -0
  126. package/dist/maps/style.js.map +1 -0
  127. package/dist/maps/styles.d.ts +82 -0
  128. package/dist/maps/styles.d.ts.map +1 -0
  129. package/dist/maps/styles.js +898 -0
  130. package/dist/maps/styles.js.map +1 -0
  131. package/dist/maps/tiles.d.ts +283 -0
  132. package/dist/maps/tiles.d.ts.map +1 -0
  133. package/dist/maps/tiles.js +469 -0
  134. package/dist/maps/tiles.js.map +1 -0
  135. package/dist/maps/types.d.ts +244 -0
  136. package/dist/maps/types.d.ts.map +1 -0
  137. package/dist/maps/types.js +2 -0
  138. package/dist/maps/types.js.map +1 -0
  139. package/dist/qml/containers.d.ts +3 -0
  140. package/dist/qml/containers.d.ts.map +1 -0
  141. package/dist/qml/containers.js +331 -0
  142. package/dist/qml/containers.js.map +1 -0
  143. package/dist/qml/controls.d.ts +12 -0
  144. package/dist/qml/controls.d.ts.map +1 -0
  145. package/dist/qml/controls.js +21 -0
  146. package/dist/qml/controls.js.map +1 -0
  147. package/dist/qml/globals.d.ts +6 -0
  148. package/dist/qml/globals.d.ts.map +1 -0
  149. package/dist/qml/globals.js +25 -0
  150. package/dist/qml/globals.js.map +1 -0
  151. package/dist/qml/index.d.ts +15 -0
  152. package/dist/qml/index.d.ts.map +1 -0
  153. package/dist/qml/index.js +25 -0
  154. package/dist/qml/index.js.map +1 -0
  155. package/dist/qml/interaction.d.ts +12 -0
  156. package/dist/qml/interaction.d.ts.map +1 -0
  157. package/dist/qml/interaction.js +246 -0
  158. package/dist/qml/interaction.js.map +1 -0
  159. package/dist/qml/ir.d.ts +106 -0
  160. package/dist/qml/ir.d.ts.map +1 -0
  161. package/dist/qml/ir.js +7 -0
  162. package/dist/qml/ir.js.map +1 -0
  163. package/dist/qml/layouts.d.ts +3 -0
  164. package/dist/qml/layouts.d.ts.map +1 -0
  165. package/dist/qml/layouts.js +122 -0
  166. package/dist/qml/layouts.js.map +1 -0
  167. package/dist/qml/models.d.ts +14 -0
  168. package/dist/qml/models.d.ts.map +1 -0
  169. package/dist/qml/models.js +80 -0
  170. package/dist/qml/models.js.map +1 -0
  171. package/dist/qml/objects.d.ts +228 -0
  172. package/dist/qml/objects.d.ts.map +1 -0
  173. package/dist/qml/objects.js +1103 -0
  174. package/dist/qml/objects.js.map +1 -0
  175. package/dist/qml/parse.d.ts +10 -0
  176. package/dist/qml/parse.d.ts.map +1 -0
  177. package/dist/qml/parse.js +607 -0
  178. package/dist/qml/parse.js.map +1 -0
  179. package/dist/qml/qtquick.d.ts +7 -0
  180. package/dist/qml/qtquick.d.ts.map +1 -0
  181. package/dist/qml/qtquick.js +347 -0
  182. package/dist/qml/qtquick.js.map +1 -0
  183. package/dist/qml/react.d.ts +83 -0
  184. package/dist/qml/react.d.ts.map +1 -0
  185. package/dist/qml/react.js +323 -0
  186. package/dist/qml/react.js.map +1 -0
  187. package/dist/qml/resolver.d.ts +38 -0
  188. package/dist/qml/resolver.d.ts.map +1 -0
  189. package/dist/qml/resolver.js +64 -0
  190. package/dist/qml/resolver.js.map +1 -0
  191. package/dist/qml/slots.d.ts +65 -0
  192. package/dist/qml/slots.d.ts.map +1 -0
  193. package/dist/qml/slots.js +230 -0
  194. package/dist/qml/slots.js.map +1 -0
  195. package/dist/qml/states.d.ts +5 -0
  196. package/dist/qml/states.d.ts.map +1 -0
  197. package/dist/qml/states.js +276 -0
  198. package/dist/qml/states.js.map +1 -0
  199. package/dist/qml/view-utils.d.ts +42 -0
  200. package/dist/qml/view-utils.d.ts.map +1 -0
  201. package/dist/qml/view-utils.js +121 -0
  202. package/dist/qml/view-utils.js.map +1 -0
  203. package/dist/reorder/hx.d.ts +18 -0
  204. package/dist/reorder/hx.d.ts.map +1 -0
  205. package/dist/reorder/hx.js +10 -0
  206. package/dist/reorder/hx.js.map +1 -0
  207. package/dist/reorder/index.d.ts +314 -0
  208. package/dist/reorder/index.d.ts.map +1 -0
  209. package/dist/reorder/index.js +1539 -0
  210. package/dist/reorder/index.js.map +1 -0
  211. package/dist/reorder/model.d.ts +107 -0
  212. package/dist/reorder/model.d.ts.map +1 -0
  213. package/dist/reorder/model.js +162 -0
  214. package/dist/reorder/model.js.map +1 -0
  215. package/dist/richtext/node.d.ts +15 -5
  216. package/dist/richtext/node.d.ts.map +1 -1
  217. package/dist/richtext/node.js +40 -11
  218. package/dist/richtext/node.js.map +1 -1
  219. package/dist/richtext/runs.d.ts +35 -13
  220. package/dist/richtext/runs.d.ts.map +1 -1
  221. package/dist/richtext/runs.js +83 -27
  222. package/dist/richtext/runs.js.map +1 -1
  223. package/dist/tabs/hx.d.ts +18 -0
  224. package/dist/tabs/hx.d.ts.map +1 -0
  225. package/dist/tabs/hx.js +10 -0
  226. package/dist/tabs/hx.js.map +1 -0
  227. package/dist/tabs/index.d.ts +160 -0
  228. package/dist/tabs/index.d.ts.map +1 -0
  229. package/dist/tabs/index.js +1265 -0
  230. package/dist/tabs/index.js.map +1 -0
  231. package/dist/terminal/vt/fonts.d.ts +45 -15
  232. package/dist/terminal/vt/fonts.d.ts.map +1 -1
  233. package/dist/terminal/vt/fonts.js +89 -19
  234. package/dist/terminal/vt/fonts.js.map +1 -1
  235. package/dist/terminal/vt/index.d.ts +1 -1
  236. package/dist/terminal/vt/index.d.ts.map +1 -1
  237. package/dist/terminal/vt/index.js +3 -3
  238. package/dist/terminal/vt/index.js.map +1 -1
  239. package/dist/terminal/vt/node.d.ts +42 -0
  240. package/dist/terminal/vt/node.d.ts.map +1 -1
  241. package/dist/terminal/vt/node.js +95 -11
  242. package/dist/terminal/vt/node.js.map +1 -1
  243. package/dist/terminal/vt/pty.d.ts +16 -0
  244. package/dist/terminal/vt/pty.d.ts.map +1 -1
  245. package/dist/terminal/vt/pty.js +186 -0
  246. package/dist/terminal/vt/pty.js.map +1 -1
  247. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  248. package/dist/terminal/vt/renderer.js +13 -3
  249. package/dist/terminal/vt/renderer.js.map +1 -1
  250. package/package.json +23 -3
  251. package/src/charts/components.ts +14 -7
  252. package/src/charts/node.ts +81 -26
  253. package/src/charts/render.ts +16 -6
  254. package/src/code-editor/node.ts +147 -53
  255. package/src/flow/draw.ts +104 -35
  256. package/src/flow/index.ts +33 -13
  257. package/src/flow/model.ts +4 -2
  258. package/src/flow/node.ts +158 -59
  259. package/src/flow/types.ts +46 -21
  260. package/src/formula/index.ts +4 -2
  261. package/src/formula/node.ts +24 -5
  262. package/src/html/controls.ts +4 -1
  263. package/src/html/css/cascade.ts +18 -7
  264. package/src/html/css/parse.ts +46 -21
  265. package/src/html/css/style.ts +6 -1
  266. package/src/html/css/values.ts +25 -9
  267. package/src/html/index.ts +1 -0
  268. package/src/html/layout/boxes.ts +19 -9
  269. package/src/html/layout/inline.ts +23 -1
  270. package/src/html/node.ts +110 -17
  271. package/src/html/paint.ts +17 -2
  272. package/src/index.ts +142 -0
  273. package/src/maps/clip.ts +184 -0
  274. package/src/maps/gzip.ts +66 -0
  275. package/src/maps/index.ts +316 -0
  276. package/src/maps/labels.ts +442 -0
  277. package/src/maps/mvt.ts +857 -0
  278. package/src/maps/node.ts +1819 -0
  279. package/src/maps/overlay.ts +639 -0
  280. package/src/maps/paint.ts +978 -0
  281. package/src/maps/proj.ts +641 -0
  282. package/src/maps/sources.ts +425 -0
  283. package/src/maps/style.ts +309 -0
  284. package/src/maps/styles.ts +1040 -0
  285. package/src/maps/tiles.ts +676 -0
  286. package/src/maps/types.ts +260 -0
  287. package/src/qml/containers.tsx +380 -0
  288. package/src/qml/controls.tsx +47 -0
  289. package/src/qml/globals.ts +33 -0
  290. package/src/qml/index.ts +67 -0
  291. package/src/qml/interaction.tsx +317 -0
  292. package/src/qml/ir.ts +80 -0
  293. package/src/qml/layouts.tsx +141 -0
  294. package/src/qml/models.ts +101 -0
  295. package/src/qml/objects.ts +1431 -0
  296. package/src/qml/parse.ts +624 -0
  297. package/src/qml/qtquick.tsx +413 -0
  298. package/src/qml/react.tsx +454 -0
  299. package/src/qml/resolver.ts +99 -0
  300. package/src/qml/slots.ts +249 -0
  301. package/src/qml/states.ts +318 -0
  302. package/src/qml/view-utils.ts +140 -0
  303. package/src/reorder/hx.ts +31 -0
  304. package/src/reorder/index.ts +2223 -0
  305. package/src/reorder/model.ts +232 -0
  306. package/src/richtext/node.ts +49 -14
  307. package/src/richtext/runs.ts +115 -30
  308. package/src/tabs/hx.ts +31 -0
  309. package/src/tabs/index.ts +1686 -0
  310. package/src/terminal/vt/fonts.ts +128 -36
  311. package/src/terminal/vt/index.ts +4 -2
  312. package/src/terminal/vt/node.ts +105 -12
  313. package/src/terminal/vt/pty.ts +243 -0
  314. package/src/terminal/vt/renderer.ts +13 -3
@@ -0,0 +1,1686 @@
1
+ // <Tabs> — one visible panel at a time, switched by a strip of triggers.
2
+ //
3
+ // The API is [Chakra UI's Tabs](https://chakra-ui.com/docs/components/tabs)
4
+ // with its parts spelled flat — `Tabs.Root` is `<Tabs>`, `Tabs.Trigger` is
5
+ // `<TabsTrigger>` — because that is how this package names a composition (see
6
+ // `/timeline` and `/charts`). The vocabulary is Chakra's too: `value` /
7
+ // `defaultValue` / `onValueChange({ value })`, the five variants (`line`,
8
+ // `subtle`, `enclosed`, `outline`, `plain`), `size`, `orientation`,
9
+ // `activationMode`, `fitted`, `justify`, `lazyMount` and `unmountOnExit` — so
10
+ // a snippet copied from Chakra's docs is the same tree with the dots removed.
11
+ // `asChild` is the one part of the surface deliberately absent: it exists to
12
+ // merge props into somebody else's DOM element, and there is no DOM here.
13
+ //
14
+ // `overflow` is the one prop with no counterpart there. A horizontal strip
15
+ // narrower than its tabs keeps the ones that fit and drops the rest into a
16
+ // menu at its end rather than running off its own edge — on by default,
17
+ // because a strip cut off mid-label is nobody's intention. It is the one
18
+ // thing here that has to measure, and `useOverflow` below is where that and
19
+ // its consequences live.
20
+ //
21
+ // The behaviour is core's `<Tabs>` — the widget this one supersedes: a single
22
+ // tab stop with roving focus, arrows that wrap and skip disabled triggers,
23
+ // Home/End, visual arrows in an RTL strip, and `activationMode="manual"` for
24
+ // a panel too expensive to build on every keystroke.
25
+ //
26
+ // It is pure composition of `<box>` and `<text>`, so there is no
27
+ // `registerElement` here and nothing for the reconciler to learn. The line
28
+ // under a `line` strip, the marker on the selected trigger, the chip behind
29
+ // an `enclosed` strip — each is a `<box>`, positioned absolutely where it has
30
+ // to sit on one edge, because borders alone cannot say "this side only" and a
31
+ // box can.
32
+ import React from 'react';
33
+ import type { ReactElement, ReactNode } from 'react';
34
+ import { Icon, useDirection, useTheme } from 'react-x11';
35
+ import type { DrawnNode, KeyboardEvent } from 'react-x11';
36
+ import { interpolate } from 'react-x11/style';
37
+ import type { Style, StyleProperties } from 'react-x11/style';
38
+ import {
39
+ XK_DOWN,
40
+ XK_END,
41
+ XK_ESCAPE,
42
+ XK_HOME,
43
+ XK_LEFT,
44
+ XK_RETURN,
45
+ XK_RIGHT,
46
+ XK_UP,
47
+ } from 'react-x11/keysyms';
48
+
49
+ import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
50
+ import { useDismissOnWindowBlur } from '../internal/widget.js';
51
+ import { hx } from './hx.js';
52
+
53
+ const h = React.createElement;
54
+
55
+ /** What `style` props here accept, matching the rest of the package. */
56
+ type StyleInput = Style | Style[];
57
+
58
+ /**
59
+ * `'@supports transparency'` is a real style block — react-x11 answers it
60
+ * from whether the display gave the window an ARGB visual — but its `Style`
61
+ * type models only the `@width`/`@height` size queries and the `:state`
62
+ * blocks. Spelled out here rather than by augmenting `StyleBlocks` globally,
63
+ * for the reason `<DatePicker>` gives where it does the same: this package
64
+ * should not quietly widen what type-checks in an app that merely installs
65
+ * it.
66
+ */
67
+ type SupportsStyle = Style & {
68
+ '@supports transparency'?: StyleProperties;
69
+ };
70
+
71
+ // --- geometry ---------------------------------------------------------------
72
+
73
+ /** The selected trigger's marker in the `line` variant. */
74
+ const INDICATOR = 2;
75
+ /** The strip's own line in the `line` and `outline` variants. */
76
+ const RULE = 1;
77
+ /** Inside a trigger, between a glyph and the label beside it. */
78
+ const TRIGGER_GAP = 6;
79
+ /** Between a strip and its panel. */
80
+ const PANEL_GAP = 8;
81
+ /** The `enclosed` strip's chip, and an `outline` tab's shoulders — one
82
+ * radius, so the two variants agree about how round this notebook is. */
83
+ const CORNER = 6;
84
+
85
+ /** The rounding on everything that is a wash rather than a tab: the `subtle`
86
+ * fill, the `line` strip's hover, an `enclosed` trigger, a menu row. */
87
+ const WASH_CORNER = 4;
88
+ /** The gap the overflow menu leaves between two rows. Read twice: once to
89
+ * lay the sheet out, and once to work out how tall it comes to. */
90
+ const MENU_ROW_GAP = 1;
91
+ /** The overflow menu's sheet: a hairline where it meets the desktop, as the
92
+ * other popups out here use, and the inset that makes a row read as a pill
93
+ * on the sheet rather than a band across it. */
94
+ const SHEET_BORDER = 1;
95
+ const SHEET_PAD = 4;
96
+ /** A menu row is narrower than a tab is tall — it is a list entry, not a
97
+ * target on a strip — so it takes its own padding rather than the size's. */
98
+ const MENU_ROW_PX = 10;
99
+ const MENU_ROW_PY = 7;
100
+ /** So a menu of one short label is still a menu rather than a chip. */
101
+ const MENU_MIN_WIDTH = 140;
102
+
103
+ export type TabsVariant = 'line' | 'subtle' | 'enclosed' | 'outline' | 'plain';
104
+ export type TabsSize = 'sm' | 'md' | 'lg';
105
+ export type TabsOverflow = 'menu' | 'clip';
106
+
107
+ /**
108
+ * Between two triggers on the strip — none in `outline`, where the tabs meet
109
+ * shoulder to shoulder. Read twice: once to lay the strip out, and once by
110
+ * the overflow arithmetic, which has to add up to the same number the strip
111
+ * did or it will fit one tab too many.
112
+ */
113
+ function stripGap(variant: TabsVariant): number {
114
+ return variant === 'outline' ? 0 : 2;
115
+ }
116
+
117
+ interface SizeSpec {
118
+ /** Horizontal padding inside a trigger. */
119
+ px: number;
120
+ /** Vertical padding inside a trigger. */
121
+ py: number;
122
+ /** How far the label sits off the theme's body size. */
123
+ step: number;
124
+ }
125
+
126
+ // Chakra's proportions, and looser than a first guess would make them: the
127
+ // labels are cap-trimmed, so `py` is the *whole* visible gap over the caps
128
+ // and under the baseline — padding sized for an untrimmed line box reads as
129
+ // cramped once the box is the letters. The type steps off the theme's own
130
+ // `fontSize` rather than a fixed scale, so a theme that sets 16 gets tabs in
131
+ // 16 (the same call `/timeline` makes).
132
+ const SIZES: Record<TabsSize, SizeSpec> = {
133
+ sm: { px: 12, py: 8, step: -1 },
134
+ md: { px: 16, py: 10, step: 0 },
135
+ lg: { px: 20, py: 12, step: 1 },
136
+ };
137
+
138
+ // --- shared state -----------------------------------------------------------
139
+
140
+ /** One trigger, as the keyboard sees it. */
141
+ interface TriggerEntry {
142
+ node: DrawnNode;
143
+ disabled: boolean;
144
+ }
145
+
146
+ /** What the parts of one `<Tabs>` share. */
147
+ interface TabsLook {
148
+ variant: TabsVariant;
149
+ vertical: boolean;
150
+ manual: boolean;
151
+ fitted: boolean;
152
+ justify: 'start' | 'center' | 'end' | undefined;
153
+ /** What a horizontal strip does with more tabs than it has room for. */
154
+ overflow: TabsOverflow;
155
+ /** The marker colour, resolved: `interpolate` mixes real colours, not
156
+ * `$token`s. */
157
+ accent: string;
158
+ /** The colour behind the tabs — what an `enclosed` or `outline` selected
159
+ * trigger is filled with, also resolved. */
160
+ ground: string;
161
+ /** The body ink, resolved, for the washes mixed from it. */
162
+ text: string;
163
+ font: number;
164
+ px: number;
165
+ py: number;
166
+ selected: string | undefined;
167
+ focused: string | null;
168
+ select(value: string): void;
169
+ setFocused(value: string | null): void;
170
+ /** The live trigger registry the keyboard walks. Order is irrelevant —
171
+ * the walk sorts by where layout actually put each trigger. */
172
+ triggers: Map<string, TriggerEntry>;
173
+ everSelected(value: string): boolean;
174
+ lazyMount: boolean;
175
+ unmountOnExit: boolean;
176
+ }
177
+
178
+ const LookContext = React.createContext<TabsLook | null>(null);
179
+
180
+ /**
181
+ * What the strip tells the triggers inside it — the two things it knows and
182
+ * the shared look does not.
183
+ *
184
+ * `indicator` is the `<TabsIndicator>` written into the strip, which the
185
+ * *selected trigger* is what draws; `null` when there is none.
186
+ *
187
+ * `grow` is whether a `fitted` strip is currently letting its triggers fill
188
+ * it. It stops while anything is in the overflow menu, and that is not a
189
+ * cosmetic call: a grown trigger lays out at its share of the strip rather
190
+ * than at its label, and the share is what the overflow arithmetic would
191
+ * then measure — hide one tab, the rest grow into the space it left, and the
192
+ * next pass reads them as too wide and hides another.
193
+ */
194
+ interface TabsStrip {
195
+ indicator: TabsIndicatorProps | null;
196
+ grow: boolean;
197
+ }
198
+
199
+ const StripContext = React.createContext<TabsStrip | null>(null);
200
+
201
+ function useLook(part: string): TabsLook {
202
+ const look = React.useContext(LookContext);
203
+ if (!look) {
204
+ throw new Error(
205
+ `@react-x11/components: <${part}> has to be inside a <Tabs>. ` +
206
+ 'It reads the selected value, the variant and the size from it.',
207
+ );
208
+ }
209
+ return look;
210
+ }
211
+
212
+ /**
213
+ * A colour prop, resolved against the palette — `$token` is normally the
214
+ * renderer's job, but `interpolate()` is arithmetic over real colours and
215
+ * runs here, before any of that. Same helper as `/timeline`'s.
216
+ */
217
+ function resolveColor(
218
+ value: string | undefined,
219
+ theme: Record<string, unknown>,
220
+ fallback: string,
221
+ ): string {
222
+ if (!value) return fallback;
223
+ if (!value.startsWith('$')) return value;
224
+ const named = theme[value.slice(1)];
225
+ return typeof named === 'string' ? named : fallback;
226
+ }
227
+
228
+ /**
229
+ * `accent` laid over `ground`, opaquely — a weak wash at low `amount`.
230
+ * Opaque rather than translucent for the same reason `/timeline` mixes: a
231
+ * wash sits over the strip's own chrome, and light through it would show the
232
+ * rule running behind a selected trigger.
233
+ */
234
+ function over(ground: string, accent: string, amount: number): string {
235
+ const mixed = interpolate(ground, accent, amount);
236
+ return typeof mixed === 'string' ? mixed : accent;
237
+ }
238
+
239
+ /**
240
+ * A single-line label's box trimmed to its letters — core's `capTrim`, the
241
+ * rule every core widget label follows (`labelContent` in
242
+ * `components/theme.js`). A line box is the font's ascent plus descent plus
243
+ * line gap, and the space over a capital differs from the space under the
244
+ * baseline by `(ascent - capHeight) - descent` — so a centred untrimmed
245
+ * label sits visibly low beside the icon centred on its own middle. Trimming
246
+ * makes the box *be* the letters, and centring centres what can be seen.
247
+ * Labels only: the icon is a `<canvas>` and panel prose is a paragraph,
248
+ * and neither wants it.
249
+ */
250
+ const CAP_TRIM: Style = { textBoxTrim: 'cap-alphabetic' };
251
+
252
+ /**
253
+ * Strings and numbers are only legal inside `<text>`, and a trigger's label
254
+ * is prose — `<TabsTrigger value="a">Members</TabsTrigger>` is the shortest
255
+ * thing that works and has to keep working. A primitive child is wrapped; an
256
+ * element child is left exactly as written, and inherits the same ink.
257
+ */
258
+ function withText(children: ReactNode, style?: Style): ReactNode {
259
+ let wrapped = false;
260
+ const mapped = React.Children.map(children, (child) => {
261
+ if (typeof child !== 'string' && typeof child !== 'number') return child;
262
+ wrapped = true;
263
+ return hx('text', style ? { style } : null, child);
264
+ });
265
+ return wrapped ? mapped : children;
266
+ }
267
+
268
+ /**
269
+ * The part's own style, then the caller's — later entries win, which is how
270
+ * every `style` prop in this renderer resolves precedence.
271
+ */
272
+ function styled(base: Style, extra: StyleInput | undefined): Style[] {
273
+ if (!extra) return [base];
274
+ return Array.isArray(extra) ? [base, ...extra] : [base, extra];
275
+ }
276
+
277
+ /**
278
+ * The hover fill on a `line` strip — the wash a `subtle` trigger wears, on
279
+ * the variant whose selected mark is a rule rather than a fill.
280
+ *
281
+ * Two things about it are deliberate and neither is obvious.
282
+ *
283
+ * **It is the size a `subtle` trigger's fill is** — the label with the same
284
+ * padding round it — and it keeps that same distance again off the panel
285
+ * edge, which is what {@link railGap} adds to a `line` trigger's padding to
286
+ * make room for. The gap is not decoration: the strip's rule runs *under*
287
+ * its triggers, so a fill over the whole box would take a bite out of that
288
+ * line for exactly the width of whatever the pointer was on, and one that
289
+ * merely cleared the rule would read as a block resting on it. Which is also
290
+ * why it is a box and not a `backgroundColor` — a background fills the box
291
+ * it is on, and this must not.
292
+ *
293
+ * And the hover that raises it is **state**, not a `:hover` block. `:hover`
294
+ * lights the node under the pointer and its ancestors (react-x11's
295
+ * `_updateHover`), and this box is the label's *sibling* — so a wash that
296
+ * styled itself would go out every time the pointer crossed a letter.
297
+ */
298
+ function hoverWash(look: TabsLook): ReactElement {
299
+ const gap = railGap(look);
300
+ return hx('box', {
301
+ key: 'wash',
302
+ style: [
303
+ {
304
+ position: 'absolute',
305
+ top: 0,
306
+ start: 0,
307
+ borderRadius: WASH_CORNER,
308
+ backgroundColor: '$surfaceHover',
309
+ },
310
+ look.vertical ? { bottom: 0, end: gap } : { bottom: gap, end: 0 },
311
+ ],
312
+ });
313
+ }
314
+
315
+ /**
316
+ * The air a `line` trigger keeps between its wash and the strip's rule, and
317
+ * therefore the padding it carries beyond the label's own on that one side.
318
+ * It is the label's padding on the axis the rule crosses, so that the wash's
319
+ * two distances — text to its edge, its edge to the line — come out the same.
320
+ *
321
+ * `0` for every other variant: they have no wash to stand off the rule, and
322
+ * an `outline` tab's fill is *supposed* to reach it.
323
+ */
324
+ function railGap(look: TabsLook): number {
325
+ if (look.variant !== 'line') return 0;
326
+ return look.vertical ? look.px : look.py;
327
+ }
328
+
329
+ /** What a stop on the strip is, as far as its looks are concerned. */
330
+ interface TabState {
331
+ selected: boolean;
332
+ disabled: boolean;
333
+ hovered: boolean;
334
+ /** A `fitted` strip is letting its stops fill it. */
335
+ grow: boolean;
336
+ }
337
+
338
+ /** A stop's own style, and the boxes drawn around its label. */
339
+ interface TabChrome {
340
+ /** The box's style, under whatever the caller merges over it. */
341
+ style: Style;
342
+ /** Written **before** the label, so a descender that dips below the
343
+ * baseline paints over them rather than being cut. */
344
+ behind: ReactNode;
345
+ /** Written after it: the `line` marker rides the panel edge, over the
346
+ * strip's rule. */
347
+ after: ReactNode;
348
+ }
349
+
350
+ /**
351
+ * Everything a stop on the strip looks like, per variant.
352
+ *
353
+ * Shared because the strip has two kinds of stop and they have to be
354
+ * indistinguishable: a `<TabsTrigger>`, and the overflow button, which wears
355
+ * the selected look whenever the tab that is selected is one of the ones it
356
+ * is holding. Two copies of this switch would drift on the first variant
357
+ * anybody touched.
358
+ *
359
+ * Each selected state is opaque where it has to cover the strip's rule, and
360
+ * every ink change is a `$token` so a `theme` prop above still wins.
361
+ */
362
+ function tabChrome(
363
+ look: TabsLook,
364
+ indicator: TabsIndicatorProps | null,
365
+ state: TabState,
366
+ ): TabChrome {
367
+ const { selected, disabled, hovered, grow } = state;
368
+ const vertical = look.vertical;
369
+ const variantStyle: Style[] = [];
370
+ const hover: Style = {};
371
+ switch (look.variant) {
372
+ case 'line':
373
+ case 'plain':
374
+ Object.assign(hover, { ':hover': { color: '$text' } });
375
+ break;
376
+ case 'subtle':
377
+ variantStyle.push({ borderRadius: WASH_CORNER });
378
+ if (selected)
379
+ variantStyle.push({
380
+ backgroundColor: over(look.ground, look.accent, 0.15),
381
+ });
382
+ else
383
+ Object.assign(hover, {
384
+ ':hover': { backgroundColor: '$surfaceHover' },
385
+ ':active': { backgroundColor: '$surfaceActive' },
386
+ });
387
+ break;
388
+ case 'enclosed':
389
+ // Every trigger carries the border so selecting one cannot change its
390
+ // size — only the selected one inks it.
391
+ variantStyle.push({
392
+ borderRadius: WASH_CORNER,
393
+ borderWidth: 1,
394
+ borderColor: 'transparent',
395
+ });
396
+ if (selected)
397
+ variantStyle.push({
398
+ backgroundColor: look.ground,
399
+ borderColor: '$border',
400
+ });
401
+ else Object.assign(hover, { ':hover': { color: '$text' } });
402
+ break;
403
+ case 'outline':
404
+ if (selected)
405
+ variantStyle.push({
406
+ // The fill is the ground, so the tab and its panel read as one
407
+ // surface — and it is what covers the strip's rule underneath.
408
+ // Rounded so the fill stays inside the frame's shoulders, but
409
+ // deliberately **borderless**: this renderer paints a node's
410
+ // border *after* its children (`Node.paint` — children, then
411
+ // `_paintBorder`), so a border here could never be opened by a
412
+ // child laid over it. The frame below carries the stroke instead.
413
+ backgroundColor: look.ground,
414
+ borderRadius: CORNER,
415
+ });
416
+ else
417
+ Object.assign(hover, {
418
+ ':hover': { backgroundColor: '$surfaceHover' },
419
+ });
420
+ break;
421
+ }
422
+
423
+ // The `line` strip's hover — see {@link hoverWash} for why it is a box and
424
+ // why `hovered` is state rather than a `:hover` block.
425
+ const wash =
426
+ look.variant === 'line' && hovered && !disabled ? hoverWash(look) : null;
427
+
428
+ // The `outline` tab's shape, from two stacked children — a shape the style
429
+ // vocabulary cannot say in one box. `borderRadius` is one number and
430
+ // requires a uniform border, so "rounded shoulders, open bottom" is drawn
431
+ // as: a *frame* (inset-0 box carrying the full rounded border) and, after
432
+ // it, a *skirt* (a strip of ground over the frame's panel edge, covering
433
+ // the border and the two corners that would curl toward the panel, and
434
+ // redrawing the straight side walls over itself). The skirt can cover the
435
+ // frame only because they are siblings — a node's own border paints over
436
+ // its children, which is why the frame is not the trigger's border.
437
+ const outlineShape =
438
+ look.variant === 'outline' && selected
439
+ ? [
440
+ hx('box', {
441
+ key: 'frame',
442
+ style: {
443
+ position: 'absolute',
444
+ top: 0,
445
+ bottom: 0,
446
+ start: 0,
447
+ end: 0,
448
+ borderWidth: 1,
449
+ borderColor: '$border',
450
+ borderRadius: CORNER,
451
+ },
452
+ }),
453
+ hx('box', {
454
+ key: 'skirt',
455
+ style: [
456
+ {
457
+ position: 'absolute',
458
+ backgroundColor: look.ground,
459
+ borderColor: '$border',
460
+ },
461
+ vertical
462
+ ? {
463
+ top: 0,
464
+ bottom: 0,
465
+ end: 0,
466
+ width: CORNER + 1,
467
+ borderTopWidth: 1,
468
+ borderBottomWidth: 1,
469
+ }
470
+ : {
471
+ start: 0,
472
+ end: 0,
473
+ bottom: 0,
474
+ height: CORNER + 1,
475
+ borderStartWidth: 1,
476
+ borderEndWidth: 1,
477
+ },
478
+ ],
479
+ }),
480
+ ]
481
+ : null;
482
+
483
+ // The selected marker in the `line` variant: a 2px box riding the panel
484
+ // edge of the stop, over the strip's 1px rule.
485
+ const marker =
486
+ look.variant === 'line'
487
+ ? hx('box', {
488
+ style: [
489
+ {
490
+ position: 'absolute',
491
+ backgroundColor: selected ? look.accent : 'transparent',
492
+ transition: { backgroundColor: 100 },
493
+ },
494
+ vertical
495
+ ? { top: 0, bottom: 0, end: 0, width: INDICATOR }
496
+ : { left: 0, right: 0, bottom: 0, height: INDICATOR },
497
+ ],
498
+ })
499
+ : null;
500
+
501
+ // A `<TabsIndicator>` in the strip is drawn here, by the selected stop,
502
+ // behind its label. Layout owns its geometry — see the part's own comment.
503
+ const chip =
504
+ indicator && selected
505
+ ? hx('box', {
506
+ key: 'chip',
507
+ style: styled(
508
+ {
509
+ position: 'absolute',
510
+ top: 0,
511
+ bottom: 0,
512
+ left: 0,
513
+ right: 0,
514
+ zIndex: -1,
515
+ borderRadius: WASH_CORNER,
516
+ backgroundColor: over(look.ground, look.accent, 0.15),
517
+ },
518
+ indicator.style,
519
+ ),
520
+ })
521
+ : null;
522
+
523
+ const ink = disabled
524
+ ? '$textMuted'
525
+ : selected
526
+ ? look.variant === 'line' || look.variant === 'subtle'
527
+ ? look.accent
528
+ : '$text'
529
+ : '$textMuted';
530
+
531
+ return {
532
+ style: {
533
+ flexDirection: 'row',
534
+ alignItems: 'center',
535
+ justifyContent: 'center',
536
+ flexShrink: 0,
537
+ ...(grow && { flexGrow: 1 }),
538
+ gap: TRIGGER_GAP,
539
+ // Logical rather than left/right, because the one side that differs is
540
+ // the panel side: a `line` trigger carries the wash's air off the rule
541
+ // as padding, so the label keeps the same room a `subtle` one gives it
542
+ // and the wash still has somewhere to stop.
543
+ paddingTop: look.py,
544
+ paddingBottom: look.py + (vertical ? 0 : railGap(look)),
545
+ paddingStart: look.px,
546
+ paddingEnd: look.px + (vertical ? railGap(look) : 0),
547
+ fontSize: look.font,
548
+ color: ink,
549
+ ...(disabled ? {} : { cursor: 'pointer' }),
550
+ transition: { backgroundColor: 100, color: 100 },
551
+ ...Object.assign({}, ...variantStyle),
552
+ ...(disabled ? {} : hover),
553
+ },
554
+ behind: [chip, wash, outlineShape],
555
+ after: marker,
556
+ };
557
+ }
558
+
559
+ // --- root -------------------------------------------------------------------
560
+
561
+ /** What `onValueChange` reports — Chakra's `details` object, so a handler
562
+ * written for Chakra (`(e) => setValue(e.value)`) transfers unchanged. */
563
+ export interface TabsValueChange {
564
+ value: string;
565
+ }
566
+
567
+ export interface TabsProps {
568
+ /** The selected trigger's value. Providing it makes the tabs controlled. */
569
+ value?: string;
570
+ /**
571
+ * The initially selected value, when uncontrolled. There is no implicit
572
+ * default: with neither prop nothing is selected and no panel shows, as in
573
+ * Chakra — the root cannot know its triggers until they mount, and a first
574
+ * render that selected nobody only to snap a frame later would be worse
575
+ * than asking for one line.
576
+ */
577
+ defaultValue?: string;
578
+ /** Selection changed — by click or by keyboard. Fires in controlled and
579
+ * uncontrolled mode alike. */
580
+ onValueChange?(change: TabsValueChange): void;
581
+ /** How the strip is drawn. Default `'line'`. */
582
+ variant?: TabsVariant;
583
+ /** Trigger padding, and the type inside. Default `'md'`. */
584
+ size?: TabsSize;
585
+ /** A vertical strip is a column beside its panel. Default `'horizontal'`. */
586
+ orientation?: 'horizontal' | 'vertical';
587
+ /**
588
+ * `'automatic'` (default) selects as the arrows move, the way a desktop
589
+ * notebook behaves. `'manual'` moves focus only and commits on Enter or
590
+ * Space — what you want when a panel is expensive to build.
591
+ */
592
+ activationMode?: 'automatic' | 'manual';
593
+ /** Triggers share the strip equally, filling its length. Default false. */
594
+ fitted?: boolean;
595
+ /** Where the triggers sit along the strip. Default `'start'`. */
596
+ justify?: 'start' | 'center' | 'end';
597
+ /**
598
+ * What a **horizontal** strip does with more tabs than it has room for.
599
+ *
600
+ * `'menu'` (the default) keeps as many as fit and moves the rest behind a
601
+ * button at the end of the strip, which drops them as a menu; the selected
602
+ * tab is never one of them. `'clip'` is the older behaviour — the strip
603
+ * overflows and the tabs past its edge are simply not visible.
604
+ *
605
+ * A vertical strip ignores this: it overflows downward, where a menu is
606
+ * the wrong answer and a scroll is the right one.
607
+ */
608
+ overflow?: TabsOverflow;
609
+ /** Build a panel the first time its tab is selected rather than up front.
610
+ * Default false: every panel mounts, hidden, so its state is live. */
611
+ lazyMount?: boolean;
612
+ /** Unmount a panel when its tab is deselected, giving up its state.
613
+ * Default false. */
614
+ unmountOnExit?: boolean;
615
+ /**
616
+ * The colour the selection takes — Chakra's `colorPalette`, as one colour.
617
+ * A `$token` is resolved against the palette. Default `$accent`.
618
+ */
619
+ accent?: string;
620
+ /**
621
+ * The colour behind the tabs, which is what an `enclosed` or `outline`
622
+ * selected trigger is filled with so it reads as part of the panel.
623
+ * Default `$background`; tabs on a card want `'$surface'`.
624
+ */
625
+ ground?: string;
626
+ /** The root `<box>`'s style — width, padding, `flexGrow`. */
627
+ style?: StyleInput;
628
+ 'data-testname'?: string;
629
+ children?: ReactNode;
630
+ }
631
+
632
+ /**
633
+ * Tabs.
634
+ *
635
+ * ```jsx
636
+ * <Tabs defaultValue="members">
637
+ * <TabsList>
638
+ * <TabsTrigger value="members">Members</TabsTrigger>
639
+ * <TabsTrigger value="projects">Projects</TabsTrigger>
640
+ * </TabsList>
641
+ * <TabsContent value="members">…</TabsContent>
642
+ * <TabsContent value="projects">…</TabsContent>
643
+ * </Tabs>
644
+ * ```
645
+ */
646
+ export function Tabs(props: TabsProps): ReactElement {
647
+ // `Theme` is an interface, so it has no implicit index signature — the same
648
+ // widening `hx.ts` documents for the `theme` prop.
649
+ const theme = useTheme() as unknown as Record<string, unknown>;
650
+ const [uncontrolled, setUncontrolled] = React.useState(props.defaultValue);
651
+ const [focused, setFocused] = React.useState<string | null>(null);
652
+ const selected = props.value ?? uncontrolled;
653
+ const vertical = props.orientation === 'vertical';
654
+ const variant = props.variant ?? 'line';
655
+ const size = SIZES[props.size ?? 'md'] ?? SIZES.md;
656
+ const accent = resolveColor(props.accent, theme, String(theme.accent));
657
+ const ground = resolveColor(props.ground, theme, String(theme.background));
658
+ const body = typeof theme.fontSize === 'number' ? theme.fontSize : 14;
659
+ const { onValueChange } = props;
660
+ const controlled = props.value !== undefined;
661
+
662
+ // The registry the keyboard walks. A ref rather than state: a trigger
663
+ // mounting must not re-render the tree, and the walk only happens inside
664
+ // an event handler, where `current` is always current.
665
+ const triggers = React.useRef(new Map<string, TriggerEntry>());
666
+
667
+ // Which values have ever been selected, for `lazyMount`. Written during
668
+ // render on purpose — adding the current selection is idempotent, and an
669
+ // effect would run a frame after the panel already needed the answer.
670
+ const seen = React.useRef(new Set<string>());
671
+ if (selected !== undefined) seen.current.add(selected);
672
+
673
+ const select = React.useCallback(
674
+ (value: string) => {
675
+ if (value === selected) return;
676
+ if (!controlled) setUncontrolled(value);
677
+ onValueChange?.({ value });
678
+ },
679
+ [selected, controlled, onValueChange],
680
+ );
681
+
682
+ const look = React.useMemo<TabsLook>(
683
+ () => ({
684
+ variant,
685
+ vertical,
686
+ manual: props.activationMode === 'manual',
687
+ fitted: props.fitted ?? false,
688
+ justify: props.justify,
689
+ overflow: props.overflow ?? 'menu',
690
+ accent,
691
+ ground,
692
+ text: String(theme.text),
693
+ font: body + size.step,
694
+ px: size.px,
695
+ py: size.py,
696
+ selected,
697
+ focused,
698
+ select,
699
+ setFocused,
700
+ triggers: triggers.current,
701
+ everSelected: (value: string) => seen.current.has(value),
702
+ lazyMount: props.lazyMount ?? false,
703
+ unmountOnExit: props.unmountOnExit ?? false,
704
+ }),
705
+ [
706
+ variant,
707
+ vertical,
708
+ props.activationMode,
709
+ props.fitted,
710
+ props.justify,
711
+ props.overflow,
712
+ props.lazyMount,
713
+ props.unmountOnExit,
714
+ accent,
715
+ ground,
716
+ theme.text,
717
+ body,
718
+ size,
719
+ selected,
720
+ focused,
721
+ select,
722
+ ],
723
+ );
724
+
725
+ return h(
726
+ LookContext.Provider,
727
+ { value: look },
728
+ hx(
729
+ 'box',
730
+ {
731
+ // The same fill-the-space default core's `<Tabs>` had: a notebook is
732
+ // usually the main region of whatever pane it is in. `style` wins for
733
+ // the one that is not.
734
+ style: styled(
735
+ {
736
+ flexGrow: 1,
737
+ minHeight: 0,
738
+ minWidth: 0,
739
+ flexDirection: vertical ? 'row' : 'column',
740
+ },
741
+ props.style,
742
+ ),
743
+ 'data-testname': props['data-testname'],
744
+ },
745
+ props.children,
746
+ ),
747
+ );
748
+ }
749
+
750
+ // --- overflow ---------------------------------------------------------------
751
+
752
+ /**
753
+ * A trigger the strip could put in its menu, read off the element the app
754
+ * wrote. The menu draws the same children the tab does, so there is no
755
+ * second place to name a label and no way for the two to disagree.
756
+ */
757
+ interface OverflowItem {
758
+ value: string;
759
+ disabled: boolean;
760
+ /**
761
+ * What this trigger's own children hash to — every primitive child, and a
762
+ * placeholder for each element one. A relabelled tab has to be measured
763
+ * again rather than kept at the width its old label had, and a tab in the
764
+ * menu is not on the strip to be measured, so the width cache is keyed by
765
+ * this as well as by `value`.
766
+ */
767
+ key: string;
768
+ children: ReactNode;
769
+ }
770
+
771
+ /** What the strip's own re-measure needs and `DrawnNode` does not carry: the
772
+ * owning window's "something moved" signal, the display scale `abs` is in,
773
+ * and the box inside the padding. The same widening `internal/widget.ts`
774
+ * documents — a ref's public contract is geometry and focus. */
775
+ interface StripNode {
776
+ root?: {
777
+ onAnchorChange?: (fn: () => void) => () => void;
778
+ } | null;
779
+ scale?: number;
780
+ contentBox?: () => { width: number; height: number };
781
+ }
782
+
783
+ /** A tab's laid-out size in logical pixels, or `null` for one the strip has
784
+ * not drawn since its label last changed. */
785
+ type SizeOf = (
786
+ value: string,
787
+ key: string,
788
+ ) => { width: number; height: number } | null;
789
+
790
+ /** The strip's tab stop when the focus is on the overflow button rather than
791
+ * on a tab. A NUL is not a value any trigger can be given. */
792
+ const MORE = '\u0000more';
793
+
794
+ const NONE: readonly string[] = [];
795
+
796
+ function sameValues(a: readonly string[], b: readonly string[]): boolean {
797
+ return a.length === b.length && a.every((value, i) => b[i] === value);
798
+ }
799
+
800
+ /** Every primitive child, joined — see {@link OverflowItem.key}. */
801
+ function labelKey(children: ReactNode): string {
802
+ const parts: string[] = [];
803
+ React.Children.forEach(children, (child) => {
804
+ parts.push(
805
+ typeof child === 'string' || typeof child === 'number'
806
+ ? String(child)
807
+ : '\u0000',
808
+ );
809
+ });
810
+ return parts.join('\u0001');
811
+ }
812
+
813
+ /**
814
+ * Which tabs do not fit, in the order they were written.
815
+ *
816
+ * The whole thing runs off what layout actually did, because nothing else
817
+ * here can answer it: a tab's width is its label in the theme's face at the
818
+ * size the theme is set to, plus whatever the app hung beside it. So the
819
+ * strip measures the triggers it has drawn and remembers what each one came
820
+ * out at, and the tabs in the menu are chosen from those remembered widths —
821
+ * they are not on the strip to be measured while they are in the menu.
822
+ *
823
+ * **Two passes, not one.** A tab whose width is not known yet (a first
824
+ * render, a tab just added, a label just changed) puts the strip back to
825
+ * showing everything for one pass, which is what gives it a width; the pass
826
+ * after that is the one that decides. That costs a frame of a strip
827
+ * overflowing its own edge — clipped, since the strip clips while the menu
828
+ * is on — and buys a fixed point: what is hidden is a pure function of the
829
+ * remembered widths and the room, so the answer does not move unless
830
+ * something really did.
831
+ *
832
+ * **When it re-runs.** After every commit, through `afterLayout` — react-x11
833
+ * lays out on a frame flush rather than in the commit, so an effect reads the
834
+ * *previous* pass's geometry. And after every layout this component did not
835
+ * cause, through the owning window's `onAnchorChange`: the strip getting
836
+ * narrower is usually the window being resized, which re-lays out without
837
+ * re-rendering anything and would otherwise leave the menu answering a width
838
+ * the strip stopped having.
839
+ */
840
+ function useOverflow(
841
+ active: boolean,
842
+ items: readonly OverflowItem[],
843
+ look: TabsLook,
844
+ listRef: React.RefObject<DrawnNode | null>,
845
+ moreRef: React.RefObject<DrawnNode | null>,
846
+ ): { hidden: readonly string[]; sizeOf: SizeOf } {
847
+ const widths = React.useRef(
848
+ new Map<string, { key: string; width: number; height: number }>(),
849
+ );
850
+ // What `abs` was in when those were taken, so the menu can ask for them
851
+ // back in the logical pixels a style length speaks.
852
+ const measured = React.useRef(1);
853
+ const [hidden, setHidden] = React.useState<readonly string[]>(NONE);
854
+ // Read and written straight, beside the state: two `onAnchorChange`
855
+ // notifications can land in one flush, and the second has to see what the
856
+ // first decided rather than the render's stale copy.
857
+ const hiddenRef = React.useRef(hidden);
858
+ hiddenRef.current = hidden;
859
+
860
+ const measure = (): void => {
861
+ const list = listRef.current as (DrawnNode & StripNode) | null;
862
+ const room = list?.contentBox?.().width ?? 0;
863
+ // Nothing laid out yet, or laid out inside a hidden panel (`display:
864
+ // 'none'` is out of layout, so the strip is 0 wide there). Answering
865
+ // "everything overflows" would empty the strip into the menu and flash
866
+ // it back the moment the panel is shown.
867
+ if (!list || !(room > 0)) return;
868
+ // `abs` and `contentBox()` are device pixels; a style length is logical.
869
+ // Everything below is device, so the style numbers convert once, here.
870
+ const scale = list.scale && list.scale > 0 ? list.scale : 1;
871
+ measured.current = scale;
872
+ const gap = stripGap(look.variant) * scale;
873
+
874
+ const drawn = (value: string): DrawnNode | null => {
875
+ const node = look.triggers.get(value)?.node;
876
+ return node && node.abs.width > 0 ? node : null;
877
+ };
878
+
879
+ // A `fitted` strip with nothing hidden has already grown its triggers to
880
+ // fill it, so those widths are the strip shared out rather than what a
881
+ // tab needs, and writing them down would be writing down the answer to
882
+ // the wrong question. The exception is the pass that matters: a `fitted`
883
+ // strip that is *overflowing* had no free space to grow into, so the
884
+ // widths are honest — and that is exactly the pass the menu appears on.
885
+ let live = 0;
886
+ let count = 0;
887
+ for (const item of items) {
888
+ const node = drawn(item.value);
889
+ if (!node) continue;
890
+ live += node.abs.width;
891
+ count += 1;
892
+ }
893
+ live += Math.max(0, count - 1) * gap;
894
+ if (!look.fitted || hiddenRef.current.length > 0 || live > room) {
895
+ for (const item of items) {
896
+ const node = drawn(item.value);
897
+ if (!node) continue;
898
+ widths.current.set(item.value, {
899
+ key: item.key,
900
+ width: node.abs.width,
901
+ height: node.abs.height,
902
+ });
903
+ }
904
+ }
905
+ // A tab that has left the strip has no width worth keeping — and this is
906
+ // the only thing that ever empties the map.
907
+ const present = new Set(items.map((item) => item.value));
908
+ for (const value of [...widths.current.keys()]) {
909
+ if (!present.has(value)) widths.current.delete(value);
910
+ }
911
+
912
+ const known = (item: OverflowItem): number | null => {
913
+ const seen = widths.current.get(item.value);
914
+ return seen && seen.key === item.key ? seen.width : null;
915
+ };
916
+
917
+ let natural = Math.max(0, items.length - 1) * gap;
918
+ let unmeasured = false;
919
+ for (const item of items) {
920
+ const width = known(item);
921
+ if (width === null) unmeasured = true;
922
+ else natural += width;
923
+ }
924
+
925
+ // The measuring pass — everything on the strip, nothing decided yet —
926
+ // is `NONE`, which is also the answer when it all fits.
927
+ let next: readonly string[] = NONE;
928
+ if (!unmeasured && natural > room) {
929
+ // The button is measured once it exists; before that it is guessed at
930
+ // its own padding plus a chevron, and the pass after it mounts
931
+ // corrects the guess.
932
+ const more =
933
+ moreRef.current?.abs.width || (look.px * 2 + look.font) * scale;
934
+ const budget = room - more - gap;
935
+ const shown = new Set<string>();
936
+ let used = 0;
937
+ const take = (value: string, width: number): void => {
938
+ used += width + (shown.size ? gap : 0);
939
+ shown.add(value);
940
+ };
941
+ // Straight down the strip, and the first tab that does not fit ends
942
+ // it: which tabs are on the strip is a question about room, and
943
+ // nothing else. The selected tab is not pulled out of the menu to keep
944
+ // its place — the button wears the selected look instead, so the strip
945
+ // does not reshuffle itself every time a tab is picked out of it.
946
+ for (const item of items) {
947
+ if (shown.has(item.value)) continue;
948
+ const width = known(item) ?? 0;
949
+ if (used + width + (shown.size ? gap : 0) > budget) break;
950
+ take(item.value, width);
951
+ }
952
+ next = items
953
+ .filter((item) => !shown.has(item.value))
954
+ .map((item) => item.value);
955
+ }
956
+
957
+ if (sameValues(next, hiddenRef.current)) return;
958
+ hiddenRef.current = next;
959
+ setHidden(next);
960
+ };
961
+
962
+ // Re-read through a ref so the subscriptions below run this render's
963
+ // closure without being torn down and rebuilt for every one of them.
964
+ const measureRef = React.useRef(measure);
965
+ measureRef.current = measure;
966
+
967
+ // Deliberately no dependency array: any commit can move a tab's width.
968
+ React.useEffect(() => {
969
+ if (!active) return undefined;
970
+ const tick = afterLayout(() => measureRef.current());
971
+ return () => cancelAfterLayout(tick);
972
+ });
973
+
974
+ React.useEffect(() => {
975
+ if (!active) return undefined;
976
+ const root = (listRef.current as StripNode | null)?.root;
977
+ if (!root?.onAnchorChange) return undefined;
978
+ return root.onAnchorChange(() => measureRef.current());
979
+ }, [active, listRef]);
980
+
981
+ // `overflow="clip"` turned on mid-life, or the strip turned vertical: hand
982
+ // the tabs back rather than leaving them in a menu nothing draws.
983
+ React.useEffect(() => {
984
+ if (!active && hiddenRef.current.length > 0) {
985
+ hiddenRef.current = NONE;
986
+ setHidden(NONE);
987
+ }
988
+ }, [active]);
989
+
990
+ const sizeOf = React.useCallback<SizeOf>((value, key) => {
991
+ const seen = widths.current.get(value);
992
+ if (!seen || seen.key !== key) return null;
993
+ const scale = measured.current || 1;
994
+ return { width: seen.width / scale, height: seen.height / scale };
995
+ }, []);
996
+
997
+ return { hidden: active ? hidden : NONE, sizeOf };
998
+ }
999
+
1000
+ /**
1001
+ * The overflow menu's own size, worked out from the measurements the strip
1002
+ * already took rather than left to the popup.
1003
+ *
1004
+ * A `<popup>` can size itself from its content, and that is normally the
1005
+ * right answer — but its placement is then a function of a number nothing
1006
+ * outside it can see, which is a bad thing for the one popup here whose
1007
+ * anchor moves every time the strip re-fits. A menu row is the same content
1008
+ * as the tab it stands for — same label, same face, same gap, same glyph
1009
+ * beside it — laid out with the row's padding instead of the tab's, so the
1010
+ * strip's own measurement answers it exactly.
1011
+ *
1012
+ * `null` when a tab in the menu has not been measured, which is the pass
1013
+ * where nothing is decided anyway; the popup sizes itself for that frame.
1014
+ */
1015
+ function menuSheet(
1016
+ look: TabsLook,
1017
+ items: readonly OverflowItem[],
1018
+ sizeOf: SizeOf,
1019
+ ): { width: number; height: number } | null {
1020
+ if (items.length === 0) return null;
1021
+ let content = 0;
1022
+ let rows = 0;
1023
+ for (const item of items) {
1024
+ const size = sizeOf(item.value, item.key);
1025
+ if (!size) return null;
1026
+ content = Math.max(content, size.width - look.px * 2 + MENU_ROW_PX * 2);
1027
+ // …and off the height, the air a `line` trigger keeps under its wash on
1028
+ // top of its padding: a menu row has no rule to stand off.
1029
+ rows += size.height - look.py * 2 - railGap(look) + MENU_ROW_PY * 2;
1030
+ }
1031
+ const frame = (SHEET_PAD + SHEET_BORDER) * 2;
1032
+ return {
1033
+ width: Math.ceil(Math.max(content + frame, MENU_MIN_WIDTH)),
1034
+ height: Math.ceil(rows + (items.length - 1) * MENU_ROW_GAP + frame),
1035
+ };
1036
+ }
1037
+
1038
+ /**
1039
+ * The button at the end of a strip that ran out of room, and the menu it
1040
+ * drops.
1041
+ *
1042
+ * **It is a stop on the strip like any other**, drawn from the same
1043
+ * {@link tabChrome} the triggers are — so when the selected tab is one of
1044
+ * the ones it is holding, the button wears the selected look (the accent
1045
+ * marker under a `line` strip, the chip on a `subtle` one) and the menu
1046
+ * marks the row. That is what a tab picked out of the menu does instead of
1047
+ * displacing a tab that fitted: the strip keeps the tabs it has room for,
1048
+ * and the button says the selection is behind it. It also takes the strip's
1049
+ * tab stop while it holds the selection, since the trigger that would
1050
+ * normally have it is not on the strip to hold anything.
1051
+ *
1052
+ * The menu is a `<popup>` anchored to the button and sized from its own
1053
+ * content — override-redirect, grabbing the pointer, dismissed by a press
1054
+ * anywhere else. Which means it never takes the focus, so **the button keeps
1055
+ * the keyboard** and hands the menu its keys, the way `<DatePicker>`'s
1056
+ * trigger does: Down/Up open it and walk it, Enter and Space commit, Escape
1057
+ * shuts it. A press elsewhere in the window blurs the button and that shuts
1058
+ * it too, and `useDismissOnWindowBlur` covers the one case a blur cannot —
1059
+ * the *window* going to the background, which leaves the button focused.
1060
+ */
1061
+ function TabsMore(props: {
1062
+ items: readonly OverflowItem[];
1063
+ look: TabsLook;
1064
+ moreRef: React.RefObject<DrawnNode | null>;
1065
+ /** What the sheet comes to, from the strip's own measurements — see
1066
+ * {@link menuSheet}. `null` leaves the popup to size itself. */
1067
+ sheet: { width: number; height: number } | null;
1068
+ }): ReactElement {
1069
+ const { items, look, moreRef, sheet } = props;
1070
+ const strip = React.useContext(StripContext);
1071
+ const theme = useTheme();
1072
+ const [open, setOpen] = React.useState(false);
1073
+ // -1 is "nothing yet", which is what a menu dropped with the mouse opens
1074
+ // on: a row lit before the pointer or the arrows have said anything is a
1075
+ // row the next Enter would commit by accident.
1076
+ const [active, setActive] = React.useState(-1);
1077
+ // The button wears whatever the tabs beside it wear — including the reason
1078
+ // a `line` strip's fill has to be a box ({@link hoverWash}), which is why
1079
+ // this is tracked rather than declared.
1080
+ const [hovered, setHovered] = React.useState(false);
1081
+ const lit = hovered || open;
1082
+
1083
+ /** Where the selection is, when it is behind this button. */
1084
+ const chosen = items.findIndex((item) => item.value === look.selected);
1085
+ const holds = chosen >= 0;
1086
+
1087
+ const close = (): void => setOpen(false);
1088
+ useDismissOnWindowBlur(moreRef, open, close);
1089
+
1090
+ // The menu is drawn from the tabs the strip could not fit, and that set
1091
+ // moves under it — a resize while it is open can take one back. Keeping
1092
+ // the cursor in range is cheaper than closing on every re-fit.
1093
+ const at = items.length ? Math.min(active, items.length - 1) : -1;
1094
+
1095
+ const choose = (value: string): void => {
1096
+ close();
1097
+ look.select(value);
1098
+ // The focus stays here: the tab that was picked is still in this menu,
1099
+ // and this button is the stop on the strip that now stands for it.
1100
+ };
1101
+
1102
+ const step = (delta: number): number => {
1103
+ const n = items.length;
1104
+ if (n === 0) return -1;
1105
+ // From "nothing yet", Down means the first row and Up means the last —
1106
+ // which is what starting one step outside either end comes to.
1107
+ const from = at < 0 ? (delta > 0 ? -1 : 0) : at;
1108
+ for (let i = 1; i <= n; i++) {
1109
+ const index = (((from + delta * i) % n) + n) % n;
1110
+ if (!items[index]?.disabled) return index;
1111
+ }
1112
+ return at;
1113
+ };
1114
+
1115
+ const commit = (): void => {
1116
+ const item = items[at];
1117
+ if (item && !item.disabled) choose(item.value);
1118
+ };
1119
+
1120
+ const onKeyDown = (ev: KeyboardEvent): void => {
1121
+ if (ev.keysym === XK_ESCAPE) {
1122
+ if (!open) return;
1123
+ close();
1124
+ ev.preventDefault();
1125
+ ev.stopPropagation();
1126
+ return;
1127
+ }
1128
+ if (!open) {
1129
+ if (ev.keysym !== XK_DOWN && ev.keysym !== XK_UP) return;
1130
+ // Opened from the keyboard, so it opens on a row: the selected one if
1131
+ // the selection is in here, and otherwise the end the key came from.
1132
+ setActive(holds ? chosen : ev.keysym === XK_UP ? items.length - 1 : 0);
1133
+ setOpen(true);
1134
+ ev.preventDefault();
1135
+ ev.stopPropagation();
1136
+ return;
1137
+ }
1138
+ // An open menu owns the keyboard: the strip's own arrow walk is one
1139
+ // bubble away, and a Down that both moved the menu's cursor and roved
1140
+ // the focus off the button would close the menu it had just moved.
1141
+ if (ev.keysym === XK_DOWN) setActive(step(1));
1142
+ else if (ev.keysym === XK_UP) setActive(step(-1));
1143
+ else if (ev.keysym === XK_RETURN || ev.codepoint === 32) commit();
1144
+ else return;
1145
+ ev.preventDefault();
1146
+ ev.stopPropagation();
1147
+ };
1148
+
1149
+ const row = (item: OverflowItem, index: number): ReactElement =>
1150
+ hx(
1151
+ 'box',
1152
+ {
1153
+ key: item.value,
1154
+ // A menu of tabs is a set of mutually exclusive choices, which is
1155
+ // what this role says — and it is the only place a screen reader
1156
+ // hears about a tab that is not on the strip to carry `role="tab"`.
1157
+ role: 'menuitemradio',
1158
+ 'aria-checked': index === chosen,
1159
+ disabled: item.disabled || undefined,
1160
+ onMouseEnter: () => {
1161
+ if (!item.disabled) setActive(index);
1162
+ },
1163
+ onClick: () => {
1164
+ if (!item.disabled) choose(item.value);
1165
+ },
1166
+ style: {
1167
+ flexDirection: 'row',
1168
+ alignItems: 'center',
1169
+ gap: TRIGGER_GAP,
1170
+ paddingLeft: MENU_ROW_PX,
1171
+ paddingRight: MENU_ROW_PX,
1172
+ paddingTop: MENU_ROW_PY,
1173
+ paddingBottom: MENU_ROW_PY,
1174
+ borderRadius: WASH_CORNER,
1175
+ fontSize: look.font,
1176
+ color: item.disabled ? '$textMuted' : '$text',
1177
+ ...(item.disabled
1178
+ ? {}
1179
+ : {
1180
+ cursor: 'pointer',
1181
+ // The lit row is state rather than a `:hover` block because
1182
+ // the arrows move it too, and a menu with two lit rows —
1183
+ // the pointer's and the keyboard's — is worse than one.
1184
+ ...(index === at && { backgroundColor: '$surfaceHover' }),
1185
+ }),
1186
+ },
1187
+ },
1188
+ // The selected row's marker: the strip's own accent bar, turned to
1189
+ // stand along a row instead of under a tab. It is a mark and not a
1190
+ // fill so it cannot be confused with the row the cursor is on — both
1191
+ // can be true of the same row.
1192
+ index === chosen
1193
+ ? hx('box', {
1194
+ key: 'mark',
1195
+ style: {
1196
+ position: 'absolute',
1197
+ start: 0,
1198
+ top: MENU_ROW_PY / 2,
1199
+ bottom: MENU_ROW_PY / 2,
1200
+ width: INDICATOR,
1201
+ borderRadius: INDICATOR / 2,
1202
+ backgroundColor: look.accent,
1203
+ },
1204
+ })
1205
+ : null,
1206
+ withText(item.children, CAP_TRIM),
1207
+ );
1208
+
1209
+ const chrome = tabChrome(look, strip?.indicator ?? null, {
1210
+ selected: holds,
1211
+ disabled: false,
1212
+ hovered: lit,
1213
+ // Never: the button only exists while something is hidden, and a
1214
+ // `fitted` strip has stopped sharing its width out by then.
1215
+ grow: false,
1216
+ });
1217
+
1218
+ return hx(
1219
+ 'box',
1220
+ {
1221
+ ref: moreRef,
1222
+ role: 'button',
1223
+ 'aria-haspopup': 'menu',
1224
+ 'aria-expanded': open,
1225
+ 'aria-label': `${items.length} more tab${items.length === 1 ? '' : 's'}`,
1226
+ focusable: true,
1227
+ // The strip is one tab stop. A tab holds it — unless the selected tab
1228
+ // is one of the ones behind this button, and then this holds it.
1229
+ tabIndex: holds ? 0 : -1,
1230
+ // On the press, not the release: a control whose whole purpose is to
1231
+ // be looked at has nothing to gain from waiting out the click.
1232
+ onMouseDown: () => {
1233
+ setActive(-1);
1234
+ setOpen((was) => !was);
1235
+ },
1236
+ onMouseEnter: () => setHovered(true),
1237
+ onMouseLeave: () => setHovered(false),
1238
+ onFocus: () => look.setFocused(MORE),
1239
+ onBlur: () => {
1240
+ look.setFocused(null);
1241
+ close();
1242
+ },
1243
+ onKeyDown,
1244
+ style: [
1245
+ chrome.style,
1246
+ // An open button reads as engaged in every variant, not only the one
1247
+ // whose wash is a box — and not when it is already wearing the
1248
+ // selected look, which outranks a hover.
1249
+ lit && !holds
1250
+ ? look.variant === 'line'
1251
+ ? { color: '$text' }
1252
+ : { color: '$text', backgroundColor: '$surfaceHover' }
1253
+ : {},
1254
+ ],
1255
+ 'data-testname': 'tabs-more',
1256
+ },
1257
+ chrome.behind,
1258
+ h(Icon, { name: 'chevronDown', size: look.font - 4 }),
1259
+ chrome.after,
1260
+ open &&
1261
+ items.length > 0 &&
1262
+ hx(
1263
+ 'popup',
1264
+ {
1265
+ theme,
1266
+ // Sized here rather than from its own content, so where it lands is
1267
+ // arithmetic this component can see — {@link menuSheet}.
1268
+ ...(sheet ?? {}),
1269
+ anchor: {
1270
+ to: moreRef,
1271
+ placement: 'bottom',
1272
+ // The menu hangs off the end of the strip because the button
1273
+ // does, and `end` mirrors on its own in an RTL subtree.
1274
+ align: 'end',
1275
+ },
1276
+ grab: true,
1277
+ onDismiss: close,
1278
+ // ARGB where the display has it, so the corners the sheet gives up
1279
+ // are the desktop rather than a colour — the same call
1280
+ // `<DatePicker>`'s sheet makes, and the same reason the window
1281
+ // paints nothing itself when it can be seen through.
1282
+ transparent: true,
1283
+ style: {
1284
+ backgroundColor: theme.background,
1285
+ '@supports transparency': { backgroundColor: 'transparent' },
1286
+ } as SupportsStyle,
1287
+ },
1288
+ hx(
1289
+ 'box',
1290
+ {
1291
+ role: 'menu',
1292
+ style: {
1293
+ flexGrow: 1,
1294
+ minWidth: MENU_MIN_WIDTH,
1295
+ padding: SHEET_PAD,
1296
+ gap: MENU_ROW_GAP,
1297
+ borderWidth: SHEET_BORDER,
1298
+ borderColor: '$border',
1299
+ backgroundColor: theme.background,
1300
+ '@supports transparency': { borderRadius: theme.radiusPopup },
1301
+ } as SupportsStyle,
1302
+ },
1303
+ items.map(row),
1304
+ ),
1305
+ ),
1306
+ );
1307
+ }
1308
+
1309
+ // --- list -------------------------------------------------------------------
1310
+
1311
+ export interface TabsListProps {
1312
+ style?: StyleInput;
1313
+ 'data-testname'?: string;
1314
+ children?: ReactNode;
1315
+ }
1316
+
1317
+ /**
1318
+ * The strip of triggers — a single tab stop, walked with the arrows.
1319
+ *
1320
+ * The arrows are visual, the list is logical: in a mirrored horizontal strip
1321
+ * the *next* trigger is the one to the left, and in either direction the walk
1322
+ * follows where layout actually put each trigger (sorted by position, not by
1323
+ * mount order), wraps at the ends, and skips disabled triggers. Home/End jump
1324
+ * to the ends. In `manual` mode focus moves without selecting, and Enter or
1325
+ * Space commits — those keys are not handled here, because core makes them
1326
+ * the click the trigger's own `onClick` already is.
1327
+ *
1328
+ * A horizontal strip with more tabs than room keeps the ones that fit and
1329
+ * drops the rest into a menu at its end (`overflow`, on the root). The
1330
+ * menu's button is the last stop on the same walk.
1331
+ */
1332
+ export function TabsList(props: TabsListProps): ReactElement {
1333
+ const look = useLook('TabsList');
1334
+ const rtl = useDirection() === 'rtl';
1335
+ const { vertical } = look;
1336
+ const listRef = React.useRef<DrawnNode | null>(null);
1337
+ const moreRef = React.useRef<DrawnNode | null>(null);
1338
+
1339
+ // A `<TabsIndicator>` written into the strip, Chakra-style, is read here
1340
+ // and handed to the triggers — the selected one draws it. It has to be a
1341
+ // direct child for this to see it; anything else in the strip renders
1342
+ // where it was written.
1343
+ //
1344
+ // The triggers come off the same walk, for the overflow menu: it draws the
1345
+ // children the app wrote on the trigger, so a tab that moves into the menu
1346
+ // keeps its label without the app naming it twice. Anything in the strip
1347
+ // that is *not* a trigger stays on the strip and is not accounted for —
1348
+ // the arithmetic is about tabs.
1349
+ const children = React.Children.toArray(props.children);
1350
+ let indicator: TabsIndicatorProps | null = null;
1351
+ const items: OverflowItem[] = [];
1352
+ for (const child of children) {
1353
+ if (!React.isValidElement(child)) continue;
1354
+ if (child.type === TabsIndicator) {
1355
+ indicator = child.props as TabsIndicatorProps;
1356
+ } else if (child.type === TabsTrigger) {
1357
+ const trigger = child.props as TabsTriggerProps;
1358
+ items.push({
1359
+ value: trigger.value,
1360
+ disabled: trigger.disabled ?? false,
1361
+ key: labelKey(trigger.children),
1362
+ children: trigger.children,
1363
+ });
1364
+ }
1365
+ }
1366
+
1367
+ // One tab cannot overflow into a menu that would take more room than it
1368
+ // does, so the machinery does not start until there are two.
1369
+ const { hidden, sizeOf } = useOverflow(
1370
+ look.overflow === 'menu' && !vertical && items.length > 1,
1371
+ items,
1372
+ look,
1373
+ listRef,
1374
+ moreRef,
1375
+ );
1376
+ const hiddenSet = hidden.length > 0 ? new Set(hidden) : null;
1377
+ const overflowed = hiddenSet
1378
+ ? items.filter((item) => hiddenSet.has(item.value))
1379
+ : [];
1380
+
1381
+ const onKeyDown = (ev: KeyboardEvent) => {
1382
+ // Where layout put each trigger is the one ordering that cannot drift
1383
+ // from what the user sees — mount order can, after a remove and re-add.
1384
+ // The overflow button is a stop on the same walk and sorts in with the
1385
+ // rest, which is what puts it at the end without it being told.
1386
+ const stops = [...look.triggers.entries()].map(([value, entry]) => ({
1387
+ value,
1388
+ ...entry,
1389
+ }));
1390
+ if (moreRef.current) {
1391
+ stops.push({ value: MORE, node: moreRef.current, disabled: false });
1392
+ }
1393
+ const entries = stops.sort((a, b) =>
1394
+ vertical ? a.node.abs.y - b.node.abs.y : a.node.abs.x - b.node.abs.x,
1395
+ );
1396
+ // Visual order back to logical: an RTL strip reads right to left. A
1397
+ // vertical strip never mirrors — Up is Up.
1398
+ const swap = !vertical && rtl;
1399
+ if (swap) entries.reverse();
1400
+
1401
+ /** The next enabled trigger `delta` steps away, wrapping. */
1402
+ const step = (from: number, delta: number) => {
1403
+ const n = entries.length;
1404
+ if (n === 0) return null;
1405
+ for (let i = 1; i <= n; i++) {
1406
+ const entry = entries[(((from + delta * i) % n) + n) % n];
1407
+ if (entry && !entry.disabled) return entry;
1408
+ }
1409
+ return null;
1410
+ };
1411
+
1412
+ const goTo = (entry: { value: string; node: DrawnNode } | null) => {
1413
+ if (!entry) return;
1414
+ entry.node.focus();
1415
+ // The overflow button selects nothing: it is the way to the tabs that
1416
+ // did not fit, not one of them.
1417
+ if (!look.manual && entry.value !== MORE) look.select(entry.value);
1418
+ };
1419
+
1420
+ const back = vertical ? XK_UP : swap ? XK_RIGHT : XK_LEFT;
1421
+ const forward = vertical ? XK_DOWN : swap ? XK_LEFT : XK_RIGHT;
1422
+ const current = entries.findIndex(
1423
+ (entry) => entry.value === (look.focused ?? look.selected),
1424
+ );
1425
+ switch (ev.keysym) {
1426
+ case back:
1427
+ goTo(step(current === -1 ? 0 : current, -1));
1428
+ return;
1429
+ case forward:
1430
+ goTo(step(current === -1 ? 0 : current, 1));
1431
+ return;
1432
+ case XK_HOME:
1433
+ goTo(entries.find((entry) => !entry.disabled) ?? null);
1434
+ return;
1435
+ case XK_END:
1436
+ goTo([...entries].reverse().find((entry) => !entry.disabled) ?? null);
1437
+ return;
1438
+ default:
1439
+ }
1440
+ };
1441
+
1442
+ // What the strip itself draws, per variant. The `line` and `outline` rules
1443
+ // are an absolutely positioned 1px box on the panel edge rather than a
1444
+ // border: borders are all-edges here, and this has to sit on one side.
1445
+ // `zIndex: -1` paints it under the triggers, so a selected `outline`
1446
+ // trigger's opaque fill is what cuts it.
1447
+ const rule =
1448
+ look.variant === 'line' || look.variant === 'outline'
1449
+ ? hx('box', {
1450
+ style: [
1451
+ { position: 'absolute', backgroundColor: '$border', zIndex: -1 },
1452
+ vertical
1453
+ ? { top: 0, bottom: 0, end: 0, width: RULE }
1454
+ : { left: 0, right: 0, bottom: 0, height: RULE },
1455
+ ],
1456
+ })
1457
+ : null;
1458
+
1459
+ const chrome: Style =
1460
+ look.variant === 'enclosed'
1461
+ ? {
1462
+ // Chakra's `bg.muted` chip, mixed from the palette this theme
1463
+ // actually has — opaque, for the reason `over` gives.
1464
+ backgroundColor: over(look.ground, look.text, 0.07),
1465
+ borderRadius: CORNER,
1466
+ padding: 3,
1467
+ gap: 2,
1468
+ }
1469
+ : { gap: stripGap(look.variant) };
1470
+
1471
+ return h(
1472
+ StripContext.Provider,
1473
+ {
1474
+ value: {
1475
+ indicator,
1476
+ // A `fitted` strip stops growing its tabs while any of them is in
1477
+ // the menu — see {@link TabsStrip}.
1478
+ grow: look.fitted && hidden.length === 0,
1479
+ },
1480
+ },
1481
+ hx(
1482
+ 'box',
1483
+ {
1484
+ ref: listRef,
1485
+ role: 'tablist',
1486
+ 'aria-orientation': look.vertical ? 'vertical' : 'horizontal',
1487
+ onKeyDown,
1488
+ style: styled(
1489
+ {
1490
+ flexShrink: 0,
1491
+ flexDirection: vertical ? 'column' : 'row',
1492
+ alignItems: 'stretch',
1493
+ ...(look.justify && {
1494
+ justifyContent:
1495
+ look.justify === 'start'
1496
+ ? 'flex-start'
1497
+ : look.justify === 'end'
1498
+ ? 'flex-end'
1499
+ : 'center',
1500
+ }),
1501
+ // A pass that has not decided yet draws every tab at its natural
1502
+ // width, which is wider than the strip by definition — clipped
1503
+ // rather than spilling over the panel for the frame it takes.
1504
+ ...(look.overflow === 'menu' && !vertical
1505
+ ? { overflow: 'hidden' }
1506
+ : {}),
1507
+ ...chrome,
1508
+ },
1509
+ props.style,
1510
+ ),
1511
+ 'data-testname': props['data-testname'],
1512
+ },
1513
+ rule,
1514
+ hiddenSet
1515
+ ? children.filter(
1516
+ (child) =>
1517
+ !(
1518
+ React.isValidElement(child) &&
1519
+ child.type === TabsTrigger &&
1520
+ hiddenSet.has((child.props as TabsTriggerProps).value)
1521
+ ),
1522
+ )
1523
+ : children,
1524
+ overflowed.length > 0
1525
+ ? h(TabsMore, {
1526
+ key: 'tabs-more',
1527
+ items: overflowed,
1528
+ look,
1529
+ moreRef,
1530
+ sheet: menuSheet(look, overflowed, sizeOf),
1531
+ })
1532
+ : null,
1533
+ ),
1534
+ );
1535
+ }
1536
+ // --- trigger ----------------------------------------------------------------
1537
+
1538
+ export interface TabsTriggerProps {
1539
+ /** What this trigger selects — the `value` its `<TabsContent>` names. */
1540
+ value: string;
1541
+ /** Unclickable and skipped by the arrows. */
1542
+ disabled?: boolean;
1543
+ style?: StyleInput;
1544
+ 'data-testname'?: string;
1545
+ children?: ReactNode;
1546
+ }
1547
+
1548
+ /**
1549
+ * One tab. Prose and glyphs alike are legal children — a string is wrapped
1550
+ * in `<text>`, and an `<Icon>` inherits the trigger's ink (its `size` does
1551
+ * not inherit and has to be named).
1552
+ */
1553
+ export function TabsTrigger(props: TabsTriggerProps): ReactElement {
1554
+ const look = useLook('TabsTrigger');
1555
+ const strip = React.useContext(StripContext);
1556
+ const indicator = strip?.indicator ?? null;
1557
+ const { value, disabled = false } = props;
1558
+ const selected = look.selected === value;
1559
+ // Tracked rather than declared, because the wash is a box of its own —
1560
+ // {@link hoverWash}.
1561
+ const [hovered, setHovered] = React.useState(false);
1562
+
1563
+ // Registration is the ref callback: a new closure each render, so React
1564
+ // re-runs it and a `disabled` that changed is re-registered. Order in the
1565
+ // map churns with that, which is why the keyboard sorts by position
1566
+ // instead of trusting it.
1567
+ const register = (node: DrawnNode | null) => {
1568
+ if (node) look.triggers.set(value, { node, disabled });
1569
+ else look.triggers.delete(value);
1570
+ };
1571
+
1572
+ // Everything it looks like, shared with the overflow button beside it —
1573
+ // {@link tabChrome}.
1574
+ const chrome = tabChrome(look, indicator, {
1575
+ selected,
1576
+ disabled,
1577
+ hovered,
1578
+ grow: strip ? strip.grow : look.fitted,
1579
+ });
1580
+
1581
+ return hx(
1582
+ 'box',
1583
+ {
1584
+ role: 'tab',
1585
+ 'aria-selected': selected,
1586
+ ref: register,
1587
+ focusable: !disabled,
1588
+ // Roving focus: the selected trigger is the strip's tab stop. With
1589
+ // nothing selected yet every trigger is reachable, which is the lesser
1590
+ // evil — a strip nobody can tab into is worse than two stops.
1591
+ tabIndex: look.selected === undefined ? 0 : selected ? 0 : -1,
1592
+ disabled,
1593
+ onFocus: () => look.setFocused(value),
1594
+ onBlur: () => look.setFocused(null),
1595
+ onMouseEnter: () => setHovered(true),
1596
+ onMouseLeave: () => setHovered(false),
1597
+ onClick: () => !disabled && look.select(value),
1598
+ style: styled(chrome.style, props.style),
1599
+ 'data-testname': props['data-testname'],
1600
+ },
1601
+ chrome.behind,
1602
+ withText(props.children, CAP_TRIM),
1603
+ chrome.after,
1604
+ );
1605
+ }
1606
+
1607
+ // --- indicator --------------------------------------------------------------
1608
+
1609
+ export interface TabsIndicatorProps {
1610
+ /** Merged over the default chip — a wash of the accent, radius 4. */
1611
+ style?: StyleInput;
1612
+ }
1613
+
1614
+ /**
1615
+ * A marker that follows the selected trigger — Chakra's `Tabs.Indicator`,
1616
+ * and like there it is what gives the `plain` variant a look.
1617
+ *
1618
+ * Write it as a direct child of `<TabsList>`. It renders nothing where it
1619
+ * stands: the selected trigger draws it, as a box behind its own label — so
1620
+ * layout owns the geometry and a resize, a font change or an RTL mirror can
1621
+ * never leave the marker where a trigger used to be. What that costs is the
1622
+ * slide: this renderer cannot animate between two mounts, so the marker
1623
+ * moves in one step.
1624
+ */
1625
+ export function TabsIndicator(props: TabsIndicatorProps): null {
1626
+ useLook('TabsIndicator');
1627
+ void props;
1628
+ return null;
1629
+ }
1630
+
1631
+ // --- content ----------------------------------------------------------------
1632
+
1633
+ export interface TabsContentProps {
1634
+ /** Which trigger shows this panel. */
1635
+ value: string;
1636
+ style?: StyleInput;
1637
+ 'data-testname'?: string;
1638
+ children?: ReactNode;
1639
+ }
1640
+
1641
+ /**
1642
+ * One panel. Every panel mounts up front and the unselected ones are
1643
+ * `display: 'none'` — out of layout, still alive, so a form half-filled on
1644
+ * another tab keeps its state. `lazyMount` defers a panel until its first
1645
+ * selection; `unmountOnExit` gives the state up when it hides. Both are on
1646
+ * the root, because they are a policy about the set, not about one panel.
1647
+ */
1648
+ export function TabsContent(props: TabsContentProps): ReactElement | null {
1649
+ const look = useLook('TabsContent');
1650
+ const selected = look.selected === props.value;
1651
+
1652
+ if (!selected) {
1653
+ if (look.unmountOnExit) return null;
1654
+ if (look.lazyMount && !look.everSelected(props.value)) return null;
1655
+ }
1656
+
1657
+ return hx(
1658
+ 'box',
1659
+ {
1660
+ role: 'tabpanel',
1661
+ // A hidden panel is still mounted (that is the point), but it must not
1662
+ // be read: `display: 'none'` takes it out of layout, and this takes it
1663
+ // out of the accessibility tree.
1664
+ 'aria-hidden': selected ? undefined : true,
1665
+ style: styled(
1666
+ {
1667
+ display: selected ? 'flex' : 'none',
1668
+ flexDirection: 'column',
1669
+ flexGrow: 1,
1670
+ minHeight: 0,
1671
+ minWidth: 0,
1672
+ // Prose in a panel is body text. Named here because a bare
1673
+ // `<text>`'s default ink is not the palette's — it has to inherit
1674
+ // one, or a dark theme reads it in the light theme's black.
1675
+ color: '$text',
1676
+ ...(look.vertical
1677
+ ? { paddingStart: PANEL_GAP }
1678
+ : { paddingTop: PANEL_GAP }),
1679
+ },
1680
+ props.style,
1681
+ ),
1682
+ 'data-testname': props['data-testname'],
1683
+ },
1684
+ withText(props.children),
1685
+ );
1686
+ }