@react-x11/components 0.3.0 → 0.4.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 +6 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/qml/containers.d.ts +3 -0
- package/dist/qml/containers.d.ts.map +1 -0
- package/dist/qml/containers.js +331 -0
- package/dist/qml/containers.js.map +1 -0
- package/dist/qml/controls.d.ts +12 -0
- package/dist/qml/controls.d.ts.map +1 -0
- package/dist/qml/controls.js +21 -0
- package/dist/qml/controls.js.map +1 -0
- package/dist/qml/globals.d.ts +6 -0
- package/dist/qml/globals.d.ts.map +1 -0
- package/dist/qml/globals.js +25 -0
- package/dist/qml/globals.js.map +1 -0
- package/dist/qml/index.d.ts +15 -0
- package/dist/qml/index.d.ts.map +1 -0
- package/dist/qml/index.js +25 -0
- package/dist/qml/index.js.map +1 -0
- package/dist/qml/interaction.d.ts +12 -0
- package/dist/qml/interaction.d.ts.map +1 -0
- package/dist/qml/interaction.js +246 -0
- package/dist/qml/interaction.js.map +1 -0
- package/dist/qml/ir.d.ts +106 -0
- package/dist/qml/ir.d.ts.map +1 -0
- package/dist/qml/ir.js +7 -0
- package/dist/qml/ir.js.map +1 -0
- package/dist/qml/layouts.d.ts +3 -0
- package/dist/qml/layouts.d.ts.map +1 -0
- package/dist/qml/layouts.js +122 -0
- package/dist/qml/layouts.js.map +1 -0
- package/dist/qml/models.d.ts +14 -0
- package/dist/qml/models.d.ts.map +1 -0
- package/dist/qml/models.js +80 -0
- package/dist/qml/models.js.map +1 -0
- package/dist/qml/objects.d.ts +228 -0
- package/dist/qml/objects.d.ts.map +1 -0
- package/dist/qml/objects.js +1103 -0
- package/dist/qml/objects.js.map +1 -0
- package/dist/qml/parse.d.ts +10 -0
- package/dist/qml/parse.d.ts.map +1 -0
- package/dist/qml/parse.js +607 -0
- package/dist/qml/parse.js.map +1 -0
- package/dist/qml/qtquick.d.ts +7 -0
- package/dist/qml/qtquick.d.ts.map +1 -0
- package/dist/qml/qtquick.js +347 -0
- package/dist/qml/qtquick.js.map +1 -0
- package/dist/qml/react.d.ts +83 -0
- package/dist/qml/react.d.ts.map +1 -0
- package/dist/qml/react.js +323 -0
- package/dist/qml/react.js.map +1 -0
- package/dist/qml/resolver.d.ts +38 -0
- package/dist/qml/resolver.d.ts.map +1 -0
- package/dist/qml/resolver.js +64 -0
- package/dist/qml/resolver.js.map +1 -0
- package/dist/qml/slots.d.ts +65 -0
- package/dist/qml/slots.d.ts.map +1 -0
- package/dist/qml/slots.js +230 -0
- package/dist/qml/slots.js.map +1 -0
- package/dist/qml/states.d.ts +5 -0
- package/dist/qml/states.d.ts.map +1 -0
- package/dist/qml/states.js +276 -0
- package/dist/qml/states.js.map +1 -0
- package/dist/qml/view-utils.d.ts +42 -0
- package/dist/qml/view-utils.d.ts.map +1 -0
- package/dist/qml/view-utils.js +121 -0
- package/dist/qml/view-utils.js.map +1 -0
- package/dist/tabs/hx.d.ts +18 -0
- package/dist/tabs/hx.d.ts.map +1 -0
- package/dist/tabs/hx.js +10 -0
- package/dist/tabs/hx.js.map +1 -0
- package/dist/tabs/index.d.ts +143 -0
- package/dist/tabs/index.d.ts.map +1 -0
- package/dist/tabs/index.js +602 -0
- package/dist/tabs/index.js.map +1 -0
- package/dist/terminal/vt/index.d.ts +1 -1
- package/dist/terminal/vt/index.d.ts.map +1 -1
- package/dist/terminal/vt/index.js +3 -3
- package/dist/terminal/vt/index.js.map +1 -1
- package/dist/terminal/vt/pty.d.ts +16 -0
- package/dist/terminal/vt/pty.d.ts.map +1 -1
- package/dist/terminal/vt/pty.js +186 -0
- package/dist/terminal/vt/pty.js.map +1 -1
- package/package.json +11 -1
- package/src/index.ts +44 -0
- package/src/qml/containers.tsx +380 -0
- package/src/qml/controls.tsx +47 -0
- package/src/qml/globals.ts +33 -0
- package/src/qml/index.ts +67 -0
- package/src/qml/interaction.tsx +317 -0
- package/src/qml/ir.ts +80 -0
- package/src/qml/layouts.tsx +141 -0
- package/src/qml/models.ts +101 -0
- package/src/qml/objects.ts +1431 -0
- package/src/qml/parse.ts +624 -0
- package/src/qml/qtquick.tsx +413 -0
- package/src/qml/react.tsx +454 -0
- package/src/qml/resolver.ts +99 -0
- package/src/qml/slots.ts +249 -0
- package/src/qml/states.ts +318 -0
- package/src/qml/view-utils.ts +140 -0
- package/src/tabs/hx.ts +31 -0
- package/src/tabs/index.ts +850 -0
- package/src/terminal/vt/index.ts +4 -2
- package/src/terminal/vt/pty.ts +243 -0
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
// <Tabs> — one visible panel at a time, switched by a strip of triggers.
|
|
2
|
+
//
|
|
3
|
+
// The API is [Chakra UI's Tabs](https://chakra-ui.com/docs/components/tabs)
|
|
4
|
+
// with its parts spelled flat — `Tabs.Root` is `<Tabs>`, `Tabs.Trigger` is
|
|
5
|
+
// `<TabsTrigger>` — because that is how this package names a composition (see
|
|
6
|
+
// `/timeline` and `/charts`). The vocabulary is Chakra's too: `value` /
|
|
7
|
+
// `defaultValue` / `onValueChange({ value })`, the five variants (`line`,
|
|
8
|
+
// `subtle`, `enclosed`, `outline`, `plain`), `size`, `orientation`,
|
|
9
|
+
// `activationMode`, `fitted`, `justify`, `lazyMount` and `unmountOnExit` — so
|
|
10
|
+
// a snippet copied from Chakra's docs is the same tree with the dots removed.
|
|
11
|
+
// `asChild` is the one part of the surface deliberately absent: it exists to
|
|
12
|
+
// merge props into somebody else's DOM element, and there is no DOM here.
|
|
13
|
+
//
|
|
14
|
+
// The behaviour is core's `<Tabs>` — the widget this one supersedes: a single
|
|
15
|
+
// tab stop with roving focus, arrows that wrap and skip disabled triggers,
|
|
16
|
+
// Home/End, visual arrows in an RTL strip, and `activationMode="manual"` for
|
|
17
|
+
// a panel too expensive to build on every keystroke.
|
|
18
|
+
//
|
|
19
|
+
// It is pure composition of `<box>` and `<text>`, so there is no
|
|
20
|
+
// `registerElement` here and nothing for the reconciler to learn. The line
|
|
21
|
+
// under a `line` strip, the marker on the selected trigger, the chip behind
|
|
22
|
+
// an `enclosed` strip — each is a `<box>`, positioned absolutely where it has
|
|
23
|
+
// to sit on one edge, because borders alone cannot say "this side only" and a
|
|
24
|
+
// box can.
|
|
25
|
+
import React from 'react';
|
|
26
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
27
|
+
import { useDirection, useTheme } from 'react-x11';
|
|
28
|
+
import type { DrawnNode, KeyboardEvent } from 'react-x11';
|
|
29
|
+
import { interpolate } from 'react-x11/style';
|
|
30
|
+
import type { Style } from 'react-x11/style';
|
|
31
|
+
import {
|
|
32
|
+
XK_DOWN,
|
|
33
|
+
XK_END,
|
|
34
|
+
XK_HOME,
|
|
35
|
+
XK_LEFT,
|
|
36
|
+
XK_RIGHT,
|
|
37
|
+
XK_UP,
|
|
38
|
+
} from 'react-x11/keysyms';
|
|
39
|
+
|
|
40
|
+
import { hx } from './hx.js';
|
|
41
|
+
|
|
42
|
+
const h = React.createElement;
|
|
43
|
+
|
|
44
|
+
/** What `style` props here accept, matching the rest of the package. */
|
|
45
|
+
type StyleInput = Style | Style[];
|
|
46
|
+
|
|
47
|
+
// --- geometry ---------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/** The selected trigger's marker in the `line` variant. */
|
|
50
|
+
const INDICATOR = 2;
|
|
51
|
+
/** The strip's own line in the `line` and `outline` variants. */
|
|
52
|
+
const RULE = 1;
|
|
53
|
+
/** Inside a trigger, between a glyph and the label beside it. */
|
|
54
|
+
const TRIGGER_GAP = 6;
|
|
55
|
+
/** Between a strip and its panel. */
|
|
56
|
+
const PANEL_GAP = 8;
|
|
57
|
+
/** The `enclosed` strip's chip, and an `outline` tab's shoulders — one
|
|
58
|
+
* radius, so the two variants agree about how round this notebook is. */
|
|
59
|
+
const CORNER = 6;
|
|
60
|
+
|
|
61
|
+
export type TabsVariant = 'line' | 'subtle' | 'enclosed' | 'outline' | 'plain';
|
|
62
|
+
export type TabsSize = 'sm' | 'md' | 'lg';
|
|
63
|
+
|
|
64
|
+
interface SizeSpec {
|
|
65
|
+
/** Horizontal padding inside a trigger. */
|
|
66
|
+
px: number;
|
|
67
|
+
/** Vertical padding inside a trigger. */
|
|
68
|
+
py: number;
|
|
69
|
+
/** How far the label sits off the theme's body size. */
|
|
70
|
+
step: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Chakra's proportions, and looser than a first guess would make them: the
|
|
74
|
+
// labels are cap-trimmed, so `py` is the *whole* visible gap over the caps
|
|
75
|
+
// and under the baseline — padding sized for an untrimmed line box reads as
|
|
76
|
+
// cramped once the box is the letters. The type steps off the theme's own
|
|
77
|
+
// `fontSize` rather than a fixed scale, so a theme that sets 16 gets tabs in
|
|
78
|
+
// 16 (the same call `/timeline` makes).
|
|
79
|
+
const SIZES: Record<TabsSize, SizeSpec> = {
|
|
80
|
+
sm: { px: 12, py: 8, step: -1 },
|
|
81
|
+
md: { px: 16, py: 10, step: 0 },
|
|
82
|
+
lg: { px: 20, py: 12, step: 1 },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// --- shared state -----------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** One trigger, as the keyboard sees it. */
|
|
88
|
+
interface TriggerEntry {
|
|
89
|
+
node: DrawnNode;
|
|
90
|
+
disabled: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What the parts of one `<Tabs>` share. */
|
|
94
|
+
interface TabsLook {
|
|
95
|
+
variant: TabsVariant;
|
|
96
|
+
vertical: boolean;
|
|
97
|
+
manual: boolean;
|
|
98
|
+
fitted: boolean;
|
|
99
|
+
justify: 'start' | 'center' | 'end' | undefined;
|
|
100
|
+
/** The marker colour, resolved: `interpolate` mixes real colours, not
|
|
101
|
+
* `$token`s. */
|
|
102
|
+
accent: string;
|
|
103
|
+
/** The colour behind the tabs — what an `enclosed` or `outline` selected
|
|
104
|
+
* trigger is filled with, also resolved. */
|
|
105
|
+
ground: string;
|
|
106
|
+
/** The body ink, resolved, for the washes mixed from it. */
|
|
107
|
+
text: string;
|
|
108
|
+
font: number;
|
|
109
|
+
px: number;
|
|
110
|
+
py: number;
|
|
111
|
+
selected: string | undefined;
|
|
112
|
+
focused: string | null;
|
|
113
|
+
select(value: string): void;
|
|
114
|
+
setFocused(value: string | null): void;
|
|
115
|
+
/** The live trigger registry the keyboard walks. Order is irrelevant —
|
|
116
|
+
* the walk sorts by where layout actually put each trigger. */
|
|
117
|
+
triggers: Map<string, TriggerEntry>;
|
|
118
|
+
everSelected(value: string): boolean;
|
|
119
|
+
lazyMount: boolean;
|
|
120
|
+
unmountOnExit: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const LookContext = React.createContext<TabsLook | null>(null);
|
|
124
|
+
|
|
125
|
+
/** What a `<TabsIndicator>` written into the strip asks the selected trigger
|
|
126
|
+
* to draw. `null` when the strip has none. */
|
|
127
|
+
const IndicatorContext = React.createContext<TabsIndicatorProps | null>(null);
|
|
128
|
+
|
|
129
|
+
function useLook(part: string): TabsLook {
|
|
130
|
+
const look = React.useContext(LookContext);
|
|
131
|
+
if (!look) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`@react-x11/components: <${part}> has to be inside a <Tabs>. ` +
|
|
134
|
+
'It reads the selected value, the variant and the size from it.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return look;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A colour prop, resolved against the palette — `$token` is normally the
|
|
142
|
+
* renderer's job, but `interpolate()` is arithmetic over real colours and
|
|
143
|
+
* runs here, before any of that. Same helper as `/timeline`'s.
|
|
144
|
+
*/
|
|
145
|
+
function resolveColor(
|
|
146
|
+
value: string | undefined,
|
|
147
|
+
theme: Record<string, unknown>,
|
|
148
|
+
fallback: string,
|
|
149
|
+
): string {
|
|
150
|
+
if (!value) return fallback;
|
|
151
|
+
if (!value.startsWith('$')) return value;
|
|
152
|
+
const named = theme[value.slice(1)];
|
|
153
|
+
return typeof named === 'string' ? named : fallback;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `accent` laid over `ground`, opaquely — a weak wash at low `amount`.
|
|
158
|
+
* Opaque rather than translucent for the same reason `/timeline` mixes: a
|
|
159
|
+
* wash sits over the strip's own chrome, and light through it would show the
|
|
160
|
+
* rule running behind a selected trigger.
|
|
161
|
+
*/
|
|
162
|
+
function over(ground: string, accent: string, amount: number): string {
|
|
163
|
+
const mixed = interpolate(ground, accent, amount);
|
|
164
|
+
return typeof mixed === 'string' ? mixed : accent;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A single-line label's box trimmed to its letters — core's `capTrim`, the
|
|
169
|
+
* rule every core widget label follows (`labelContent` in
|
|
170
|
+
* `components/theme.js`). A line box is the font's ascent plus descent plus
|
|
171
|
+
* line gap, and the space over a capital differs from the space under the
|
|
172
|
+
* baseline by `(ascent - capHeight) - descent` — so a centred untrimmed
|
|
173
|
+
* label sits visibly low beside the icon centred on its own middle. Trimming
|
|
174
|
+
* makes the box *be* the letters, and centring centres what can be seen.
|
|
175
|
+
* Labels only: the icon is a `<canvas>` and panel prose is a paragraph,
|
|
176
|
+
* and neither wants it.
|
|
177
|
+
*/
|
|
178
|
+
const CAP_TRIM: Style = { textBoxTrim: 'cap-alphabetic' };
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Strings and numbers are only legal inside `<text>`, and a trigger's label
|
|
182
|
+
* is prose — `<TabsTrigger value="a">Members</TabsTrigger>` is the shortest
|
|
183
|
+
* thing that works and has to keep working. A primitive child is wrapped; an
|
|
184
|
+
* element child is left exactly as written, and inherits the same ink.
|
|
185
|
+
*/
|
|
186
|
+
function withText(children: ReactNode, style?: Style): ReactNode {
|
|
187
|
+
let wrapped = false;
|
|
188
|
+
const mapped = React.Children.map(children, (child) => {
|
|
189
|
+
if (typeof child !== 'string' && typeof child !== 'number') return child;
|
|
190
|
+
wrapped = true;
|
|
191
|
+
return hx('text', style ? { style } : null, child);
|
|
192
|
+
});
|
|
193
|
+
return wrapped ? mapped : children;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The part's own style, then the caller's — later entries win, which is how
|
|
198
|
+
* every `style` prop in this renderer resolves precedence.
|
|
199
|
+
*/
|
|
200
|
+
function styled(base: Style, extra: StyleInput | undefined): Style[] {
|
|
201
|
+
if (!extra) return [base];
|
|
202
|
+
return Array.isArray(extra) ? [base, ...extra] : [base, extra];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- root -------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/** What `onValueChange` reports — Chakra's `details` object, so a handler
|
|
208
|
+
* written for Chakra (`(e) => setValue(e.value)`) transfers unchanged. */
|
|
209
|
+
export interface TabsValueChange {
|
|
210
|
+
value: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface TabsProps {
|
|
214
|
+
/** The selected trigger's value. Providing it makes the tabs controlled. */
|
|
215
|
+
value?: string;
|
|
216
|
+
/**
|
|
217
|
+
* The initially selected value, when uncontrolled. There is no implicit
|
|
218
|
+
* default: with neither prop nothing is selected and no panel shows, as in
|
|
219
|
+
* Chakra — the root cannot know its triggers until they mount, and a first
|
|
220
|
+
* render that selected nobody only to snap a frame later would be worse
|
|
221
|
+
* than asking for one line.
|
|
222
|
+
*/
|
|
223
|
+
defaultValue?: string;
|
|
224
|
+
/** Selection changed — by click or by keyboard. Fires in controlled and
|
|
225
|
+
* uncontrolled mode alike. */
|
|
226
|
+
onValueChange?(change: TabsValueChange): void;
|
|
227
|
+
/** How the strip is drawn. Default `'line'`. */
|
|
228
|
+
variant?: TabsVariant;
|
|
229
|
+
/** Trigger padding, and the type inside. Default `'md'`. */
|
|
230
|
+
size?: TabsSize;
|
|
231
|
+
/** A vertical strip is a column beside its panel. Default `'horizontal'`. */
|
|
232
|
+
orientation?: 'horizontal' | 'vertical';
|
|
233
|
+
/**
|
|
234
|
+
* `'automatic'` (default) selects as the arrows move, the way a desktop
|
|
235
|
+
* notebook behaves. `'manual'` moves focus only and commits on Enter or
|
|
236
|
+
* Space — what you want when a panel is expensive to build.
|
|
237
|
+
*/
|
|
238
|
+
activationMode?: 'automatic' | 'manual';
|
|
239
|
+
/** Triggers share the strip equally, filling its length. Default false. */
|
|
240
|
+
fitted?: boolean;
|
|
241
|
+
/** Where the triggers sit along the strip. Default `'start'`. */
|
|
242
|
+
justify?: 'start' | 'center' | 'end';
|
|
243
|
+
/** Build a panel the first time its tab is selected rather than up front.
|
|
244
|
+
* Default false: every panel mounts, hidden, so its state is live. */
|
|
245
|
+
lazyMount?: boolean;
|
|
246
|
+
/** Unmount a panel when its tab is deselected, giving up its state.
|
|
247
|
+
* Default false. */
|
|
248
|
+
unmountOnExit?: boolean;
|
|
249
|
+
/**
|
|
250
|
+
* The colour the selection takes — Chakra's `colorPalette`, as one colour.
|
|
251
|
+
* A `$token` is resolved against the palette. Default `$accent`.
|
|
252
|
+
*/
|
|
253
|
+
accent?: string;
|
|
254
|
+
/**
|
|
255
|
+
* The colour behind the tabs, which is what an `enclosed` or `outline`
|
|
256
|
+
* selected trigger is filled with so it reads as part of the panel.
|
|
257
|
+
* Default `$background`; tabs on a card want `'$surface'`.
|
|
258
|
+
*/
|
|
259
|
+
ground?: string;
|
|
260
|
+
/** The root `<box>`'s style — width, padding, `flexGrow`. */
|
|
261
|
+
style?: StyleInput;
|
|
262
|
+
'data-testname'?: string;
|
|
263
|
+
children?: ReactNode;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Tabs.
|
|
268
|
+
*
|
|
269
|
+
* ```jsx
|
|
270
|
+
* <Tabs defaultValue="members">
|
|
271
|
+
* <TabsList>
|
|
272
|
+
* <TabsTrigger value="members">Members</TabsTrigger>
|
|
273
|
+
* <TabsTrigger value="projects">Projects</TabsTrigger>
|
|
274
|
+
* </TabsList>
|
|
275
|
+
* <TabsContent value="members">…</TabsContent>
|
|
276
|
+
* <TabsContent value="projects">…</TabsContent>
|
|
277
|
+
* </Tabs>
|
|
278
|
+
* ```
|
|
279
|
+
*/
|
|
280
|
+
export function Tabs(props: TabsProps): ReactElement {
|
|
281
|
+
// `Theme` is an interface, so it has no implicit index signature — the same
|
|
282
|
+
// widening `hx.ts` documents for the `theme` prop.
|
|
283
|
+
const theme = useTheme() as unknown as Record<string, unknown>;
|
|
284
|
+
const [uncontrolled, setUncontrolled] = React.useState(props.defaultValue);
|
|
285
|
+
const [focused, setFocused] = React.useState<string | null>(null);
|
|
286
|
+
const selected = props.value ?? uncontrolled;
|
|
287
|
+
const vertical = props.orientation === 'vertical';
|
|
288
|
+
const variant = props.variant ?? 'line';
|
|
289
|
+
const size = SIZES[props.size ?? 'md'] ?? SIZES.md;
|
|
290
|
+
const accent = resolveColor(props.accent, theme, String(theme.accent));
|
|
291
|
+
const ground = resolveColor(props.ground, theme, String(theme.background));
|
|
292
|
+
const body = typeof theme.fontSize === 'number' ? theme.fontSize : 14;
|
|
293
|
+
const { onValueChange } = props;
|
|
294
|
+
const controlled = props.value !== undefined;
|
|
295
|
+
|
|
296
|
+
// The registry the keyboard walks. A ref rather than state: a trigger
|
|
297
|
+
// mounting must not re-render the tree, and the walk only happens inside
|
|
298
|
+
// an event handler, where `current` is always current.
|
|
299
|
+
const triggers = React.useRef(new Map<string, TriggerEntry>());
|
|
300
|
+
|
|
301
|
+
// Which values have ever been selected, for `lazyMount`. Written during
|
|
302
|
+
// render on purpose — adding the current selection is idempotent, and an
|
|
303
|
+
// effect would run a frame after the panel already needed the answer.
|
|
304
|
+
const seen = React.useRef(new Set<string>());
|
|
305
|
+
if (selected !== undefined) seen.current.add(selected);
|
|
306
|
+
|
|
307
|
+
const select = React.useCallback(
|
|
308
|
+
(value: string) => {
|
|
309
|
+
if (value === selected) return;
|
|
310
|
+
if (!controlled) setUncontrolled(value);
|
|
311
|
+
onValueChange?.({ value });
|
|
312
|
+
},
|
|
313
|
+
[selected, controlled, onValueChange],
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const look = React.useMemo<TabsLook>(
|
|
317
|
+
() => ({
|
|
318
|
+
variant,
|
|
319
|
+
vertical,
|
|
320
|
+
manual: props.activationMode === 'manual',
|
|
321
|
+
fitted: props.fitted ?? false,
|
|
322
|
+
justify: props.justify,
|
|
323
|
+
accent,
|
|
324
|
+
ground,
|
|
325
|
+
text: String(theme.text),
|
|
326
|
+
font: body + size.step,
|
|
327
|
+
px: size.px,
|
|
328
|
+
py: size.py,
|
|
329
|
+
selected,
|
|
330
|
+
focused,
|
|
331
|
+
select,
|
|
332
|
+
setFocused,
|
|
333
|
+
triggers: triggers.current,
|
|
334
|
+
everSelected: (value: string) => seen.current.has(value),
|
|
335
|
+
lazyMount: props.lazyMount ?? false,
|
|
336
|
+
unmountOnExit: props.unmountOnExit ?? false,
|
|
337
|
+
}),
|
|
338
|
+
[
|
|
339
|
+
variant,
|
|
340
|
+
vertical,
|
|
341
|
+
props.activationMode,
|
|
342
|
+
props.fitted,
|
|
343
|
+
props.justify,
|
|
344
|
+
props.lazyMount,
|
|
345
|
+
props.unmountOnExit,
|
|
346
|
+
accent,
|
|
347
|
+
ground,
|
|
348
|
+
theme.text,
|
|
349
|
+
body,
|
|
350
|
+
size,
|
|
351
|
+
selected,
|
|
352
|
+
focused,
|
|
353
|
+
select,
|
|
354
|
+
],
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
return h(
|
|
358
|
+
LookContext.Provider,
|
|
359
|
+
{ value: look },
|
|
360
|
+
hx(
|
|
361
|
+
'box',
|
|
362
|
+
{
|
|
363
|
+
// The same fill-the-space default core's `<Tabs>` had: a notebook is
|
|
364
|
+
// usually the main region of whatever pane it is in. `style` wins for
|
|
365
|
+
// the one that is not.
|
|
366
|
+
style: styled(
|
|
367
|
+
{
|
|
368
|
+
flexGrow: 1,
|
|
369
|
+
minHeight: 0,
|
|
370
|
+
minWidth: 0,
|
|
371
|
+
flexDirection: vertical ? 'row' : 'column',
|
|
372
|
+
},
|
|
373
|
+
props.style,
|
|
374
|
+
),
|
|
375
|
+
'data-testname': props['data-testname'],
|
|
376
|
+
},
|
|
377
|
+
props.children,
|
|
378
|
+
),
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// --- list -------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
export interface TabsListProps {
|
|
385
|
+
style?: StyleInput;
|
|
386
|
+
'data-testname'?: string;
|
|
387
|
+
children?: ReactNode;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The strip of triggers — a single tab stop, walked with the arrows.
|
|
392
|
+
*
|
|
393
|
+
* The arrows are visual, the list is logical: in a mirrored horizontal strip
|
|
394
|
+
* the *next* trigger is the one to the left, and in either direction the walk
|
|
395
|
+
* follows where layout actually put each trigger (sorted by position, not by
|
|
396
|
+
* mount order), wraps at the ends, and skips disabled triggers. Home/End jump
|
|
397
|
+
* to the ends. In `manual` mode focus moves without selecting, and Enter or
|
|
398
|
+
* Space commits — those keys are not handled here, because core makes them
|
|
399
|
+
* the click the trigger's own `onClick` already is.
|
|
400
|
+
*/
|
|
401
|
+
export function TabsList(props: TabsListProps): ReactElement {
|
|
402
|
+
const look = useLook('TabsList');
|
|
403
|
+
const rtl = useDirection() === 'rtl';
|
|
404
|
+
const { vertical } = look;
|
|
405
|
+
|
|
406
|
+
// A `<TabsIndicator>` written into the strip, Chakra-style, is read here
|
|
407
|
+
// and handed to the triggers — the selected one draws it. It has to be a
|
|
408
|
+
// direct child for this to see it; anything else in the strip renders
|
|
409
|
+
// where it was written.
|
|
410
|
+
let indicator: TabsIndicatorProps | null = null;
|
|
411
|
+
for (const child of React.Children.toArray(props.children)) {
|
|
412
|
+
if (React.isValidElement(child) && child.type === TabsIndicator) {
|
|
413
|
+
indicator = child.props as TabsIndicatorProps;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const onKeyDown = (ev: KeyboardEvent) => {
|
|
418
|
+
// Where layout put each trigger is the one ordering that cannot drift
|
|
419
|
+
// from what the user sees — mount order can, after a remove and re-add.
|
|
420
|
+
const entries = [...look.triggers.entries()]
|
|
421
|
+
.map(([value, entry]) => ({ value, ...entry }))
|
|
422
|
+
.sort((a, b) =>
|
|
423
|
+
vertical ? a.node.abs.y - b.node.abs.y : a.node.abs.x - b.node.abs.x,
|
|
424
|
+
);
|
|
425
|
+
// Visual order back to logical: an RTL strip reads right to left. A
|
|
426
|
+
// vertical strip never mirrors — Up is Up.
|
|
427
|
+
const swap = !vertical && rtl;
|
|
428
|
+
if (swap) entries.reverse();
|
|
429
|
+
|
|
430
|
+
/** The next enabled trigger `delta` steps away, wrapping. */
|
|
431
|
+
const step = (from: number, delta: number) => {
|
|
432
|
+
const n = entries.length;
|
|
433
|
+
if (n === 0) return null;
|
|
434
|
+
for (let i = 1; i <= n; i++) {
|
|
435
|
+
const entry = entries[(((from + delta * i) % n) + n) % n];
|
|
436
|
+
if (entry && !entry.disabled) return entry;
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const goTo = (entry: { value: string; node: DrawnNode } | null) => {
|
|
442
|
+
if (!entry) return;
|
|
443
|
+
entry.node.focus();
|
|
444
|
+
if (!look.manual) look.select(entry.value);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const back = vertical ? XK_UP : swap ? XK_RIGHT : XK_LEFT;
|
|
448
|
+
const forward = vertical ? XK_DOWN : swap ? XK_LEFT : XK_RIGHT;
|
|
449
|
+
const current = entries.findIndex(
|
|
450
|
+
(entry) => entry.value === (look.focused ?? look.selected),
|
|
451
|
+
);
|
|
452
|
+
switch (ev.keysym) {
|
|
453
|
+
case back:
|
|
454
|
+
goTo(step(current === -1 ? 0 : current, -1));
|
|
455
|
+
return;
|
|
456
|
+
case forward:
|
|
457
|
+
goTo(step(current === -1 ? 0 : current, 1));
|
|
458
|
+
return;
|
|
459
|
+
case XK_HOME:
|
|
460
|
+
goTo(entries.find((entry) => !entry.disabled) ?? null);
|
|
461
|
+
return;
|
|
462
|
+
case XK_END:
|
|
463
|
+
goTo([...entries].reverse().find((entry) => !entry.disabled) ?? null);
|
|
464
|
+
return;
|
|
465
|
+
default:
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// What the strip itself draws, per variant. The `line` and `outline` rules
|
|
470
|
+
// are an absolutely positioned 1px box on the panel edge rather than a
|
|
471
|
+
// border: borders are all-edges here, and this has to sit on one side.
|
|
472
|
+
// `zIndex: -1` paints it under the triggers, so a selected `outline`
|
|
473
|
+
// trigger's opaque fill is what cuts it.
|
|
474
|
+
const rule =
|
|
475
|
+
look.variant === 'line' || look.variant === 'outline'
|
|
476
|
+
? hx('box', {
|
|
477
|
+
style: [
|
|
478
|
+
{ position: 'absolute', backgroundColor: '$border', zIndex: -1 },
|
|
479
|
+
vertical
|
|
480
|
+
? { top: 0, bottom: 0, end: 0, width: RULE }
|
|
481
|
+
: { left: 0, right: 0, bottom: 0, height: RULE },
|
|
482
|
+
],
|
|
483
|
+
})
|
|
484
|
+
: null;
|
|
485
|
+
|
|
486
|
+
const chrome: Style =
|
|
487
|
+
look.variant === 'enclosed'
|
|
488
|
+
? {
|
|
489
|
+
// Chakra's `bg.muted` chip, mixed from the palette this theme
|
|
490
|
+
// actually has — opaque, for the reason `over` gives.
|
|
491
|
+
backgroundColor: over(look.ground, look.text, 0.07),
|
|
492
|
+
borderRadius: CORNER,
|
|
493
|
+
padding: 3,
|
|
494
|
+
gap: 2,
|
|
495
|
+
}
|
|
496
|
+
: { gap: look.variant === 'outline' ? 0 : 2 };
|
|
497
|
+
|
|
498
|
+
return h(
|
|
499
|
+
IndicatorContext.Provider,
|
|
500
|
+
{ value: indicator },
|
|
501
|
+
hx(
|
|
502
|
+
'box',
|
|
503
|
+
{
|
|
504
|
+
role: 'tablist',
|
|
505
|
+
'aria-orientation': look.vertical ? 'vertical' : 'horizontal',
|
|
506
|
+
onKeyDown,
|
|
507
|
+
style: styled(
|
|
508
|
+
{
|
|
509
|
+
flexShrink: 0,
|
|
510
|
+
flexDirection: vertical ? 'column' : 'row',
|
|
511
|
+
alignItems: 'stretch',
|
|
512
|
+
...(look.justify && {
|
|
513
|
+
justifyContent:
|
|
514
|
+
look.justify === 'start'
|
|
515
|
+
? 'flex-start'
|
|
516
|
+
: look.justify === 'end'
|
|
517
|
+
? 'flex-end'
|
|
518
|
+
: 'center',
|
|
519
|
+
}),
|
|
520
|
+
...chrome,
|
|
521
|
+
},
|
|
522
|
+
props.style,
|
|
523
|
+
),
|
|
524
|
+
'data-testname': props['data-testname'],
|
|
525
|
+
},
|
|
526
|
+
rule,
|
|
527
|
+
props.children,
|
|
528
|
+
),
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// --- trigger ----------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
export interface TabsTriggerProps {
|
|
535
|
+
/** What this trigger selects — the `value` its `<TabsContent>` names. */
|
|
536
|
+
value: string;
|
|
537
|
+
/** Unclickable and skipped by the arrows. */
|
|
538
|
+
disabled?: boolean;
|
|
539
|
+
style?: StyleInput;
|
|
540
|
+
'data-testname'?: string;
|
|
541
|
+
children?: ReactNode;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* One tab. Prose and glyphs alike are legal children — a string is wrapped
|
|
546
|
+
* in `<text>`, and an `<Icon>` inherits the trigger's ink (its `size` does
|
|
547
|
+
* not inherit and has to be named).
|
|
548
|
+
*/
|
|
549
|
+
export function TabsTrigger(props: TabsTriggerProps): ReactElement {
|
|
550
|
+
const look = useLook('TabsTrigger');
|
|
551
|
+
const indicator = React.useContext(IndicatorContext);
|
|
552
|
+
const { value, disabled = false } = props;
|
|
553
|
+
const selected = look.selected === value;
|
|
554
|
+
const vertical = look.vertical;
|
|
555
|
+
|
|
556
|
+
// Registration is the ref callback: a new closure each render, so React
|
|
557
|
+
// re-runs it and a `disabled` that changed is re-registered. Order in the
|
|
558
|
+
// map churns with that, which is why the keyboard sorts by position
|
|
559
|
+
// instead of trusting it.
|
|
560
|
+
const register = (node: DrawnNode | null) => {
|
|
561
|
+
if (node) look.triggers.set(value, { node, disabled });
|
|
562
|
+
else look.triggers.delete(value);
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// What the trigger looks like, per variant. Each selected state is opaque
|
|
566
|
+
// where it has to cover the strip's rule, and every ink change is a
|
|
567
|
+
// `$token` so a `theme` prop above still wins.
|
|
568
|
+
const variantStyle: Style[] = [];
|
|
569
|
+
const hover: Style = {};
|
|
570
|
+
switch (look.variant) {
|
|
571
|
+
case 'line':
|
|
572
|
+
case 'plain':
|
|
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';
|
|
725
|
+
|
|
726
|
+
return hx(
|
|
727
|
+
'box',
|
|
728
|
+
{
|
|
729
|
+
role: 'tab',
|
|
730
|
+
'aria-selected': selected,
|
|
731
|
+
ref: register,
|
|
732
|
+
focusable: !disabled,
|
|
733
|
+
// Roving focus: the selected trigger is the strip's tab stop. With
|
|
734
|
+
// nothing selected yet every trigger is reachable, which is the lesser
|
|
735
|
+
// evil — a strip nobody can tab into is worse than two stops.
|
|
736
|
+
tabIndex: look.selected === undefined ? 0 : selected ? 0 : -1,
|
|
737
|
+
disabled,
|
|
738
|
+
onFocus: () => look.setFocused(value),
|
|
739
|
+
onBlur: () => look.setFocused(null),
|
|
740
|
+
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
|
+
),
|
|
762
|
+
'data-testname': props['data-testname'],
|
|
763
|
+
},
|
|
764
|
+
chip,
|
|
765
|
+
outlineShape,
|
|
766
|
+
withText(props.children, CAP_TRIM),
|
|
767
|
+
marker,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// --- indicator --------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
export interface TabsIndicatorProps {
|
|
774
|
+
/** Merged over the default chip — a wash of the accent, radius 4. */
|
|
775
|
+
style?: StyleInput;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* A marker that follows the selected trigger — Chakra's `Tabs.Indicator`,
|
|
780
|
+
* and like there it is what gives the `plain` variant a look.
|
|
781
|
+
*
|
|
782
|
+
* Write it as a direct child of `<TabsList>`. It renders nothing where it
|
|
783
|
+
* stands: the selected trigger draws it, as a box behind its own label — so
|
|
784
|
+
* layout owns the geometry and a resize, a font change or an RTL mirror can
|
|
785
|
+
* never leave the marker where a trigger used to be. What that costs is the
|
|
786
|
+
* slide: this renderer cannot animate between two mounts, so the marker
|
|
787
|
+
* moves in one step.
|
|
788
|
+
*/
|
|
789
|
+
export function TabsIndicator(props: TabsIndicatorProps): null {
|
|
790
|
+
useLook('TabsIndicator');
|
|
791
|
+
void props;
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// --- content ----------------------------------------------------------------
|
|
796
|
+
|
|
797
|
+
export interface TabsContentProps {
|
|
798
|
+
/** Which trigger shows this panel. */
|
|
799
|
+
value: string;
|
|
800
|
+
style?: StyleInput;
|
|
801
|
+
'data-testname'?: string;
|
|
802
|
+
children?: ReactNode;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* One panel. Every panel mounts up front and the unselected ones are
|
|
807
|
+
* `display: 'none'` — out of layout, still alive, so a form half-filled on
|
|
808
|
+
* another tab keeps its state. `lazyMount` defers a panel until its first
|
|
809
|
+
* selection; `unmountOnExit` gives the state up when it hides. Both are on
|
|
810
|
+
* the root, because they are a policy about the set, not about one panel.
|
|
811
|
+
*/
|
|
812
|
+
export function TabsContent(props: TabsContentProps): ReactElement | null {
|
|
813
|
+
const look = useLook('TabsContent');
|
|
814
|
+
const selected = look.selected === props.value;
|
|
815
|
+
|
|
816
|
+
if (!selected) {
|
|
817
|
+
if (look.unmountOnExit) return null;
|
|
818
|
+
if (look.lazyMount && !look.everSelected(props.value)) return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return hx(
|
|
822
|
+
'box',
|
|
823
|
+
{
|
|
824
|
+
role: 'tabpanel',
|
|
825
|
+
// A hidden panel is still mounted (that is the point), but it must not
|
|
826
|
+
// be read: `display: 'none'` takes it out of layout, and this takes it
|
|
827
|
+
// out of the accessibility tree.
|
|
828
|
+
'aria-hidden': selected ? undefined : true,
|
|
829
|
+
style: styled(
|
|
830
|
+
{
|
|
831
|
+
display: selected ? 'flex' : 'none',
|
|
832
|
+
flexDirection: 'column',
|
|
833
|
+
flexGrow: 1,
|
|
834
|
+
minHeight: 0,
|
|
835
|
+
minWidth: 0,
|
|
836
|
+
// Prose in a panel is body text. Named here because a bare
|
|
837
|
+
// `<text>`'s default ink is not the palette's — it has to inherit
|
|
838
|
+
// one, or a dark theme reads it in the light theme's black.
|
|
839
|
+
color: '$text',
|
|
840
|
+
...(look.vertical
|
|
841
|
+
? { paddingStart: PANEL_GAP }
|
|
842
|
+
: { paddingTop: PANEL_GAP }),
|
|
843
|
+
},
|
|
844
|
+
props.style,
|
|
845
|
+
),
|
|
846
|
+
'data-testname': props['data-testname'],
|
|
847
|
+
},
|
|
848
|
+
withText(props.children),
|
|
849
|
+
);
|
|
850
|
+
}
|