@react-x11/components 0.4.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 (219) hide show
  1. package/README.md +125 -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 +7 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +21 -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/reorder/hx.d.ts +18 -0
  140. package/dist/reorder/hx.d.ts.map +1 -0
  141. package/dist/reorder/hx.js +10 -0
  142. package/dist/reorder/hx.js.map +1 -0
  143. package/dist/reorder/index.d.ts +314 -0
  144. package/dist/reorder/index.d.ts.map +1 -0
  145. package/dist/reorder/index.js +1539 -0
  146. package/dist/reorder/index.js.map +1 -0
  147. package/dist/reorder/model.d.ts +107 -0
  148. package/dist/reorder/model.d.ts.map +1 -0
  149. package/dist/reorder/model.js +162 -0
  150. package/dist/reorder/model.js.map +1 -0
  151. package/dist/richtext/node.d.ts +15 -5
  152. package/dist/richtext/node.d.ts.map +1 -1
  153. package/dist/richtext/node.js +40 -11
  154. package/dist/richtext/node.js.map +1 -1
  155. package/dist/richtext/runs.d.ts +35 -13
  156. package/dist/richtext/runs.d.ts.map +1 -1
  157. package/dist/richtext/runs.js +83 -27
  158. package/dist/richtext/runs.js.map +1 -1
  159. package/dist/tabs/index.d.ts +17 -0
  160. package/dist/tabs/index.d.ts.map +1 -1
  161. package/dist/tabs/index.js +849 -186
  162. package/dist/tabs/index.js.map +1 -1
  163. package/dist/terminal/vt/fonts.d.ts +45 -15
  164. package/dist/terminal/vt/fonts.d.ts.map +1 -1
  165. package/dist/terminal/vt/fonts.js +89 -19
  166. package/dist/terminal/vt/fonts.js.map +1 -1
  167. package/dist/terminal/vt/node.d.ts +42 -0
  168. package/dist/terminal/vt/node.d.ts.map +1 -1
  169. package/dist/terminal/vt/node.js +95 -11
  170. package/dist/terminal/vt/node.js.map +1 -1
  171. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  172. package/dist/terminal/vt/renderer.js +13 -3
  173. package/dist/terminal/vt/renderer.js.map +1 -1
  174. package/package.json +13 -3
  175. package/src/charts/components.ts +14 -7
  176. package/src/charts/node.ts +81 -26
  177. package/src/charts/render.ts +16 -6
  178. package/src/code-editor/node.ts +147 -53
  179. package/src/flow/draw.ts +104 -35
  180. package/src/flow/index.ts +33 -13
  181. package/src/flow/model.ts +4 -2
  182. package/src/flow/node.ts +158 -59
  183. package/src/flow/types.ts +46 -21
  184. package/src/formula/index.ts +4 -2
  185. package/src/formula/node.ts +24 -5
  186. package/src/html/controls.ts +4 -1
  187. package/src/html/css/cascade.ts +18 -7
  188. package/src/html/css/parse.ts +46 -21
  189. package/src/html/css/style.ts +6 -1
  190. package/src/html/css/values.ts +25 -9
  191. package/src/html/index.ts +1 -0
  192. package/src/html/layout/boxes.ts +19 -9
  193. package/src/html/layout/inline.ts +23 -1
  194. package/src/html/node.ts +110 -17
  195. package/src/html/paint.ts +17 -2
  196. package/src/index.ts +98 -0
  197. package/src/maps/clip.ts +184 -0
  198. package/src/maps/gzip.ts +66 -0
  199. package/src/maps/index.ts +316 -0
  200. package/src/maps/labels.ts +442 -0
  201. package/src/maps/mvt.ts +857 -0
  202. package/src/maps/node.ts +1819 -0
  203. package/src/maps/overlay.ts +639 -0
  204. package/src/maps/paint.ts +978 -0
  205. package/src/maps/proj.ts +641 -0
  206. package/src/maps/sources.ts +425 -0
  207. package/src/maps/style.ts +309 -0
  208. package/src/maps/styles.ts +1040 -0
  209. package/src/maps/tiles.ts +676 -0
  210. package/src/maps/types.ts +260 -0
  211. package/src/reorder/hx.ts +31 -0
  212. package/src/reorder/index.ts +2223 -0
  213. package/src/reorder/model.ts +232 -0
  214. package/src/richtext/node.ts +49 -14
  215. package/src/richtext/runs.ts +115 -30
  216. package/src/tabs/index.ts +1040 -204
  217. package/src/terminal/vt/fonts.ts +128 -36
  218. package/src/terminal/vt/node.ts +105 -12
  219. package/src/terminal/vt/renderer.ts +13 -3
@@ -11,6 +11,13 @@
11
11
  // `asChild` is the one part of the surface deliberately absent: it exists to
12
12
  // merge props into somebody else's DOM element, and there is no DOM here.
13
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
+ //
14
21
  // The behaviour is core's `<Tabs>` — the widget this one supersedes: a single
15
22
  // tab stop with roving focus, arrows that wrap and skip disabled triggers,
16
23
  // Home/End, visual arrows in an RTL strip, and `activationMode="manual"` for
@@ -23,9 +30,11 @@
23
30
  // to sit on one edge, because borders alone cannot say "this side only" and a
24
31
  // box can.
25
32
  import React from 'react';
26
- import { useDirection, useTheme } from 'react-x11';
33
+ import { Icon, useDirection, useTheme } from 'react-x11';
27
34
  import { interpolate } from 'react-x11/style';
28
- import { XK_DOWN, XK_END, XK_HOME, XK_LEFT, XK_RIGHT, XK_UP, } from 'react-x11/keysyms';
35
+ import { XK_DOWN, XK_END, XK_ESCAPE, XK_HOME, XK_LEFT, XK_RETURN, XK_RIGHT, XK_UP, } from 'react-x11/keysyms';
36
+ import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
37
+ import { useDismissOnWindowBlur } from '../internal/widget.js';
29
38
  import { hx } from './hx.js';
30
39
  const h = React.createElement;
31
40
  // --- geometry ---------------------------------------------------------------
@@ -40,6 +49,32 @@ const PANEL_GAP = 8;
40
49
  /** The `enclosed` strip's chip, and an `outline` tab's shoulders — one
41
50
  * radius, so the two variants agree about how round this notebook is. */
42
51
  const CORNER = 6;
