@react-x11/components 0.4.0 → 0.6.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.
- package/README.md +143 -8
- package/dist/charts/components.d.ts +3 -1
- package/dist/charts/components.d.ts.map +1 -1
- package/dist/charts/components.js +8 -4
- package/dist/charts/components.js.map +1 -1
- package/dist/charts/node.d.ts +13 -6
- package/dist/charts/node.d.ts.map +1 -1
- package/dist/charts/node.js +75 -24
- package/dist/charts/node.js.map +1 -1
- package/dist/charts/render.d.ts +4 -0
- package/dist/charts/render.d.ts.map +1 -1
- package/dist/charts/render.js +11 -6
- package/dist/charts/render.js.map +1 -1
- package/dist/code-editor/node.d.ts +47 -16
- package/dist/code-editor/node.d.ts.map +1 -1
- package/dist/code-editor/node.js +121 -51
- package/dist/code-editor/node.js.map +1 -1
- package/dist/flow/draw.d.ts +19 -1
- package/dist/flow/draw.d.ts.map +1 -1
- package/dist/flow/draw.js +64 -35
- package/dist/flow/draw.js.map +1 -1
- package/dist/flow/index.d.ts.map +1 -1
- package/dist/flow/index.js +24 -8
- package/dist/flow/index.js.map +1 -1
- package/dist/flow/model.d.ts +4 -2
- package/dist/flow/model.d.ts.map +1 -1
- package/dist/flow/model.js +4 -2
- package/dist/flow/model.js.map +1 -1
- package/dist/flow/node.d.ts +40 -9
- package/dist/flow/node.d.ts.map +1 -1
- package/dist/flow/node.js +142 -53
- package/dist/flow/node.js.map +1 -1
- package/dist/flow/types.d.ts +46 -21
- package/dist/flow/types.d.ts.map +1 -1
- package/dist/formula/index.d.ts +4 -2
- package/dist/formula/index.d.ts.map +1 -1
- package/dist/formula/index.js.map +1 -1
- package/dist/formula/node.d.ts +5 -1
- package/dist/formula/node.d.ts.map +1 -1
- package/dist/formula/node.js +19 -3
- package/dist/formula/node.js.map +1 -1
- package/dist/html/controls.d.ts +4 -1
- package/dist/html/controls.d.ts.map +1 -1
- package/dist/html/controls.js.map +1 -1
- package/dist/html/css/cascade.d.ts +11 -5
- package/dist/html/css/cascade.d.ts.map +1 -1
- package/dist/html/css/cascade.js +18 -8
- package/dist/html/css/cascade.js.map +1 -1
- package/dist/html/css/parse.d.ts +15 -10
- package/dist/html/css/parse.d.ts.map +1 -1
- package/dist/html/css/parse.js +41 -16
- package/dist/html/css/parse.js.map +1 -1
- package/dist/html/css/style.d.ts +3 -0
- package/dist/html/css/style.d.ts.map +1 -1
- package/dist/html/css/style.js +3 -1
- package/dist/html/css/style.js.map +1 -1
- package/dist/html/css/values.d.ts +14 -1
- package/dist/html/css/values.d.ts.map +1 -1
- package/dist/html/css/values.js +11 -8
- package/dist/html/css/values.js.map +1 -1
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +1 -0
- package/dist/html/index.js.map +1 -1
- package/dist/html/layout/boxes.d.ts +12 -5
- package/dist/html/layout/boxes.d.ts.map +1 -1
- package/dist/html/layout/boxes.js +7 -4
- package/dist/html/layout/boxes.js.map +1 -1
- package/dist/html/layout/inline.d.ts.map +1 -1
- package/dist/html/layout/inline.js +22 -1
- package/dist/html/layout/inline.js.map +1 -1
- package/dist/html/node.d.ts +22 -8
- package/dist/html/node.d.ts.map +1 -1
- package/dist/html/node.js +97 -15
- package/dist/html/node.js.map +1 -1
- package/dist/html/paint.d.ts +3 -0
- package/dist/html/paint.d.ts.map +1 -1
- package/dist/html/paint.js +2 -2
- package/dist/html/paint.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -1
- package/dist/maps/clip.d.ts +43 -0
- package/dist/maps/clip.d.ts.map +1 -0
- package/dist/maps/clip.js +171 -0
- package/dist/maps/clip.js.map +1 -0
- package/dist/maps/gzip.d.ts +15 -0
- package/dist/maps/gzip.d.ts.map +1 -0
- package/dist/maps/gzip.js +56 -0
- package/dist/maps/gzip.js.map +1 -0
- package/dist/maps/index.d.ts +55 -0
- package/dist/maps/index.d.ts.map +1 -0
- package/dist/maps/index.js +141 -0
- package/dist/maps/index.js.map +1 -0
- package/dist/maps/labels.d.ts +123 -0
- package/dist/maps/labels.d.ts.map +1 -0
- package/dist/maps/labels.js +323 -0
- package/dist/maps/labels.js.map +1 -0
- package/dist/maps/mvt.d.ts +244 -0
- package/dist/maps/mvt.d.ts.map +1 -0
- package/dist/maps/mvt.js +820 -0
- package/dist/maps/mvt.js.map +1 -0
- package/dist/maps/node.d.ts +278 -0
- package/dist/maps/node.d.ts.map +1 -0
- package/dist/maps/node.js +1459 -0
- package/dist/maps/node.js.map +1 -0
- package/dist/maps/overlay.d.ts +157 -0
- package/dist/maps/overlay.d.ts.map +1 -0
- package/dist/maps/overlay.js +472 -0
- package/dist/maps/overlay.js.map +1 -0
- package/dist/maps/paint.d.ts +226 -0
- package/dist/maps/paint.d.ts.map +1 -0
- package/dist/maps/paint.js +705 -0
- package/dist/maps/paint.js.map +1 -0
- package/dist/maps/proj.d.ts +281 -0
- package/dist/maps/proj.d.ts.map +1 -0
- package/dist/maps/proj.js +451 -0
- package/dist/maps/proj.js.map +1 -0
- package/dist/maps/sources.d.ts +256 -0
- package/dist/maps/sources.d.ts.map +1 -0
- package/dist/maps/sources.js +210 -0
- package/dist/maps/sources.js.map +1 -0
- package/dist/maps/style.d.ts +141 -0
- package/dist/maps/style.d.ts.map +1 -0
- package/dist/maps/style.js +147 -0
- package/dist/maps/style.js.map +1 -0
- package/dist/maps/styles.d.ts +82 -0
- package/dist/maps/styles.d.ts.map +1 -0
- package/dist/maps/styles.js +898 -0
- package/dist/maps/styles.js.map +1 -0
- package/dist/maps/tiles.d.ts +283 -0
- package/dist/maps/tiles.d.ts.map +1 -0
- package/dist/maps/tiles.js +469 -0
- package/dist/maps/tiles.js.map +1 -0
- package/dist/maps/types.d.ts +244 -0
- package/dist/maps/types.d.ts.map +1 -0
- package/dist/maps/types.js +2 -0
- package/dist/maps/types.js.map +1 -0
- package/dist/markdown/ast.d.ts +80 -6
- package/dist/markdown/ast.d.ts.map +1 -1
- package/dist/markdown/ast.js +13 -4
- package/dist/markdown/ast.js.map +1 -1
- package/dist/markdown/expressions.d.ts +27 -0
- package/dist/markdown/expressions.d.ts.map +1 -0
- package/dist/markdown/expressions.js +203 -0
- package/dist/markdown/expressions.js.map +1 -0
- package/dist/markdown/index.d.ts +61 -2
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js +101 -2
- package/dist/markdown/index.js.map +1 -1
- package/dist/markdown/parse.d.ts +1 -1
- package/dist/markdown/parse.d.ts.map +1 -1
- package/dist/markdown/parse.js +163 -15
- package/dist/markdown/parse.js.map +1 -1
- package/dist/markdown/spans.d.ts.map +1 -1
- package/dist/markdown/spans.js +4 -0
- package/dist/markdown/spans.js.map +1 -1
- package/dist/markdown/tags.d.ts +35 -0
- package/dist/markdown/tags.d.ts.map +1 -0
- package/dist/markdown/tags.js +213 -0
- package/dist/markdown/tags.js.map +1 -0
- package/dist/reorder/hx.d.ts +18 -0
- package/dist/reorder/hx.d.ts.map +1 -0
- package/dist/reorder/hx.js +10 -0
- package/dist/reorder/hx.js.map +1 -0
- package/dist/reorder/index.d.ts +314 -0
- package/dist/reorder/index.d.ts.map +1 -0
- package/dist/reorder/index.js +1539 -0
- package/dist/reorder/index.js.map +1 -0
- package/dist/reorder/model.d.ts +107 -0
- package/dist/reorder/model.d.ts.map +1 -0
- package/dist/reorder/model.js +162 -0
- package/dist/reorder/model.js.map +1 -0
- package/dist/richtext/node.d.ts +15 -5
- package/dist/richtext/node.d.ts.map +1 -1
- package/dist/richtext/node.js +40 -11
- package/dist/richtext/node.js.map +1 -1
- package/dist/richtext/runs.d.ts +35 -13
- package/dist/richtext/runs.d.ts.map +1 -1
- package/dist/richtext/runs.js +83 -27
- package/dist/richtext/runs.js.map +1 -1
- package/dist/tabs/index.d.ts +17 -0
- package/dist/tabs/index.d.ts.map +1 -1
- package/dist/tabs/index.js +849 -186
- package/dist/tabs/index.js.map +1 -1
- package/dist/terminal/vt/fonts.d.ts +45 -15
- package/dist/terminal/vt/fonts.d.ts.map +1 -1
- package/dist/terminal/vt/fonts.js +89 -19
- package/dist/terminal/vt/fonts.js.map +1 -1
- package/dist/terminal/vt/node.d.ts +42 -0
- package/dist/terminal/vt/node.d.ts.map +1 -1
- package/dist/terminal/vt/node.js +95 -11
- package/dist/terminal/vt/node.js.map +1 -1
- package/dist/terminal/vt/renderer.d.ts.map +1 -1
- package/dist/terminal/vt/renderer.js +13 -3
- package/dist/terminal/vt/renderer.js.map +1 -1
- package/package.json +14 -3
- package/src/charts/components.ts +14 -7
- package/src/charts/node.ts +81 -26
- package/src/charts/render.ts +16 -6
- package/src/code-editor/node.ts +147 -53
- package/src/flow/draw.ts +104 -35
- package/src/flow/index.ts +33 -13
- package/src/flow/model.ts +4 -2
- package/src/flow/node.ts +158 -59
- package/src/flow/types.ts +46 -21
- package/src/formula/index.ts +4 -2
- package/src/formula/node.ts +24 -5
- package/src/html/controls.ts +4 -1
- package/src/html/css/cascade.ts +18 -7
- package/src/html/css/parse.ts +46 -21
- package/src/html/css/style.ts +6 -1
- package/src/html/css/values.ts +25 -9
- package/src/html/index.ts +1 -0
- package/src/html/layout/boxes.ts +19 -9
- package/src/html/layout/inline.ts +23 -1
- package/src/html/node.ts +110 -17
- package/src/html/paint.ts +17 -2
- package/src/index.ts +102 -0
- package/src/maps/clip.ts +184 -0
- package/src/maps/gzip.ts +66 -0
- package/src/maps/index.ts +316 -0
- package/src/maps/labels.ts +442 -0
- package/src/maps/mvt.ts +857 -0
- package/src/maps/node.ts +1819 -0
- package/src/maps/overlay.ts +639 -0
- package/src/maps/paint.ts +978 -0
- package/src/maps/proj.ts +641 -0
- package/src/maps/sources.ts +425 -0
- package/src/maps/style.ts +309 -0
- package/src/maps/styles.ts +1040 -0
- package/src/maps/tiles.ts +676 -0
- package/src/maps/types.ts +260 -0
- package/src/markdown/ast.ts +85 -9
- package/src/markdown/expressions.ts +223 -0
- package/src/markdown/index.ts +190 -5
- package/src/markdown/parse.ts +230 -15
- package/src/markdown/spans.ts +4 -0
- package/src/markdown/tags.ts +240 -0
- package/src/reorder/hx.ts +31 -0
- package/src/reorder/index.ts +2223 -0
- package/src/reorder/model.ts +232 -0
- package/src/richtext/node.ts +49 -14
- package/src/richtext/runs.ts +115 -30
- package/src/tabs/index.ts +1040 -204
- package/src/terminal/vt/fonts.ts +128 -36
- package/src/terminal/vt/node.ts +105 -12
- package/src/terminal/vt/renderer.ts +13 -3
package/src/tabs/index.ts
CHANGED
|
@@ -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
|
|
@@ -24,19 +31,23 @@
|
|
|
24
31
|
// box can.
|
|
25
32
|
import React from 'react';
|
|
26
33
|
import type { ReactElement, ReactNode } from 'react';
|
|
27
|
-
import { useDirection, useTheme } from 'react-x11';
|
|
34
|
+
import { Icon, useDirection, useTheme } from 'react-x11';
|
|
28
35
|
import type { DrawnNode, KeyboardEvent } from 'react-x11';
|
|
29
36
|
import { interpolate } from 'react-x11/style';
|
|
30
|
-
import type { Style } from 'react-x11/style';
|
|
37
|
+
import type { Style, StyleProperties } from 'react-x11/style';
|
|
31
38
|
import {
|
|
32
39
|
XK_DOWN,
|
|
33
40
|
XK_END,
|
|
41
|
+
XK_ESCAPE,
|
|
34
42
|
XK_HOME,
|
|
35
43
|
XK_LEFT,
|
|
44
|
+
XK_RETURN,
|
|
36
45
|
XK_RIGHT,
|
|
37
46
|
XK_UP,
|
|
38
47
|
} from 'react-x11/keysyms';
|
|
39
48
|
|
|
49
|
+
import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
|
|
50
|
+
import { useDismissOnWindowBlur } from '../internal/widget.js';
|
|
40
51
|
import { hx } from './hx.js';
|
|
41
52
|
|
|
42
53
|
const h = React.createElement;
|
|
@@ -44,6 +55,19 @@ const h = React.createElement;
|
|
|
44
55
|
/** What `style` props here accept, matching the rest of the package. */
|
|
45
56
|
type StyleInput = Style | Style[];
|
|
46
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
|
+
|
|
47
71
|
// --- geometry ---------------------------------------------------------------
|
|
48
72
|
|
|
49
73
|
/** The selected trigger's marker in the `line` variant. */
|
|
@@ -58,8 +82,37 @@ const PANEL_GAP = 8;
|
|
|
58
82
|
* radius, so the two variants agree about how round this notebook is. */
|
|
59
83
|
const CORNER = 6;
|
|
60
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
|
+
|
|
61
103
|
export type TabsVariant = 'line' | 'subtle' | 'enclosed' | 'outline' | 'plain';
|
|
62
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
|
+
}
|
|
63
116
|
|
|
64
117
|
interface SizeSpec {
|
|
65
118
|
/** Horizontal padding inside a trigger. */
|
|
@@ -97,6 +150,8 @@ interface TabsLook {
|
|
|
97
150
|
manual: boolean;
|
|
98
151
|
fitted: boolean;
|
|
99
152
|
justify: 'start' | 'center' | 'end' | undefined;
|
|
153
|
+
/** What a horizontal strip does with more tabs than it has room for. */
|
|
154
|
+
overflow: TabsOverflow;
|
|
100
155
|
/** The marker colour, resolved: `interpolate` mixes real colours, not
|
|
101
156
|
* `$token`s. */
|
|
102
157
|
accent: string;
|
|
@@ -122,9 +177,26 @@ interface TabsLook {
|
|
|
122
177
|
|
|
123
178
|
const LookContext = React.createContext<TabsLook | null>(null);
|
|
124
179
|
|
|
125
|
-
/**
|
|
126
|
-
*
|
|
127
|
-
|
|
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);
|
|
128
200
|
|
|
129
201
|
function useLook(part: string): TabsLook {
|
|
130
202
|
const look = React.useContext(LookContext);
|
|
@@ -202,6 +274,288 @@ function styled(base: Style, extra: StyleInput | undefined): Style[] {
|
|
|
202
274
|
return Array.isArray(extra) ? [base, ...extra] : [base, extra];
|
|
203
275
|
}
|
|
204
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
|
+
|
|
205
559
|
// --- root -------------------------------------------------------------------
|
|
206
560
|
|
|
207
561
|
/** What `onValueChange` reports — Chakra's `details` object, so a handler
|
|
@@ -240,6 +594,18 @@ export interface TabsProps {
|
|
|
240
594
|
fitted?: boolean;
|
|
241
595
|
/** Where the triggers sit along the strip. Default `'start'`. */
|
|
242
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;
|
|
243
609
|
/** Build a panel the first time its tab is selected rather than up front.
|
|
244
610
|
* Default false: every panel mounts, hidden, so its state is live. */
|
|
245
611
|
lazyMount?: boolean;
|
|
@@ -320,6 +686,7 @@ export function Tabs(props: TabsProps): ReactElement {
|
|
|
320
686
|
manual: props.activationMode === 'manual',
|
|
321
687
|
fitted: props.fitted ?? false,
|
|
322
688
|
justify: props.justify,
|
|
689
|
+
overflow: props.overflow ?? 'menu',
|
|
323
690
|
accent,
|
|
324
691
|
ground,
|
|
325
692
|
text: String(theme.text),
|
|
@@ -341,6 +708,7 @@ export function Tabs(props: TabsProps): ReactElement {
|
|
|
341
708
|
props.activationMode,
|
|
342
709
|
props.fitted,
|
|
343
710
|
props.justify,
|
|
711
|
+
props.overflow,
|
|
344
712
|
props.lazyMount,
|
|
345
713
|
props.unmountOnExit,
|
|
346
714
|
accent,
|
|
@@ -379,6 +747,565 @@ export function Tabs(props: TabsProps): ReactElement {
|
|
|
379
747
|
);
|
|
380
748
|
}
|
|
381
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
|
+
|
|
382
1309
|
// --- list -------------------------------------------------------------------
|
|
383
1310
|
|
|
384
1311
|
export interface TabsListProps {
|
|
@@ -397,31 +1324,75 @@ export interface TabsListProps {
|
|
|
397
1324
|
* to the ends. In `manual` mode focus moves without selecting, and Enter or
|
|
398
1325
|
* Space commits — those keys are not handled here, because core makes them
|
|
399
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.
|
|
400
1331
|
*/
|
|
401
1332
|
export function TabsList(props: TabsListProps): ReactElement {
|
|
402
1333
|
const look = useLook('TabsList');
|
|
403
1334
|
const rtl = useDirection() === 'rtl';
|
|
404
1335
|
const { vertical } = look;
|
|
1336
|
+
const listRef = React.useRef<DrawnNode | null>(null);
|
|
1337
|
+
const moreRef = React.useRef<DrawnNode | null>(null);
|
|
405
1338
|
|
|
406
1339
|
// A `<TabsIndicator>` written into the strip, Chakra-style, is read here
|
|
407
1340
|
// and handed to the triggers — the selected one draws it. It has to be a
|
|
408
1341
|
// direct child for this to see it; anything else in the strip renders
|
|
409
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);
|
|
410
1350
|
let indicator: TabsIndicatorProps | null = null;
|
|
411
|
-
|
|
412
|
-
|
|
1351
|
+
const items: OverflowItem[] = [];
|
|
1352
|
+
for (const child of children) {
|
|
1353
|
+
if (!React.isValidElement(child)) continue;
|
|
1354
|
+
if (child.type === TabsIndicator) {
|
|
413
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
|
+
});
|
|
414
1364
|
}
|
|
415
1365
|
}
|
|
416
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
|
+
|
|
417
1381
|
const onKeyDown = (ev: KeyboardEvent) => {
|
|
418
1382
|
// Where layout put each trigger is the one ordering that cannot drift
|
|
419
1383
|
// from what the user sees — mount order can, after a remove and re-add.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
+
);
|
|
425
1396
|
// Visual order back to logical: an RTL strip reads right to left. A
|
|
426
1397
|
// vertical strip never mirrors — Up is Up.
|
|
427
1398
|
const swap = !vertical && rtl;
|
|
@@ -441,7 +1412,9 @@ export function TabsList(props: TabsListProps): ReactElement {
|
|
|
441
1412
|
const goTo = (entry: { value: string; node: DrawnNode } | null) => {
|
|
442
1413
|
if (!entry) return;
|
|
443
1414
|
entry.node.focus();
|
|
444
|
-
|
|
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);
|
|
445
1418
|
};
|
|
446
1419
|
|
|
447
1420
|
const back = vertical ? XK_UP : swap ? XK_RIGHT : XK_LEFT;
|
|
@@ -493,14 +1466,22 @@ export function TabsList(props: TabsListProps): ReactElement {
|
|
|
493
1466
|
padding: 3,
|
|
494
1467
|
gap: 2,
|
|
495
1468
|
}
|
|
496
|
-
: { gap: look.variant
|
|
1469
|
+
: { gap: stripGap(look.variant) };
|
|
497
1470
|
|
|
498
1471
|
return h(
|
|
499
|
-
|
|
500
|
-
{
|
|
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
|
+
},
|
|
501
1481
|
hx(
|
|
502
1482
|
'box',
|
|
503
1483
|
{
|
|
1484
|
+
ref: listRef,
|
|
504
1485
|
role: 'tablist',
|
|
505
1486
|
'aria-orientation': look.vertical ? 'vertical' : 'horizontal',
|
|
506
1487
|
onKeyDown,
|
|
@@ -517,6 +1498,12 @@ export function TabsList(props: TabsListProps): ReactElement {
|
|
|
517
1498
|
? 'flex-end'
|
|
518
1499
|
: 'center',
|
|
519
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
|
+
: {}),
|
|
520
1507
|
...chrome,
|
|
521
1508
|
},
|
|
522
1509
|
props.style,
|
|
@@ -524,11 +1511,28 @@ export function TabsList(props: TabsListProps): ReactElement {
|
|
|
524
1511
|
'data-testname': props['data-testname'],
|
|
525
1512
|
},
|
|
526
1513
|
rule,
|
|
527
|
-
|
|
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,
|
|
528
1533
|
),
|
|
529
1534
|
);
|
|
530
1535
|
}
|
|
531
|
-
|
|
532
1536
|
// --- trigger ----------------------------------------------------------------
|
|
533
1537
|
|
|
534
1538
|
export interface TabsTriggerProps {
|
|
@@ -548,10 +1552,13 @@ export interface TabsTriggerProps {
|
|
|
548
1552
|
*/
|
|
549
1553
|
export function TabsTrigger(props: TabsTriggerProps): ReactElement {
|
|
550
1554
|
const look = useLook('TabsTrigger');
|
|
551
|
-
const
|
|
1555
|
+
const strip = React.useContext(StripContext);
|
|
1556
|
+
const indicator = strip?.indicator ?? null;
|
|
552
1557
|
const { value, disabled = false } = props;
|
|
553
1558
|
const selected = look.selected === value;
|
|
554
|
-
|
|
1559
|
+
// Tracked rather than declared, because the wash is a box of its own —
|
|
1560
|
+
// {@link hoverWash}.
|
|
1561
|
+
const [hovered, setHovered] = React.useState(false);
|
|
555
1562
|
|
|
556
1563
|
// Registration is the ref callback: a new closure each render, so React
|
|
557
1564
|
// re-runs it and a `disabled` that changed is re-registered. Order in the
|
|
@@ -562,166 +1569,14 @@ export function TabsTrigger(props: TabsTriggerProps): ReactElement {
|
|
|
562
1569
|
else look.triggers.delete(value);
|
|
563
1570
|
};
|
|
564
1571
|
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
Object.assign(hover, { ':hover': { color: '$text' } });
|
|
574
|
-
break;
|
|
575
|
-
case 'subtle':
|
|
576
|
-
variantStyle.push({ borderRadius: 4 });
|
|
577
|
-
if (selected)
|
|
578
|
-
variantStyle.push({
|
|
579
|
-
backgroundColor: over(look.ground, look.accent, 0.15),
|
|
580
|
-
});
|
|
581
|
-
else
|
|
582
|
-
Object.assign(hover, {
|
|
583
|
-
':hover': { backgroundColor: '$surfaceHover' },
|
|
584
|
-
':active': { backgroundColor: '$surfaceActive' },
|
|
585
|
-
});
|
|
586
|
-
break;
|
|
587
|
-
case 'enclosed':
|
|
588
|
-
// Every trigger carries the border so selecting one cannot change its
|
|
589
|
-
// size — only the selected one inks it.
|
|
590
|
-
variantStyle.push({
|
|
591
|
-
borderRadius: 4,
|
|
592
|
-
borderWidth: 1,
|
|
593
|
-
borderColor: 'transparent',
|
|
594
|
-
});
|
|
595
|
-
if (selected)
|
|
596
|
-
variantStyle.push({
|
|
597
|
-
backgroundColor: look.ground,
|
|
598
|
-
borderColor: '$border',
|
|
599
|
-
});
|
|
600
|
-
else Object.assign(hover, { ':hover': { color: '$text' } });
|
|
601
|
-
break;
|
|
602
|
-
case 'outline':
|
|
603
|
-
if (selected)
|
|
604
|
-
variantStyle.push({
|
|
605
|
-
// The fill is the ground, so the tab and its panel read as one
|
|
606
|
-
// surface — and it is what covers the strip's rule underneath.
|
|
607
|
-
// Rounded so the fill stays inside the frame's shoulders, but
|
|
608
|
-
// deliberately **borderless**: this renderer paints a node's
|
|
609
|
-
// border *after* its children (`Node.paint` — children, then
|
|
610
|
-
// `_paintBorder`), so a border here could never be opened by a
|
|
611
|
-
// child laid over it. The frame below carries the stroke instead.
|
|
612
|
-
backgroundColor: look.ground,
|
|
613
|
-
borderRadius: CORNER,
|
|
614
|
-
});
|
|
615
|
-
else
|
|
616
|
-
Object.assign(hover, {
|
|
617
|
-
':hover': { backgroundColor: '$surfaceHover' },
|
|
618
|
-
});
|
|
619
|
-
break;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// The `outline` tab's shape, from two stacked children — a shape the style
|
|
623
|
-
// vocabulary cannot say in one box. `borderRadius` is one number and
|
|
624
|
-
// requires a uniform border, so "rounded shoulders, open bottom" is drawn
|
|
625
|
-
// as: a *frame* (inset-0 box carrying the full rounded border) and, after
|
|
626
|
-
// it, a *skirt* (a strip of ground over the frame's panel edge, covering
|
|
627
|
-
// the border and the two corners that would curl toward the panel, and
|
|
628
|
-
// redrawing the straight side walls over itself). The skirt can cover the
|
|
629
|
-
// frame only because they are siblings — a node's own border paints over
|
|
630
|
-
// its children, which is why the frame is not the trigger's border.
|
|
631
|
-
// Both are written before the label, so a descender that dips below the
|
|
632
|
-
// baseline paints over them rather than being cut.
|
|
633
|
-
const outlineShape =
|
|
634
|
-
look.variant === 'outline' && selected
|
|
635
|
-
? [
|
|
636
|
-
hx('box', {
|
|
637
|
-
key: 'frame',
|
|
638
|
-
style: {
|
|
639
|
-
position: 'absolute',
|
|
640
|
-
top: 0,
|
|
641
|
-
bottom: 0,
|
|
642
|
-
start: 0,
|
|
643
|
-
end: 0,
|
|
644
|
-
borderWidth: 1,
|
|
645
|
-
borderColor: '$border',
|
|
646
|
-
borderRadius: CORNER,
|
|
647
|
-
},
|
|
648
|
-
}),
|
|
649
|
-
hx('box', {
|
|
650
|
-
key: 'skirt',
|
|
651
|
-
style: [
|
|
652
|
-
{
|
|
653
|
-
position: 'absolute',
|
|
654
|
-
backgroundColor: look.ground,
|
|
655
|
-
borderColor: '$border',
|
|
656
|
-
},
|
|
657
|
-
vertical
|
|
658
|
-
? {
|
|
659
|
-
top: 0,
|
|
660
|
-
bottom: 0,
|
|
661
|
-
end: 0,
|
|
662
|
-
width: CORNER + 1,
|
|
663
|
-
borderTopWidth: 1,
|
|
664
|
-
borderBottomWidth: 1,
|
|
665
|
-
}
|
|
666
|
-
: {
|
|
667
|
-
start: 0,
|
|
668
|
-
end: 0,
|
|
669
|
-
bottom: 0,
|
|
670
|
-
height: CORNER + 1,
|
|
671
|
-
borderStartWidth: 1,
|
|
672
|
-
borderEndWidth: 1,
|
|
673
|
-
},
|
|
674
|
-
],
|
|
675
|
-
}),
|
|
676
|
-
]
|
|
677
|
-
: null;
|
|
678
|
-
|
|
679
|
-
// The selected marker in the `line` variant: a 2px box riding the panel
|
|
680
|
-
// edge of the trigger, over the strip's 1px rule.
|
|
681
|
-
const marker =
|
|
682
|
-
look.variant === 'line'
|
|
683
|
-
? hx('box', {
|
|
684
|
-
style: [
|
|
685
|
-
{
|
|
686
|
-
position: 'absolute',
|
|
687
|
-
backgroundColor: selected ? look.accent : 'transparent',
|
|
688
|
-
transition: { backgroundColor: 100 },
|
|
689
|
-
},
|
|
690
|
-
vertical
|
|
691
|
-
? { top: 0, bottom: 0, end: 0, width: INDICATOR }
|
|
692
|
-
: { left: 0, right: 0, bottom: 0, height: INDICATOR },
|
|
693
|
-
],
|
|
694
|
-
})
|
|
695
|
-
: null;
|
|
696
|
-
|
|
697
|
-
// A `<TabsIndicator>` in the strip is drawn here, by the selected trigger,
|
|
698
|
-
// behind its label. Layout owns its geometry — see the part's own comment.
|
|
699
|
-
const chip =
|
|
700
|
-
indicator && selected
|
|
701
|
-
? hx('box', {
|
|
702
|
-
style: styled(
|
|
703
|
-
{
|
|
704
|
-
position: 'absolute',
|
|
705
|
-
top: 0,
|
|
706
|
-
bottom: 0,
|
|
707
|
-
left: 0,
|
|
708
|
-
right: 0,
|
|
709
|
-
zIndex: -1,
|
|
710
|
-
borderRadius: 4,
|
|
711
|
-
backgroundColor: over(look.ground, look.accent, 0.15),
|
|
712
|
-
},
|
|
713
|
-
indicator.style,
|
|
714
|
-
),
|
|
715
|
-
})
|
|
716
|
-
: null;
|
|
717
|
-
|
|
718
|
-
const ink = disabled
|
|
719
|
-
? '$textMuted'
|
|
720
|
-
: selected
|
|
721
|
-
? look.variant === 'line' || look.variant === 'subtle'
|
|
722
|
-
? look.accent
|
|
723
|
-
: '$text'
|
|
724
|
-
: '$textMuted';
|
|
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
|
+
});
|
|
725
1580
|
|
|
726
1581
|
return hx(
|
|
727
1582
|
'box',
|
|
@@ -737,34 +1592,15 @@ export function TabsTrigger(props: TabsTriggerProps): ReactElement {
|
|
|
737
1592
|
disabled,
|
|
738
1593
|
onFocus: () => look.setFocused(value),
|
|
739
1594
|
onBlur: () => look.setFocused(null),
|
|
1595
|
+
onMouseEnter: () => setHovered(true),
|
|
1596
|
+
onMouseLeave: () => setHovered(false),
|
|
740
1597
|
onClick: () => !disabled && look.select(value),
|
|
741
|
-
style: styled(
|
|
742
|
-
{
|
|
743
|
-
flexDirection: 'row',
|
|
744
|
-
alignItems: 'center',
|
|
745
|
-
justifyContent: 'center',
|
|
746
|
-
flexShrink: 0,
|
|
747
|
-
...(look.fitted && { flexGrow: 1 }),
|
|
748
|
-
gap: TRIGGER_GAP,
|
|
749
|
-
paddingTop: look.py,
|
|
750
|
-
paddingBottom: look.py,
|
|
751
|
-
paddingLeft: look.px,
|
|
752
|
-
paddingRight: look.px,
|
|
753
|
-
fontSize: look.font,
|
|
754
|
-
color: ink,
|
|
755
|
-
...(disabled ? {} : { cursor: 'pointer' }),
|
|
756
|
-
transition: { backgroundColor: 100, color: 100 },
|
|
757
|
-
...Object.assign({}, ...variantStyle),
|
|
758
|
-
...(disabled ? {} : hover),
|
|
759
|
-
},
|
|
760
|
-
props.style,
|
|
761
|
-
),
|
|
1598
|
+
style: styled(chrome.style, props.style),
|
|
762
1599
|
'data-testname': props['data-testname'],
|
|
763
1600
|
},
|
|
764
|
-
|
|
765
|
-
outlineShape,
|
|
1601
|
+
chrome.behind,
|
|
766
1602
|
withText(props.children, CAP_TRIM),
|
|
767
|
-
|
|
1603
|
+
chrome.after,
|
|
768
1604
|
);
|
|
769
1605
|
}
|
|
770
1606
|
|