52
+ /** The rounding on everything that is a wash rather than a tab: the `subtle`
53
+ * fill, the `line` strip's hover, an `enclosed` trigger, a menu row. */
54
+ const WASH_CORNER = 4;
55
+ /** The gap the overflow menu leaves between two rows. Read twice: once to
56
+ * lay the sheet out, and once to work out how tall it comes to. */
57
+ const MENU_ROW_GAP = 1;
58
+ /** The overflow menu's sheet: a hairline where it meets the desktop, as the
59
+ * other popups out here use, and the inset that makes a row read as a pill
60
+ * on the sheet rather than a band across it. */
61
+ const SHEET_BORDER = 1;
62
+ const SHEET_PAD = 4;
63
+ /** A menu row is narrower than a tab is tall — it is a list entry, not a
64
+ * target on a strip — so it takes its own padding rather than the size's. */
65
+ const MENU_ROW_PX = 10;
66
+ const MENU_ROW_PY = 7;
67
+ /** So a menu of one short label is still a menu rather than a chip. */
68
+ const MENU_MIN_WIDTH = 140;
69
+ /**
70
+ * Between two triggers on the strip — none in `outline`, where the tabs meet
71
+ * shoulder to shoulder. Read twice: once to lay the strip out, and once by
72
+ * the overflow arithmetic, which has to add up to the same number the strip
73
+ * did or it will fit one tab too many.
74
+ */
75
+ function stripGap(variant) {
76
+ return variant === 'outline' ? 0 : 2;
77
+ }
43
78
  // Chakra's proportions, and looser than a first guess would make them: the
44
79
  // labels are cap-trimmed, so `py` is the *whole* visible gap over the caps
45
80
  // and under the baseline — padding sized for an untrimmed line box reads as
@@ -52,9 +87,7 @@ const SIZES = {
52
87
  lg: { px: 20, py: 12, step: 1 },
53
88
  };
54
89
  const LookContext = React.createContext(null);
55
- /** What a `<TabsIndicator>` written into the strip asks the selected trigger
56
- * to draw. `null` when the strip has none. */
57
- const IndicatorContext = React.createContext(null);
90
+ const StripContext = React.createContext(null);
58
91
  function useLook(part) {
59
92
  const look = React.useContext(LookContext);
60
93
  if (!look) {
@@ -123,6 +156,249 @@ function styled(base, extra) {
123
156
  return [base];
124
157
  return Array.isArray(extra) ? [base, ...extra] : [base, extra];
125
158
  }
159
+ /**
160
+ * The hover fill on a `line` strip — the wash a `subtle` trigger wears, on
161
+ * the variant whose selected mark is a rule rather than a fill.
162
+ *
163
+ * Two things about it are deliberate and neither is obvious.
164
+ *
165
+ * **It is the size a `subtle` trigger's fill is** — the label with the same
166
+ * padding round it — and it keeps that same distance again off the panel
167
+ * edge, which is what {@link railGap} adds to a `line` trigger's padding to
168
+ * make room for. The gap is not decoration: the strip's rule runs *under*
169
+ * its triggers, so a fill over the whole box would take a bite out of that
170
+ * line for exactly the width of whatever the pointer was on, and one that
171
+ * merely cleared the rule would read as a block resting on it. Which is also
172
+ * why it is a box and not a `backgroundColor` — a background fills the box
173
+ * it is on, and this must not.
174
+ *
175
+ * And the hover that raises it is **state**, not a `:hover` block. `:hover`
176
+ * lights the node under the pointer and its ancestors (react-x11's
177
+ * `_updateHover`), and this box is the label's *sibling* — so a wash that
178
+ * styled itself would go out every time the pointer crossed a letter.
179
+ */
180
+ function hoverWash(look) {
181
+ const gap = railGap(look);
182
+ return hx('box', {
183
+ key: 'wash',
184
+ style: [
185
+ {
186
+ position: 'absolute',
187
+ top: 0,
188
+ start: 0,
189
+ borderRadius: WASH_CORNER,
190
+ backgroundColor: '$surfaceHover',
191
+ },
192
+ look.vertical ? { bottom: 0, end: gap } : { bottom: gap, end: 0 },
193
+ ],
194
+ });
195
+ }
196
+ /**
197
+ * The air a `line` trigger keeps between its wash and the strip's rule, and
198
+ * therefore the padding it carries beyond the label's own on that one side.
199
+ * It is the label's padding on the axis the rule crosses, so that the wash's
200
+ * two distances — text to its edge, its edge to the line — come out the same.
201
+ *
202
+ * `0` for every other variant: they have no wash to stand off the rule, and
203
+ * an `outline` tab's fill is *supposed* to reach it.
204
+ */
205
+ function railGap(look) {
206
+ if (look.variant !== 'line')
207
+ return 0;
208
+ return look.vertical ? look.px : look.py;
209
+ }
210
+ /**
211
+ * Everything a stop on the strip looks like, per variant.
212
+ *
213
+ * Shared because the strip has two kinds of stop and they have to be
214
+ * indistinguishable: a `<TabsTrigger>`, and the overflow button, which wears
215
+ * the selected look whenever the tab that is selected is one of the ones it
216
+ * is holding. Two copies of this switch would drift on the first variant
217
+ * anybody touched.
218
+ *
219
+ * Each selected state is opaque where it has to cover the strip's rule, and
220
+ * every ink change is a `$token` so a `theme` prop above still wins.
221
+ */
222
+ function tabChrome(look, indicator, state) {
223
+ const { selected, disabled, hovered, grow } = state;
224
+ const vertical = look.vertical;
225
+ const variantStyle = [];
226
+ const hover = {};
227
+ switch (look.variant) {
228
+ case 'line':
229
+ case 'plain':
230
+ Object.assign(hover, { ':hover': { color: '$text' } });
231
+ break;
232
+ case 'subtle':
233
+ variantStyle.push({ borderRadius: WASH_CORNER });
234
+ if (selected)
235
+ variantStyle.push({
236
+ backgroundColor: over(look.ground, look.accent, 0.15),
237
+ });
238
+ else
239
+ Object.assign(hover, {
240
+ ':hover': { backgroundColor: '$surfaceHover' },
241
+ ':active': { backgroundColor: '$surfaceActive' },
242
+ });
243
+ break;
244
+ case 'enclosed':
245
+ // Every trigger carries the border so selecting one cannot change its
246
+ // size — only the selected one inks it.
247
+ variantStyle.push({
248
+ borderRadius: WASH_CORNER,
249
+ borderWidth: 1,
250
+ borderColor: 'transparent',
251
+ });
252
+ if (selected)
253
+ variantStyle.push({
254
+ backgroundColor: look.ground,
255
+ borderColor: '$border',
256
+ });
257
+ else
258
+ Object.assign(hover, { ':hover': { color: '$text' } });
259
+ break;
260
+ case 'outline':
261
+ if (selected)
262
+ variantStyle.push({
263
+ // The fill is the ground, so the tab and its panel read as one
264
+ // surface — and it is what covers the strip's rule underneath.
265
+ // Rounded so the fill stays inside the frame's shoulders, but
266
+ // deliberately **borderless**: this renderer paints a node's
267
+ // border *after* its children (`Node.paint` — children, then
268
+ // `_paintBorder`), so a border here could never be opened by a
269
+ // child laid over it. The frame below carries the stroke instead.
270
+ backgroundColor: look.ground,
271
+ borderRadius: CORNER,
272
+ });
273
+ else
274
+ Object.assign(hover, {
275
+ ':hover': { backgroundColor: '$surfaceHover' },
276
+ });
277
+ break;
278
+ }
279
+ // The `line` strip's hover — see {@link hoverWash} for why it is a box and
280
+ // why `hovered` is state rather than a `:hover` block.
281
+ const wash = look.variant === 'line' && hovered && !disabled ? hoverWash(look) : null;
282
+ // The `outline` tab's shape, from two stacked children — a shape the style
283
+ // vocabulary cannot say in one box. `borderRadius` is one number and
284
+ // requires a uniform border, so "rounded shoulders, open bottom" is drawn
285
+ // as: a *frame* (inset-0 box carrying the full rounded border) and, after
286
+ // it, a *skirt* (a strip of ground over the frame's panel edge, covering
287
+ // the border and the two corners that would curl toward the panel, and
288
+ // redrawing the straight side walls over itself). The skirt can cover the
289
+ // frame only because they are siblings — a node's own border paints over
290
+ // its children, which is why the frame is not the trigger's border.
291
+ const outlineShape = look.variant === 'outline' && selected
292
+ ? [
293
+ hx('box', {
294
+ key: 'frame',
295
+ style: {
296
+ position: 'absolute',
297
+ top: 0,
298
+ bottom: 0,
299
+ start: 0,
300
+ end: 0,
301
+ borderWidth: 1,
302
+ borderColor: '$border',
303
+ borderRadius: CORNER,
304
+ },
305
+ }),
306
+ hx('box', {
307
+ key: 'skirt',
308
+ style: [
309
+ {
310
+ position: 'absolute',
311
+ backgroundColor: look.ground,
312
+ borderColor: '$border',
313
+ },
314
+ vertical
315
+ ? {
316
+ top: 0,
317
+ bottom: 0,
318
+ end: 0,
319
+ width: CORNER + 1,
320
+ borderTopWidth: 1,
321
+ borderBottomWidth: 1,
322
+ }
323
+ : {
324
+ start: 0,
325
+ end: 0,
326
+ bottom: 0,
327
+ height: CORNER + 1,
328
+ borderStartWidth: 1,
329
+ borderEndWidth: 1,
330
+ },
331
+ ],
332
+ }),
333
+ ]
334
+ : null;
335
+ // The selected marker in the `line` variant: a 2px box riding the panel
336
+ // edge of the stop, over the strip's 1px rule.
337
+ const marker = look.variant === 'line'
338
+ ? hx('box', {
339
+ style: [
340
+ {
341
+ position: 'absolute',
342
+ backgroundColor: selected ? look.accent : 'transparent',
343
+ transition: { backgroundColor: 100 },
344
+ },
345
+ vertical
346
+ ? { top: 0, bottom: 0, end: 0, width: INDICATOR }
347
+ : { left: 0, right: 0, bottom: 0, height: INDICATOR },
348
+ ],
349
+ })
350
+ : null;
351
+ // A `<TabsIndicator>` in the strip is drawn here, by the selected stop,
352
+ // behind its label. Layout owns its geometry — see the part's own comment.
353
+ const chip = indicator && selected
354
+ ? hx('box', {
355
+ key: 'chip',
356
+ style: styled({
357
+ position: 'absolute',
358
+ top: 0,
359
+ bottom: 0,
360
+ left: 0,
361
+ right: 0,
362
+ zIndex: -1,
363
+ borderRadius: WASH_CORNER,
364
+ backgroundColor: over(look.ground, look.accent, 0.15),
365
+ }, indicator.style),
366
+ })
367
+ : null;
368
+ const ink = disabled
369
+ ? '$textMuted'
370
+ : selected
371
+ ? look.variant === 'line' || look.variant === 'subtle'
372
+ ? look.accent
373
+ : '$text'
374
+ : '$textMuted';
375
+ return {
376
+ style: {
377
+ flexDirection: 'row',
378
+ alignItems: 'center',
379
+ justifyContent: 'center',
380
+ flexShrink: 0,
381
+ ...(grow && { flexGrow: 1 }),
382
+ gap: TRIGGER_GAP,
383
+ // Logical rather than left/right, because the one side that differs is
384
+ // the panel side: a `line` trigger carries the wash's air off the rule
385
+ // as padding, so the label keeps the same room a `subtle` one gives it
386
+ // and the wash still has somewhere to stop.
387
+ paddingTop: look.py,
388
+ paddingBottom: look.py + (vertical ? 0 : railGap(look)),
389
+ paddingStart: look.px,
390
+ paddingEnd: look.px + (vertical ? railGap(look) : 0),
391
+ fontSize: look.font,
392
+ color: ink,
393
+ ...(disabled ? {} : { cursor: 'pointer' }),
394
+ transition: { backgroundColor: 100, color: 100 },
395
+ ...Object.assign({}, ...variantStyle),
396
+ ...(disabled ? {} : hover),
397
+ },
398
+ behind: [chip, wash, outlineShape],
399
+ after: marker,
400
+ };
401
+ }
126
402
  /**
127
403
  * Tabs.
128
404
  *
@@ -175,6 +451,7 @@ export function Tabs(props) {
175
451
  manual: props.activationMode === 'manual',
176
452
  fitted: props.fitted ?? false,
177
453
  justify: props.justify,
454
+ overflow: props.overflow ?? 'menu',
178
455
  accent,
179
456
  ground,
180
457
  text: String(theme.text),
@@ -195,6 +472,7 @@ export function Tabs(props) {
195
472
  props.activationMode,
196
473
  props.fitted,
197
474
  props.justify,
475
+ props.overflow,
198
476
  props.lazyMount,
199
477
  props.unmountOnExit,
200
478
  accent,
@@ -219,6 +497,479 @@ export function Tabs(props) {
219
497
  'data-testname': props['data-testname'],
220
498
  }, props.children));
221
499
  }
500
+ /** The strip's tab stop when the focus is on the overflow button rather than
501
+ * on a tab. A NUL is not a value any trigger can be given. */
502
+ const MORE = '\u0000more';
503
+ const NONE = [];
504
+ function sameValues(a, b) {
505
+ return a.length === b.length && a.every((value, i) => b[i] === value);
506
+ }
507
+ /** Every primitive child, joined — see {@link OverflowItem.key}. */
508
+ function labelKey(children) {
509
+ const parts = [];
510
+ React.Children.forEach(children, (child) => {
511
+ parts.push(typeof child === 'string' || typeof child === 'number'
512
+ ? String(child)
513
+ : '\u0000');
514
+ });
515
+ return parts.join('\u0001');
516
+ }
517
+ /**
518
+ * Which tabs do not fit, in the order they were written.
519
+ *
520
+ * The whole thing runs off what layout actually did, because nothing else
521
+ * here can answer it: a tab's width is its label in the theme's face at the
522
+ * size the theme is set to, plus whatever the app hung beside it. So the
523
+ * strip measures the triggers it has drawn and remembers what each one came
524
+ * out at, and the tabs in the menu are chosen from those remembered widths —
525
+ * they are not on the strip to be measured while they are in the menu.
526
+ *
527
+ * **Two passes, not one.** A tab whose width is not known yet (a first
528
+ * render, a tab just added, a label just changed) puts the strip back to
529
+ * showing everything for one pass, which is what gives it a width; the pass
530
+ * after that is the one that decides. That costs a frame of a strip
531
+ * overflowing its own edge — clipped, since the strip clips while the menu
532
+ * is on — and buys a fixed point: what is hidden is a pure function of the
533
+ * remembered widths and the room, so the answer does not move unless
534
+ * something really did.
535
+ *
536
+ * **When it re-runs.** After every commit, through `afterLayout` — react-x11
537
+ * lays out on a frame flush rather than in the commit, so an effect reads the
538
+ * *previous* pass's geometry. And after every layout this component did not
539
+ * cause, through the owning window's `onAnchorChange`: the strip getting
540
+ * narrower is usually the window being resized, which re-lays out without
541
+ * re-rendering anything and would otherwise leave the menu answering a width
542
+ * the strip stopped having.
543
+ */
544
+ function useOverflow(active, items, look, listRef, moreRef) {
545
+ const widths = React.useRef(new Map());
546
+ // What `abs` was in when those were taken, so the menu can ask for them
547
+ // back in the logical pixels a style length speaks.
548
+ const measured = React.useRef(1);
549
+ const [hidden, setHidden] = React.useState(NONE);
550
+ // Read and written straight, beside the state: two `onAnchorChange`
551
+ // notifications can land in one flush, and the second has to see what the
552
+ // first decided rather than the render's stale copy.
553
+ const hiddenRef = React.useRef(hidden);
554
+ hiddenRef.current = hidden;
555
+ const measure = () => {
556
+ const list = listRef.current;
557
+ const room = list?.contentBox?.().width ?? 0;
558
+ // Nothing laid out yet, or laid out inside a hidden panel (`display:
559
+ // 'none'` is out of layout, so the strip is 0 wide there). Answering
560
+ // "everything overflows" would empty the strip into the menu and flash
561
+ // it back the moment the panel is shown.
562
+ if (!list || !(room > 0))
563
+ return;
564
+ // `abs` and `contentBox()` are device pixels; a style length is logical.
565
+ // Everything below is device, so the style numbers convert once, here.
566
+ const scale = list.scale && list.scale > 0 ? list.scale : 1;
567
+ measured.current = scale;
568
+ const gap = stripGap(look.variant) * scale;
569
+ const drawn = (value) => {
570
+ const node = look.triggers.get(value)?.node;
571
+ return node && node.abs.width > 0 ? node : null;
572
+ };
573
+ // A `fitted` strip with nothing hidden has already grown its triggers to
574
+ // fill it, so those widths are the strip shared out rather than what a
575
+ // tab needs, and writing them down would be writing down the answer to
576
+ // the wrong question. The exception is the pass that matters: a `fitted`
577
+ // strip that is *overflowing* had no free space to grow into, so the
578
+ // widths are honest — and that is exactly the pass the menu appears on.
579
+ let live = 0;
580
+ let count = 0;
581
+ for (const item of items) {
582
+ const node = drawn(item.value);
583
+ if (!node)
584
+ continue;
585
+ live += node.abs.width;
586
+ count += 1;
587
+ }
588
+ live += Math.max(0, count - 1) * gap;
589
+ if (!look.fitted || hiddenRef.current.length > 0 || live > room) {
590
+ for (const item of items) {
591
+ const node = drawn(item.value);
592
+ if (!node)
593
+ continue;
594
+ widths.current.set(item.value, {
595
+ key: item.key,
596
+ width: node.abs.width,
597
+ height: node.abs.height,
598
+ });
599
+ }
600
+ }
601
+ // A tab that has left the strip has no width worth keeping — and this is
602
+ // the only thing that ever empties the map.
603
+ const present = new Set(items.map((item) => item.value));
604
+ for (const value of [...widths.current.keys()]) {
605
+ if (!present.has(value))
606
+ widths.current.delete(value);
607
+ }
608
+ const known = (item) => {
609
+ const seen = widths.current.get(item.value);
610
+ return seen && seen.key === item.key ? seen.width : null;
611
+ };
612
+ let natural = Math.max(0, items.length - 1) * gap;
613
+ let unmeasured = false;
614
+ for (const item of items) {
615
+ const width = known(item);
616
+ if (width === null)
617
+ unmeasured = true;
618
+ else
619
+ natural += width;
620
+ }
621
+ // The measuring pass — everything on the strip, nothing decided yet —
622
+ // is `NONE`, which is also the answer when it all fits.
623
+ let next = NONE;
624
+ if (!unmeasured && natural > room) {
625
+ // The button is measured once it exists; before that it is guessed at
626
+ // its own padding plus a chevron, and the pass after it mounts
627
+ // corrects the guess.
628
+ const more = moreRef.current?.abs.width || (look.px * 2 + look.font) * scale;
629
+ const budget = room - more - gap;
630
+ const shown = new Set();
631
+ let used = 0;
632
+ const take = (value, width) => {
633
+ used += width + (shown.size ? gap : 0);
634
+ shown.add(value);
635
+ };
636
+ // Straight down the strip, and the first tab that does not fit ends
637
+ // it: which tabs are on the strip is a question about room, and
638
+ // nothing else. The selected tab is not pulled out of the menu to keep
639
+ // its place — the button wears the selected look instead, so the strip
640
+ // does not reshuffle itself every time a tab is picked out of it.
641
+ for (const item of items) {
642
+ if (shown.has(item.value))
643
+ continue;
644
+ const width = known(item) ?? 0;
645
+ if (used + width + (shown.size ? gap : 0) > budget)
646
+ break;
647
+ take(item.value, width);
648
+ }
649
+ next = items
650
+ .filter((item) => !shown.has(item.value))
651
+ .map((item) => item.value);
652
+ }
653
+ if (sameValues(next, hiddenRef.current))
654
+ return;
655
+ hiddenRef.current = next;
656
+ setHidden(next);
657
+ };
658
+ // Re-read through a ref so the subscriptions below run this render's
659
+ // closure without being torn down and rebuilt for every one of them.
660
+ const measureRef = React.useRef(measure);
661
+ measureRef.current = measure;
662
+ // Deliberately no dependency array: any commit can move a tab's width.
663
+ React.useEffect(() => {
664
+ if (!active)
665
+ return undefined;
666
+ const tick = afterLayout(() => measureRef.current());
667
+ return () => cancelAfterLayout(tick);
668
+ });
669
+ React.useEffect(() => {
670
+ if (!active)
671
+ return undefined;
672
+ const root = listRef.current?.root;
673
+ if (!root?.onAnchorChange)
674
+ return undefined;
675
+ return root.onAnchorChange(() => measureRef.current());
676
+ }, [active, listRef]);
677
+ // `overflow="clip"` turned on mid-life, or the strip turned vertical: hand
678
+ // the tabs back rather than leaving them in a menu nothing draws.
679
+ React.useEffect(() => {
680
+ if (!active && hiddenRef.current.length > 0) {
681
+ hiddenRef.current = NONE;
682
+ setHidden(NONE);
683
+ }
684
+ }, [active]);
685
+ const sizeOf = React.useCallback((value, key) => {
686
+ const seen = widths.current.get(value);
687
+ if (!seen || seen.key !== key)
688
+ return null;
689
+ const scale = measured.current || 1;
690
+ return { width: seen.width / scale, height: seen.height / scale };
691
+ }, []);
692
+ return { hidden: active ? hidden : NONE, sizeOf };
693
+ }
694
+ /**
695
+ * The overflow menu's own size, worked out from the measurements the strip
696
+ * already took rather than left to the popup.
697
+ *
698
+ * A `<popup>` can size itself from its content, and that is normally the
699
+ * right answer — but its placement is then a function of a number nothing
700
+ * outside it can see, which is a bad thing for the one popup here whose
701
+ * anchor moves every time the strip re-fits. A menu row is the same content
702
+ * as the tab it stands for — same label, same face, same gap, same glyph
703
+ * beside it — laid out with the row's padding instead of the tab's, so the
704
+ * strip's own measurement answers it exactly.
705
+ *
706
+ * `null` when a tab in the menu has not been measured, which is the pass
707
+ * where nothing is decided anyway; the popup sizes itself for that frame.
708
+ */
709
+ function menuSheet(look, items, sizeOf) {
710
+ if (items.length === 0)
711
+ return null;
712
+ let content = 0;
713
+ let rows = 0;
714
+ for (const item of items) {
715
+ const size = sizeOf(item.value, item.key);
716
+ if (!size)
717
+ return null;
718
+ content = Math.max(content, size.width - look.px * 2 + MENU_ROW_PX * 2);
719
+ // …and off the height, the air a `line` trigger keeps under its wash on
720
+ // top of its padding: a menu row has no rule to stand off.
721
+ rows += size.height - look.py * 2 - railGap(look) + MENU_ROW_PY * 2;
722
+ }
723
+ const frame = (SHEET_PAD + SHEET_BORDER) * 2;
724
+ return {
725
+ width: Math.ceil(Math.max(content + frame, MENU_MIN_WIDTH)),
726
+ height: Math.ceil(rows + (items.length - 1) * MENU_ROW_GAP + frame),
727
+ };
728
+ }
729
+ /**
730
+ * The button at the end of a strip that ran out of room, and the menu it
731
+ * drops.
732
+ *
733
+ * **It is a stop on the strip like any other**, drawn from the same
734
+ * {@link tabChrome} the triggers are — so when the selected tab is one of
735
+ * the ones it is holding, the button wears the selected look (the accent
736
+ * marker under a `line` strip, the chip on a `subtle` one) and the menu
737
+ * marks the row. That is what a tab picked out of the menu does instead of
738
+ * displacing a tab that fitted: the strip keeps the tabs it has room for,
739
+ * and the button says the selection is behind it. It also takes the strip's
740
+ * tab stop while it holds the selection, since the trigger that would
741
+ * normally have it is not on the strip to hold anything.
742
+ *
743
+ * The menu is a `<popup>` anchored to the button and sized from its own
744
+ * content — override-redirect, grabbing the pointer, dismissed by a press
745
+ * anywhere else. Which means it never takes the focus, so **the button keeps
746
+ * the keyboard** and hands the menu its keys, the way `<DatePicker>`'s
747
+ * trigger does: Down/Up open it and walk it, Enter and Space commit, Escape
748
+ * shuts it. A press elsewhere in the window blurs the button and that shuts
749
+ * it too, and `useDismissOnWindowBlur` covers the one case a blur cannot —
750
+ * the *window* going to the background, which leaves the button focused.
751
+ */
752
+ function TabsMore(props) {
753
+ const { items, look, moreRef, sheet } = props;
754
+ const strip = React.useContext(StripContext);
755
+ const theme = useTheme();
756
+ const [open, setOpen] = React.useState(false);
757
+ // -1 is "nothing yet", which is what a menu dropped with the mouse opens
758
+ // on: a row lit before the pointer or the arrows have said anything is a
759
+ // row the next Enter would commit by accident.
760
+ const [active, setActive] = React.useState(-1);
761
+ // The button wears whatever the tabs beside it wear — including the reason
762
+ // a `line` strip's fill has to be a box ({@link hoverWash}), which is why
763
+ // this is tracked rather than declared.
764
+ const [hovered, setHovered] = React.useState(false);
765
+ const lit = hovered || open;
766
+ /** Where the selection is, when it is behind this button. */
767
+ const chosen = items.findIndex((item) => item.value === look.selected);
768
+ const holds = chosen >= 0;
769
+ const close = () => setOpen(false);
770
+ useDismissOnWindowBlur(moreRef, open, close);
771
+ // The menu is drawn from the tabs the strip could not fit, and that set
772
+ // moves under it — a resize while it is open can take one back. Keeping
773
+ // the cursor in range is cheaper than closing on every re-fit.
774
+ const at = items.length ? Math.min(active, items.length - 1) : -1;
775
+ const choose = (value) => {
776
+ close();
777
+ look.select(value);
778
+ // The focus stays here: the tab that was picked is still in this menu,
779
+ // and this button is the stop on the strip that now stands for it.
780
+ };
781
+ const step = (delta) => {
782
+ const n = items.length;
783
+ if (n === 0)
784
+ return -1;
785
+ // From "nothing yet", Down means the first row and Up means the last —
786
+ // which is what starting one step outside either end comes to.
787
+ const from = at < 0 ? (delta > 0 ? -1 : 0) : at;
788
+ for (let i = 1; i <= n; i++) {
789
+ const index = (((from + delta * i) % n) + n) % n;
790
+ if (!items[index]?.disabled)
791
+ return index;
792
+ }
793
+ return at;
794
+ };
795
+ const commit = () => {
796
+ const item = items[at];
797
+ if (item && !item.disabled)
798
+ choose(item.value);
799
+ };
800
+ const onKeyDown = (ev) => {
801
+ if (ev.keysym === XK_ESCAPE) {
802
+ if (!open)
803
+ return;
804
+ close();
805
+ ev.preventDefault();
806
+ ev.stopPropagation();
807
+ return;
808
+ }
809
+ if (!open) {
810
+ if (ev.keysym !== XK_DOWN && ev.keysym !== XK_UP)
811
+ return;
812
+ // Opened from the keyboard, so it opens on a row: the selected one if
813
+ // the selection is in here, and otherwise the end the key came from.
814
+ setActive(holds ? chosen : ev.keysym === XK_UP ? items.length - 1 : 0);
815
+ setOpen(true);
816
+ ev.preventDefault();
817
+ ev.stopPropagation();
818
+ return;
819
+ }
820
+ // An open menu owns the keyboard: the strip's own arrow walk is one
821
+ // bubble away, and a Down that both moved the menu's cursor and roved
822
+ // the focus off the button would close the menu it had just moved.
823
+ if (ev.keysym === XK_DOWN)
824
+ setActive(step(1));
825
+ else if (ev.keysym === XK_UP)
826
+ setActive(step(-1));
827
+ else if (ev.keysym === XK_RETURN || ev.codepoint === 32)
828
+ commit();
829
+ else
830
+ return;
831
+ ev.preventDefault();
832
+ ev.stopPropagation();
833
+ };
834
+ const row = (item, index) => hx('box', {
835
+ key: item.value,
836
+ // A menu of tabs is a set of mutually exclusive choices, which is
837
+ // what this role says — and it is the only place a screen reader
838
+ // hears about a tab that is not on the strip to carry `role="tab"`.
839
+ role: 'menuitemradio',
840
+ 'aria-checked': index === chosen,
841
+ disabled: item.disabled || undefined,
842
+ onMouseEnter: () => {
843
+ if (!item.disabled)
844
+ setActive(index);
845
+ },
846
+ onClick: () => {
847
+ if (!item.disabled)
848
+ choose(item.value);
849
+ },
850
+ style: {
851
+ flexDirection: 'row',
852
+ alignItems: 'center',
853
+ gap: TRIGGER_GAP,
854
+ paddingLeft: MENU_ROW_PX,
855
+ paddingRight: MENU_ROW_PX,
856
+ paddingTop: MENU_ROW_PY,
857
+ paddingBottom: MENU_ROW_PY,
858
+ borderRadius: WASH_CORNER,
859
+ fontSize: look.font,
860
+ color: item.disabled ? '$textMuted' : '$text',
861
+ ...(item.disabled
862
+ ? {}
863
+ : {
864
+ cursor: 'pointer',
865
+ // The lit row is state rather than a `:hover` block because
866
+ // the arrows move it too, and a menu with two lit rows —
867
+ // the pointer's and the keyboard's — is worse than one.
868
+ ...(index === at && { backgroundColor: '$surfaceHover' }),
869
+ }),
870
+ },
871
+ },
872
+ // The selected row's marker: the strip's own accent bar, turned to
873
+ // stand along a row instead of under a tab. It is a mark and not a
874
+ // fill so it cannot be confused with the row the cursor is on — both
875
+ // can be true of the same row.
876
+ index === chosen
877
+ ? hx('box', {
878
+ key: 'mark',
879
+ style: {
880
+ position: 'absolute',
881
+ start: 0,
882
+ top: MENU_ROW_PY / 2,
883
+ bottom: MENU_ROW_PY / 2,
884
+ width: INDICATOR,
885
+ borderRadius: INDICATOR / 2,
886
+ backgroundColor: look.accent,
887
+ },
888
+ })
889
+ : null, withText(item.children, CAP_TRIM));
890
+ const chrome = tabChrome(look, strip?.indicator ?? null, {
891
+ selected: holds,
892
+ disabled: false,
893
+ hovered: lit,
894
+ // Never: the button only exists while something is hidden, and a
895
+ // `fitted` strip has stopped sharing its width out by then.
896
+ grow: false,
897
+ });
898
+ return hx('box', {
899
+ ref: moreRef,
900
+ role: 'button',
901
+ 'aria-haspopup': 'menu',
902
+ 'aria-expanded': open,
903
+ 'aria-label': `${items.length} more tab${items.length === 1 ? '' : 's'}`,
904
+ focusable: true,
905
+ // The strip is one tab stop. A tab holds it — unless the selected tab
906
+ // is one of the ones behind this button, and then this holds it.
907
+ tabIndex: holds ? 0 : -1,
908
+ // On the press, not the release: a control whose whole purpose is to
909
+ // be looked at has nothing to gain from waiting out the click.
910
+ onMouseDown: () => {
911
+ setActive(-1);
912
+ setOpen((was) => !was);
913
+ },
914
+ onMouseEnter: () => setHovered(true),
915
+ onMouseLeave: () => setHovered(false),
916
+ onFocus: () => look.setFocused(MORE),
917
+ onBlur: () => {
918
+ look.setFocused(null);
919
+ close();
920
+ },
921
+ onKeyDown,
922
+ style: [
923
+ chrome.style,
924
+ // An open button reads as engaged in every variant, not only the one
925
+ // whose wash is a box — and not when it is already wearing the
926
+ // selected look, which outranks a hover.
927
+ lit && !holds
928
+ ? look.variant === 'line'
929
+ ? { color: '$text' }
930
+ : { color: '$text', backgroundColor: '$surfaceHover' }
931
+ : {},
932
+ ],
933
+ 'data-testname': 'tabs-more',
934
+ }, chrome.behind, h(Icon, { name: 'chevronDown', size: look.font - 4 }), chrome.after, open &&
935
+ items.length > 0 &&
936
+ hx('popup', {
937
+ theme,
938
+ // Sized here rather than from its own content, so where it lands is
939
+ // arithmetic this component can see — {@link menuSheet}.
940
+ ...(sheet ?? {}),
941
+ anchor: {
942
+ to: moreRef,
943
+ placement: 'bottom',
944
+ // The menu hangs off the end of the strip because the button
945
+ // does, and `end` mirrors on its own in an RTL subtree.
946
+ align: 'end',
947
+ },
948
+ grab: true,
949
+ onDismiss: close,
950
+ // ARGB where the display has it, so the corners the sheet gives up
951
+ // are the desktop rather than a colour — the same call
952
+ // `<DatePicker>`'s sheet makes, and the same reason the window
953
+ // paints nothing itself when it can be seen through.
954
+ transparent: true,
955
+ style: {
956
+ backgroundColor: theme.background,
957
+ '@supports transparency': { backgroundColor: 'transparent' },
958
+ },
959
+ }, hx('box', {
960
+ role: 'menu',
961
+ style: {
962
+ flexGrow: 1,
963
+ minWidth: MENU_MIN_WIDTH,
964
+ padding: SHEET_PAD,
965
+ gap: MENU_ROW_GAP,
966
+ borderWidth: SHEET_BORDER,
967
+ borderColor: '$border',
968
+ backgroundColor: theme.background,
969
+ '@supports transparency': { borderRadius: theme.radiusPopup },
970
+ },
971
+ }, items.map(row))));
972
+ }
222
973
  /**
223
974
  * The strip of triggers — a single tab stop, walked with the arrows.
224
975
  *
@@ -229,27 +980,66 @@ export function Tabs(props) {
229
980
  * to the ends. In `manual` mode focus moves without selecting, and Enter or
230
981
  * Space commits — those keys are not handled here, because core makes them
231
982
  * the click the trigger's own `onClick` already is.
983
+ *
984
+ * A horizontal strip with more tabs than room keeps the ones that fit and
985
+ * drops the rest into a menu at its end (`overflow`, on the root). The
986
+ * menu's button is the last stop on the same walk.
232
987
  */
233
988
  export function TabsList(props) {
234
989
  const look = useLook('TabsList');
235
990
  const rtl = useDirection() === 'rtl';
236
991
  const { vertical } = look;
992
+ const listRef = React.useRef(null);
993
+ const moreRef = React.useRef(null);
237
994
  // A `<TabsIndicator>` written into the strip, Chakra-style, is read here
238
995
  // and handed to the triggers — the selected one draws it. It has to be a
239
996
  // direct child for this to see it; anything else in the strip renders
240
997
  // where it was written.
998
+ //
999
+ // The triggers come off the same walk, for the overflow menu: it draws the
1000
+ // children the app wrote on the trigger, so a tab that moves into the menu
1001
+ // keeps its label without the app naming it twice. Anything in the strip
1002
+ // that is *not* a trigger stays on the strip and is not accounted for —
1003
+ // the arithmetic is about tabs.
1004
+ const children = React.Children.toArray(props.children);
241
1005
  let indicator = null;
242
- for (const child of React.Children.toArray(props.children)) {
243
- if (React.isValidElement(child) && child.type === TabsIndicator) {
1006
+ const items = [];
1007
+ for (const child of children) {
1008
+ if (!React.isValidElement(child))
1009
+ continue;
1010
+ if (child.type === TabsIndicator) {
244
1011
  indicator = child.props;
245
1012
  }
1013
+ else if (child.type === TabsTrigger) {
1014
+ const trigger = child.props;
1015
+ items.push({
1016
+ value: trigger.value,
1017
+ disabled: trigger.disabled ?? false,
1018
+ key: labelKey(trigger.children),
1019
+ children: trigger.children,
1020
+ });
1021
+ }
246
1022
  }
1023
+ // One tab cannot overflow into a menu that would take more room than it
1024
+ // does, so the machinery does not start until there are two.
1025
+ const { hidden, sizeOf } = useOverflow(look.overflow === 'menu' && !vertical && items.length > 1, items, look, listRef, moreRef);
1026
+ const hiddenSet = hidden.length > 0 ? new Set(hidden) : null;
1027
+ const overflowed = hiddenSet
1028
+ ? items.filter((item) => hiddenSet.has(item.value))
1029
+ : [];
247
1030
  const onKeyDown = (ev) => {
248
1031
  // Where layout put each trigger is the one ordering that cannot drift
249
1032
  // from what the user sees — mount order can, after a remove and re-add.
250
- const entries = [...look.triggers.entries()]
251
- .map(([value, entry]) => ({ value, ...entry }))
252
- .sort((a, b) => vertical ? a.node.abs.y - b.node.abs.y : a.node.abs.x - b.node.abs.x);
1033
+ // The overflow button is a stop on the same walk and sorts in with the
1034
+ // rest, which is what puts it at the end without it being told.
1035
+ const stops = [...look.triggers.entries()].map(([value, entry]) => ({
1036
+ value,
1037
+ ...entry,
1038
+ }));
1039
+ if (moreRef.current) {
1040
+ stops.push({ value: MORE, node: moreRef.current, disabled: false });
1041
+ }
1042
+ const entries = stops.sort((a, b) => vertical ? a.node.abs.y - b.node.abs.y : a.node.abs.x - b.node.abs.x);
253
1043
  // Visual order back to logical: an RTL strip reads right to left. A
254
1044
  // vertical strip never mirrors — Up is Up.
255
1045
  const swap = !vertical && rtl;
@@ -271,7 +1061,9 @@ export function TabsList(props) {
271
1061
  if (!entry)
272
1062
  return;
273
1063
  entry.node.focus();
274
- if (!look.manual)
1064
+ // The overflow button selects nothing: it is the way to the tabs that
1065
+ // did not fit, not one of them.
1066
+ if (!look.manual && entry.value !== MORE)
275
1067
  look.select(entry.value);
276
1068
  };
277
1069
  const back = vertical ? XK_UP : swap ? XK_RIGHT : XK_LEFT;
@@ -317,8 +1109,16 @@ export function TabsList(props) {
317
1109
  padding: 3,
318
1110
  gap: 2,
319
1111
  }
320
- : { gap: look.variant === 'outline' ? 0 : 2 };
321
- return h(IndicatorContext.Provider, { value: indicator }, hx('box', {
1112
+ : { gap: stripGap(look.variant) };
1113
+ return h(StripContext.Provider, {
1114
+ value: {
1115
+ indicator,
1116
+ // A `fitted` strip stops growing its tabs while any of them is in
1117
+ // the menu — see {@link TabsStrip}.
1118
+ grow: look.fitted && hidden.length === 0,
1119
+ },
1120
+ }, hx('box', {
1121
+ ref: listRef,
322
1122
  role: 'tablist',
323
1123
  'aria-orientation': look.vertical ? 'vertical' : 'horizontal',
324
1124
  onKeyDown,
@@ -333,10 +1133,28 @@ export function TabsList(props) {
333
1133
  ? 'flex-end'
334
1134
  : 'center',
335
1135
  }),
1136
+ // A pass that has not decided yet draws every tab at its natural
1137
+ // width, which is wider than the strip by definition — clipped
1138
+ // rather than spilling over the panel for the frame it takes.
1139
+ ...(look.overflow === 'menu' && !vertical
1140
+ ? { overflow: 'hidden' }
1141
+ : {}),
336
1142
  ...chrome,
337
1143
  }, props.style),
338
1144
  'data-testname': props['data-testname'],
339
- }, rule, props.children));
1145
+ }, rule, hiddenSet
1146
+ ? children.filter((child) => !(React.isValidElement(child) &&
1147
+ child.type === TabsTrigger &&
1148
+ hiddenSet.has(child.props.value)))
1149
+ : children, overflowed.length > 0
1150
+ ? h(TabsMore, {
1151
+ key: 'tabs-more',
1152
+ items: overflowed,
1153
+ look,
1154
+ moreRef,
1155
+ sheet: menuSheet(look, overflowed, sizeOf),
1156
+ })
1157
+ : null));
340
1158
  }
341
1159
  /**
342
1160
  * One tab. Prose and glyphs alike are legal children — a string is wrapped
@@ -345,10 +1163,13 @@ export function TabsList(props) {
345
1163
  */
346
1164
  export function TabsTrigger(props) {
347
1165
  const look = useLook('TabsTrigger');
348
- const indicator = React.useContext(IndicatorContext);
1166
+ const strip = React.useContext(StripContext);
1167
+ const indicator = strip?.indicator ?? null;
349
1168
  const { value, disabled = false } = props;
350
1169
  const selected = look.selected === value;
351
- const vertical = look.vertical;
1170
+ // Tracked rather than declared, because the wash is a box of its own —
1171
+ // {@link hoverWash}.
1172
+ const [hovered, setHovered] = React.useState(false);
352
1173
  // Registration is the ref callback: a new closure each render, so React
353
1174
  // re-runs it and a `disabled` that changed is re-registered. Order in the
354
1175
  // map churns with that, which is why the keyboard sorts by position
@@ -359,157 +1180,14 @@ export function TabsTrigger(props) {
359
1180
  else
360
1181
  look.triggers.delete(value);
361
1182
  };
362
- // What the trigger looks like, per variant. Each selected state is opaque
363
- // where it has to cover the strip's rule, and every ink change is a
364
- // `$token` so a `theme` prop above still wins.
365
- const variantStyle = [];
366
- const hover = {};
367
- switch (look.variant) {
368
- case 'line':
369
- case 'plain':
370
- Object.assign(hover, { ':hover': { color: '$text' } });
371
- break;
372
- case 'subtle':
373
- variantStyle.push({ borderRadius: 4 });
374
- if (selected)
375
- variantStyle.push({
376
- backgroundColor: over(look.ground, look.accent, 0.15),
377
- });
378
- else
379
- Object.assign(hover, {
380
- ':hover': { backgroundColor: '$surfaceHover' },
381
- ':active': { backgroundColor: '$surfaceActive' },
382
- });
383
- break;
384
- case 'enclosed':
385
- // Every trigger carries the border so selecting one cannot change its
386
- // size — only the selected one inks it.
387
- variantStyle.push({
388
- borderRadius: 4,
389
- borderWidth: 1,
390
- borderColor: 'transparent',
391
- });
392
- if (selected)
393
- variantStyle.push({
394
- backgroundColor: look.ground,
395
- borderColor: '$border',
396
- });
397
- else
398
- Object.assign(hover, { ':hover': { color: '$text' } });
399
- break;
400
- case 'outline':
401
- if (selected)
402
- variantStyle.push({
403
- // The fill is the ground, so the tab and its panel read as one
404
- // surface — and it is what covers the strip's rule underneath.
405
- // Rounded so the fill stays inside the frame's shoulders, but
406
- // deliberately **borderless**: this renderer paints a node's
407
- // border *after* its children (`Node.paint` — children, then
408
- // `_paintBorder`), so a border here could never be opened by a
409
- // child laid over it. The frame below carries the stroke instead.
410
- backgroundColor: look.ground,
411
- borderRadius: CORNER,
412
- });
413
- else
414
- Object.assign(hover, {
415
- ':hover': { backgroundColor: '$surfaceHover' },
416
- });
417
- break;
418
- }
419
- // The `outline` tab's shape, from two stacked children — a shape the style
420
- // vocabulary cannot say in one box. `borderRadius` is one number and
421
- // requires a uniform border, so "rounded shoulders, open bottom" is drawn
422
- // as: a *frame* (inset-0 box carrying the full rounded border) and, after
423
- // it, a *skirt* (a strip of ground over the frame's panel edge, covering
424
- // the border and the two corners that would curl toward the panel, and
425
- // redrawing the straight side walls over itself). The skirt can cover the
426
- // frame only because they are siblings — a node's own border paints over
427
- // its children, which is why the frame is not the trigger's border.
428
- // Both are written before the label, so a descender that dips below the
429
- // baseline paints over them rather than being cut.
430
- const outlineShape = look.variant === 'outline' && selected
431
- ? [
432
- hx('box', {
433
- key: 'frame',
434
- style: {
435
- position: 'absolute',
436
- top: 0,
437
- bottom: 0,
438
- start: 0,
439
- end: 0,
440
- borderWidth: 1,
441
- borderColor: '$border',
442
- borderRadius: CORNER,
443
- },
444
- }),
445
- hx('box', {
446
- key: 'skirt',
447
- style: [
448
- {
449
- position: 'absolute',
450
- backgroundColor: look.ground,
451
- borderColor: '$border',
452
- },
453
- vertical
454
- ? {
455
- top: 0,
456
- bottom: 0,
457
- end: 0,
458
- width: CORNER + 1,
459
- borderTopWidth: 1,
460
- borderBottomWidth: 1,
461
- }
462
- : {
463
- start: 0,
464
- end: 0,
465
- bottom: 0,
466
- height: CORNER + 1,
467
- borderStartWidth: 1,
468
- borderEndWidth: 1,
469
- },
470
- ],
471
- }),
472
- ]
473
- : null;
474
- // The selected marker in the `line` variant: a 2px box riding the panel
475
- // edge of the trigger, over the strip's 1px rule.
476
- const marker = look.variant === 'line'
477
- ? hx('box', {
478
- style: [
479
- {
480
- position: 'absolute',
481
- backgroundColor: selected ? look.accent : 'transparent',
482
- transition: { backgroundColor: 100 },
483
- },
484
- vertical
485
- ? { top: 0, bottom: 0, end: 0, width: INDICATOR }
486
- : { left: 0, right: 0, bottom: 0, height: INDICATOR },
487
- ],
488
- })
489
- : null;
490
- // A `<TabsIndicator>` in the strip is drawn here, by the selected trigger,
491
- // behind its label. Layout owns its geometry — see the part's own comment.
492
- const chip = indicator && selected
493
- ? hx('box', {
494
- style: styled({
495
- position: 'absolute',
496
- top: 0,
497
- bottom: 0,
498
- left: 0,
499
- right: 0,
500
- zIndex: -1,
501
- borderRadius: 4,
502
- backgroundColor: over(look.ground, look.accent, 0.15),
503
- }, indicator.style),
504
- })
505
- : null;
506
- const ink = disabled
507
- ? '$textMuted'
508
- : selected
509
- ? look.variant === 'line' || look.variant === 'subtle'
510
- ? look.accent
511
- : '$text'
512
- : '$textMuted';
1183
+ // Everything it looks like, shared with the overflow button beside it —
1184
+ // {@link tabChrome}.
1185
+ const chrome = tabChrome(look, indicator, {
1186
+ selected,
1187
+ disabled,
1188
+ hovered,
1189
+ grow: strip ? strip.grow : look.fitted,
1190
+ });
513
1191
  return hx('box', {
514
1192
  role: 'tab',
515
1193
  'aria-selected': selected,
@@ -522,27 +1200,12 @@ export function TabsTrigger(props) {
522
1200
  disabled,
523
1201
  onFocus: () => look.setFocused(value),
524
1202
  onBlur: () => look.setFocused(null),
1203
+ onMouseEnter: () => setHovered(true),
1204
+ onMouseLeave: () => setHovered(false),
525
1205
  onClick: () => !disabled && look.select(value),
526
- style: styled({
527
- flexDirection: 'row',
528
- alignItems: 'center',
529
- justifyContent: 'center',
530
- flexShrink: 0,
531
- ...(look.fitted && { flexGrow: 1 }),
532
- gap: TRIGGER_GAP,
533
- paddingTop: look.py,
534
- paddingBottom: look.py,
535
- paddingLeft: look.px,
536
- paddingRight: look.px,
537
- fontSize: look.font,
538
- color: ink,
539
- ...(disabled ? {} : { cursor: 'pointer' }),
540
- transition: { backgroundColor: 100, color: 100 },
541
- ...Object.assign({}, ...variantStyle),
542
- ...(disabled ? {} : hover),
543
- }, props.style),
1206
+ style: styled(chrome.style, props.style),
544
1207
  'data-testname': props['data-testname'],
545
- }, chip, outlineShape, withText(props.children, CAP_TRIM), marker);
1208
+ }, chrome.behind, withText(props.children, CAP_TRIM), chrome.after);
546
1209
  }
547
1210
  /**
548
1211
  * A marker that follows the selected trigger — Chakra's `Tabs.Indicator`,