react-x11 2.51.0 → 2.52.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 +87 -54
- package/docs/README.md +410 -0
- package/docs/accessibility.md +628 -0
- package/docs/appearance.md +416 -0
- package/docs/architecture/accessibility.md +470 -0
- package/docs/architecture/animation.md +707 -0
- package/docs/architecture/container-queries.md +525 -0
- package/docs/architecture/custom-layout.md +334 -0
- package/docs/architecture/drag-and-drop.md +941 -0
- package/docs/architecture/element-layer-contents.md +622 -0
- package/docs/architecture/frame-pacing.md +255 -0
- package/docs/architecture/grid-layout.md +358 -0
- package/docs/architecture/protocol-efficiency.md +357 -0
- package/docs/architecture/video.md +1149 -0
- package/docs/architecture/windowed-regions.md +854 -0
- package/docs/click-to-component.md +125 -0
- package/docs/clipboard.md +281 -0
- package/docs/components.md +1749 -0
- package/docs/dbus.md +277 -0
- package/docs/debugging.md +464 -0
- package/docs/desktop-calendar.md +284 -0
- package/docs/desktop.md +648 -0
- package/docs/devtools.md +250 -0
- package/docs/drag-and-drop.md +663 -0
- package/docs/ecosystem/animation.md +394 -0
- package/docs/ecosystem/data-fetching.md +201 -0
- package/docs/ecosystem/dev-tooling.md +436 -0
- package/docs/ecosystem/forms.md +313 -0
- package/docs/ecosystem/headless.md +219 -0
- package/docs/ecosystem/i18n.md +89 -0
- package/docs/ecosystem/icons.md +410 -0
- package/docs/ecosystem/layout.md +216 -0
- package/docs/ecosystem/routing.md +146 -0
- package/docs/ecosystem/state.md +361 -0
- package/docs/ecosystem/testing.md +500 -0
- package/docs/ecosystem/theming.md +540 -0
- package/docs/ecosystem.md +473 -0
- package/docs/elements.md +2762 -0
- package/docs/embedding.md +288 -0
- package/docs/events.md +1025 -0
- package/docs/extending.md +2242 -0
- package/docs/eyedropper.md +223 -0
- package/docs/filedialog.md +258 -0
- package/docs/frame.md +580 -0
- package/docs/gl.md +170 -0
- package/docs/globalmenu.md +278 -0
- package/docs/glx.md +137 -0
- package/docs/macos.md +3295 -0
- package/docs/notifications.md +164 -0
- package/docs/packaging.md +611 -0
- package/docs/permissions.md +148 -0
- package/docs/react-features.md +430 -0
- package/docs/remote.md +223 -0
- package/docs/scale.md +339 -0
- package/docs/security.md +132 -0
- package/docs/styling.md +1928 -0
- package/docs/system.md +599 -0
- package/docs/testing.md +514 -0
- package/docs/typescript.md +117 -0
- package/docs/uri-schemes.md +380 -0
- package/docs/wayland-backend.md +591 -0
- package/docs/wayland.md +545 -0
- package/docs/windows-embedding.md +271 -0
- package/docs/windows-gl.md +221 -0
- package/docs/windows-integrations.md +233 -0
- package/docs/windows.md +1404 -0
- package/package.json +10 -3
- package/src/bus.js +39 -26
- package/src/cli/create.d.ts +43 -0
- package/src/cli/create.js +305 -0
- package/src/cli/env.js +484 -0
- package/src/cli/index.js +29 -2
- package/src/cli/templates/AGENTS.template.md +195 -0
- package/src/cli/templates/DESIGN.template.md +269 -0
- package/src/desktopintegration.js +2 -1
- package/src/desktopsettings.js +193 -24
- package/src/filedialog.js +3 -3
- package/src/keyboardstate.js +28 -0
- package/src/node.d.ts +10 -10
- package/src/portal.js +31 -0
- package/src/screencolor.js +5 -10
- package/src/types/system.d.ts +8 -6
- package/src/wayland/app.js +59 -0
- package/src/wayland/clipboard.js +14 -7
- package/src/wayland/connection.js +3 -0
- package/src/wayland/context2d.js +129 -10
- package/src/wayland/protocols/index.json +6 -0
- package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
- package/src/wayland/seat.js +28 -1
- package/src/wayland/swapchain.js +23 -0
- package/src/wayland/xkb.js +137 -0
|
@@ -0,0 +1,1749 @@
|
|
|
1
|
+
# Components
|
|
2
|
+
|
|
3
|
+
Every component takes **`style`** and merges it after its own, so an override
|
|
4
|
+
wins by position rather than by clobbering a computed value:
|
|
5
|
+
|
|
6
|
+
```jsx
|
|
7
|
+
<Button
|
|
8
|
+
label="Sign"
|
|
9
|
+
style={{ marginTop: 8, ':hover': { borderColor: '#333' } }}
|
|
10
|
+
/>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Props that a component consumes itself are never style — `ProgressBar`'s
|
|
14
|
+
`color`, `Dialog`'s `width`/`height` (a dialog is a real popup window, and
|
|
15
|
+
those are its window's size), `ContextMenu`'s `fontSize` (it measures labels
|
|
16
|
+
with it). Everything else is forwarded to the host box. See
|
|
17
|
+
[styling.md](styling.md).
|
|
18
|
+
|
|
19
|
+
Widget components are plain React built on the host elements — no
|
|
20
|
+
reconciler support involved. They live in the package root export.
|
|
21
|
+
|
|
22
|
+
All of them are screen-reader ready: roles, names, states and AT-driven
|
|
23
|
+
control come built in, and an `aria-label` (or a label child) is the only
|
|
24
|
+
thing a widget can need from you — [accessibility.md](accessibility.md)
|
|
25
|
+
lists what each one announces.
|
|
26
|
+
|
|
27
|
+
## Theming
|
|
28
|
+
|
|
29
|
+
**Widgets follow the desktop with nothing declared** — dark on a dark desktop,
|
|
30
|
+
light on a light one, the same way a GTK or Qt app does. See
|
|
31
|
+
[system appearance](appearance.md) for how that is worked out and how to pin
|
|
32
|
+
or override it.
|
|
33
|
+
|
|
34
|
+
`<ThemeProvider value={palette}>` gives every widget beneath it a palette,
|
|
35
|
+
layered over the scheme in force — so a partial one names what your app
|
|
36
|
+
changes and everything else keeps following. It carries **shape as well as
|
|
37
|
+
colour** — corner radius, border weight, text size and the padding inside a
|
|
38
|
+
control are most of what separates one platform's controls from another's:
|
|
39
|
+
|
|
40
|
+
| token | |
|
|
41
|
+
| --------------------------------------------------------------- | ------------------------------------ |
|
|
42
|
+
| `background` `surface` | the ground, and what is raised on it |
|
|
43
|
+
| `text` `textMuted` `border` | ink, secondary ink, edges |
|
|
44
|
+
| `accent` `accentHover` `accentText` | primary buttons, checks, fills |
|
|
45
|
+
| `hoverBackground` `hoverText` | selected rows, menu highlights |
|
|
46
|
+
| `surfaceHover` `track` `borderFocus` | hover fills, tracks, focus |
|
|
47
|
+
| `selection` `caret` `link` | selected text, the caret, links |
|
|
48
|
+
| `danger` `success` `warning` `info` (+`…Text`) | what the app says with colour |
|
|
49
|
+
| `accentActive` `surfaceActive` `textMutedActive` `dangerActive` | the pressed step of each fill |
|
|
50
|
+
| `radius` `radiusSmall` `borderWidth` | control shape |
|
|
51
|
+
| `radiusPopup` `radiusPopupItem` `radiusTooltip` | floating-surface shape |
|
|
52
|
+
| `fontSize` `paddingX` `paddingY` | control size |
|
|
53
|
+
| `fontFamily` `monoFamily` | the app's two faces |
|
|
54
|
+
| `direction` | which way the app reads |
|
|
55
|
+
|
|
56
|
+
### The ground and what is on it
|
|
57
|
+
|
|
58
|
+
`background` is what the **window** is — the fill under everything, painted
|
|
59
|
+
by the window itself. `surface` is what is raised off it: a control's fill, a
|
|
60
|
+
card, the sheet a menu or a dialog is drawn on. The light palette makes them
|
|
61
|
+
the same colour, because a white app on a white ground is what it has always
|
|
62
|
+
been; the dark one lifts `surface` a step, because a card at the ground's own
|
|
63
|
+
colour is a card you cannot see.
|
|
64
|
+
|
|
65
|
+
A palette that has one ground says so by naming one: **`surface` follows
|
|
66
|
+
`background` unless you name it**, so a theme that only recolours the app
|
|
67
|
+
keeps working and only a design that really raises its cards has a second
|
|
68
|
+
token to fill in.
|
|
69
|
+
|
|
70
|
+
```jsx
|
|
71
|
+
<ThemeProvider value={{ background: '#f6f8fa', surface: '#ffffff' }}>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`surfaceHover` and `surfaceActive` are that surface's own interaction steps,
|
|
75
|
+
which is what they have always been — before there was a `surface` to be the
|
|
76
|
+
steps of.
|
|
77
|
+
|
|
78
|
+
### What the app says with colour
|
|
79
|
+
|
|
80
|
+
`danger`, `success`, `warning` and `info` are the four things a screen has to
|
|
81
|
+
be able to say: this failed, this worked, look at this, here is a note. They
|
|
82
|
+
are what an alert, a badge, a validation message and a toast are coloured
|
|
83
|
+
from, and having them as tokens is what keeps a hard-coded `#e74c3c` — which
|
|
84
|
+
is a bruise on a dark desktop — out of application code.
|
|
85
|
+
|
|
86
|
+
Each works as **ink as well as fill**: every one clears 4.5:1 against its own
|
|
87
|
+
palette's ground, so the message under a field and the badge beside it are one
|
|
88
|
+
token.
|
|
89
|
+
|
|
90
|
+
```jsx
|
|
91
|
+
<text style={{ color: '$danger' }}>Password too short</text>
|
|
92
|
+
<box style={{ backgroundColor: '$danger' }}>
|
|
93
|
+
<text style={{ color: '$dangerText' }}>Delete</text>
|
|
94
|
+
</box>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For the tinted panel an alert usually wants — a wash of the colour rather
|
|
98
|
+
than the colour — tint the fill and leave the ink alone:
|
|
99
|
+
|
|
100
|
+
```jsx
|
|
101
|
+
import { tint } from 'react-x11/style';
|
|
102
|
+
<box style={{ backgroundColor: tint(theme.danger, 0.12) }}>
|
|
103
|
+
<text style={{ color: '$danger' }}>Could not save</text>
|
|
104
|
+
</box>;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Only `danger` has `…Hover` and `…Active`, because a destructive button is the
|
|
108
|
+
only status fill anyone presses; the other three are things the app says, not
|
|
109
|
+
things the user clicks.
|
|
110
|
+
|
|
111
|
+
The `…Text` four are the letters on a status fill, and **a palette almost
|
|
112
|
+
never sets them**: each is derived from the fill it goes on, as the more
|
|
113
|
+
legible of that palette's own `text` and `background`. So a theme whose
|
|
114
|
+
`warning` is a yellow gets dark letters on it without having thought about
|
|
115
|
+
it — and the same rule fixes `accentText`, which used to be inherited as
|
|
116
|
+
white onto accents that could not carry white.
|
|
117
|
+
|
|
118
|
+
`fontFamily` and `fontSize` are the type this app sets, and text that names
|
|
119
|
+
neither **takes them** — a `<text>`, a `<textinput>`, a widget's own label. So
|
|
120
|
+
`<ThemeProvider value={{ fontFamily: 'Inter', fontSize: 16 }}>` is where an app
|
|
121
|
+
says what it is set in, once, rather than on every label, and a `Select`'s rows
|
|
122
|
+
grow with it because a row is its label with even space all round. A `fontSize`
|
|
123
|
+
or `fontFamily` in a style still wins, the way a style property always wins
|
|
124
|
+
over what a node inherits.
|
|
125
|
+
|
|
126
|
+
The widgets that take a size of their own keep taking it: `MenuBar` and
|
|
127
|
+
`ContextMenu` have a `fontSize` prop because they measure their labels before
|
|
128
|
+
they have anywhere to measure them in, and `Table` has `rowHeight` because
|
|
129
|
+
every row has to be the same height for it to skip the ones off screen.
|
|
130
|
+
|
|
131
|
+
Both are CSS-style family lists — `'"JetBrains Mono", monospace'` names a
|
|
132
|
+
preference and a fallback.
|
|
133
|
+
|
|
134
|
+
`monoFamily` is the second face, and nothing unstyled reads it: it is there so
|
|
135
|
+
that every code surface in an app — a listing, a log pane, a hex dump, all
|
|
136
|
+
written by different components — can say `fontFamily: '$monoFamily'` and be
|
|
137
|
+
set from one place.
|
|
138
|
+
|
|
139
|
+
### Which way it reads
|
|
140
|
+
|
|
141
|
+
`direction` is `'ltr'` or `'rtl'`, and it is **seeded from the locale**: an
|
|
142
|
+
app started under an RTL locale is mirrored without being configured, the
|
|
143
|
+
way a GTK or Qt one is. Set it here to mirror a whole UI from one place:
|
|
144
|
+
|
|
145
|
+
```jsx
|
|
146
|
+
<ThemeProvider value={{ direction: settings.rtl ? 'rtl' : 'ltr' }}>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The provider plants the matching `direction` style property in the node tree
|
|
150
|
+
as it goes, so the **boxes** and the **widgets** mirror together. That is
|
|
151
|
+
why this is the way to do it rather than a bare `<box style={{ direction:
|
|
152
|
+
'rtl' }}>`: yoga mirrors boxes on its own, but which way an arrow key steps,
|
|
153
|
+
which way a chevron points and which side a submenu opens on are widget
|
|
154
|
+
decisions, read through `useDirection()`. Under a bare style property the
|
|
155
|
+
layout mirrors and those do not.
|
|
156
|
+
|
|
157
|
+
Most of the set needs no help — `Button`, `Checkbox`, `Radio`, `Switch` and
|
|
158
|
+
`ProgressBar` are rows and flex ratios, and come out mirrored on their own.
|
|
159
|
+
The rest each have one thing yoga could not answer:
|
|
160
|
+
|
|
161
|
+
| widget | what mirrors |
|
|
162
|
+
| ------------------------- | --------------------------------------------------- |
|
|
163
|
+
| `Slider` | the drag, and Left/Right (Up/Down do not) |
|
|
164
|
+
| `Tabs` | Left/Right walk the strip the way it is drawn |
|
|
165
|
+
| `Table` | column resizing, the sort mark, the header's scroll |
|
|
166
|
+
| `SplitPane` | which pane is first, the drag and its arrows |
|
|
167
|
+
| `MenuBar` / `ContextMenu` | submenus open to the start side, and their arrows |
|
|
168
|
+
| `Select`, `PasswordInput` | the field's own insets follow the text |
|
|
169
|
+
|
|
170
|
+
`<textinput>` and `<textarea>` mirror on the inside too — the value, the
|
|
171
|
+
placeholder and the caret are against the direction's start edge, and the
|
|
172
|
+
value is shaped at the box's base level rather than at its own first strong
|
|
173
|
+
character ([styling.md](styling.md#inside-an-editable-field)). What stays
|
|
174
|
+
put: Left/Right and Home/End step through the string rather than across the
|
|
175
|
+
screen.
|
|
176
|
+
|
|
177
|
+
See [styling.md](styling.md#direction-and-the-logical-edges) for the style
|
|
178
|
+
property and the logical edges.
|
|
179
|
+
|
|
180
|
+
`paddingY` is the space **you can see** — above the capitals and below the
|
|
181
|
+
baseline — not the space plus whatever the font's ascent left over: every
|
|
182
|
+
widget label is trimmed to its letters
|
|
183
|
+
([styling.md](styling.md#measuring-text-to-its-letters)), so a control is its
|
|
184
|
+
label band plus this twice. That makes it a larger number than the same look
|
|
185
|
+
would need in CSS, and it makes it mean the same thing in every typeface.
|
|
186
|
+
Rows on a popup follow the same rule — a menu row and a `Select` option are
|
|
187
|
+
their label with `padding` all round, so the space above a row's text is the
|
|
188
|
+
space beside it. So does `<textinput>`, which is what keeps a field the same
|
|
189
|
+
height as the `Button` and the `Select` next to it on a form; give it
|
|
190
|
+
`paddingY` and the three agree by construction
|
|
191
|
+
([styling.md](styling.md#measuring-text-to-its-letters)).
|
|
192
|
+
|
|
193
|
+
The `…Active` four are the colour a control takes **while it is held**, and
|
|
194
|
+
a palette almost never sets them: each is derived from the step the palette's
|
|
195
|
+
own hover already makes — `accent` → `accentHover` → one more of the same —
|
|
196
|
+
so it darkens a light theme and lightens a dark one. Set one explicitly and
|
|
197
|
+
it wins. See [The press state](#the-press-state) for why every control has
|
|
198
|
+
one.
|
|
199
|
+
|
|
200
|
+
The `radius…` three that name a popup are derived too, and from the **text
|
|
201
|
+
size** rather than from `radius`: a menu is a sheet laid over the window
|
|
202
|
+
where a button is a control cut into it, and half the body size is the
|
|
203
|
+
number desktops land near — 7px at a 14px body. The other two step in from
|
|
204
|
+
there, because a rounded thing inside a rounded thing needs the tighter
|
|
205
|
+
curve: `radiusPopupItem` is the highlight on a menu row, `radiusTooltip` the
|
|
206
|
+
tooltip bubble. A palette that moves `fontSize` and names none of them gets
|
|
207
|
+
all three in proportion; naming one pins it.
|
|
208
|
+
|
|
209
|
+
`radiusPopupItem` is a **ceiling**, not the radius a row gets. A rounded rect
|
|
210
|
+
inside a rounded rect only reads as one shape when the two curves share a
|
|
211
|
+
centre, which happens exactly when the inner radius is the outer one less the
|
|
212
|
+
gap between them — so a row's corner is `radiusPopup` minus the sheet's
|
|
213
|
+
hairline border minus the inset the row is padded in by, and the token caps
|
|
214
|
+
it. A theme that wants rounder highlights rounds the sheet they sit on: that
|
|
215
|
+
is the only change that can round both and keep them concentric.
|
|
216
|
+
|
|
217
|
+
They are only ever _seen_ where the display composites — a popup gives up
|
|
218
|
+
its corners by not painting them, and with nothing blending them those
|
|
219
|
+
pixels are black rather than the desktop, so the widgets ask the window
|
|
220
|
+
(`'@supports transparency'`, [styling.md](styling.md)) and stay square
|
|
221
|
+
where the answer is no.
|
|
222
|
+
|
|
223
|
+
There are two consumers of a palette, and one provider feeds both. Widgets
|
|
224
|
+
read it as React context through `useTheme()`; a `$token` in a style
|
|
225
|
+
resolves against the nearest `theme` **prop** in the node tree, which knows
|
|
226
|
+
nothing about React ([styling.md](styling.md#theme-tokens)). `ThemeProvider`
|
|
227
|
+
puts the merged palette on the context _and_ plants it in the tree, so
|
|
228
|
+
|
|
229
|
+
```jsx
|
|
230
|
+
<ThemeProvider value={dark}>
|
|
231
|
+
<box style={{ backgroundColor: '$background' }}>…</box>
|
|
232
|
+
</ThemeProvider>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
paints the palette rather than nothing. Nesting works the same either way:
|
|
236
|
+
an inner provider merges over the outer, as an inner `theme` prop does.
|
|
237
|
+
|
|
238
|
+
`useTheme()` returns the palette in force — complete, whatever the provider
|
|
239
|
+
above set — and is the same object token resolution sees:
|
|
240
|
+
|
|
241
|
+
```jsx
|
|
242
|
+
function Panel() {
|
|
243
|
+
const theme = useTheme();
|
|
244
|
+
return <box style={{ borderColor: theme.border }} />;
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
It is a bag of tokens, and indexes like one: a component that resolves a name
|
|
249
|
+
it was handed rather than one it wrote — a code block reading its own colour
|
|
250
|
+
map, a renderer resolving `$token` itself — writes `theme[name]` with no cast.
|
|
251
|
+
The named tokens keep their types, anything else is `unknown` and narrows.
|
|
252
|
+
What a provider _accepts_ stays closed (`Partial<Theme>`), because a typo in a
|
|
253
|
+
palette has nowhere else to be caught.
|
|
254
|
+
|
|
255
|
+
Where the palette reaches the tree depends on where the provider is written.
|
|
256
|
+
|
|
257
|
+
**Above the windows**, at the root of the tree, it draws nothing, and every
|
|
258
|
+
window under it takes the palette — its background and the type every
|
|
259
|
+
unstyled `<text>` falls back to as well as the `$token`s inside. Whatever
|
|
260
|
+
shape the windows arrive in: a component that renders one, a window that is
|
|
261
|
+
closed for now, several at once, a provider nested in another.
|
|
262
|
+
|
|
263
|
+
```jsx
|
|
264
|
+
root.render(
|
|
265
|
+
<ThemeProvider value={brand} colorScheme={settings.theme}>
|
|
266
|
+
<App />
|
|
267
|
+
</ThemeProvider>,
|
|
268
|
+
);
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Inside a window** it is a `<box>`, styled `{ flexGrow: 1 }` so an app-level
|
|
272
|
+
provider fills its parent; pass `style` to change that
|
|
273
|
+
(`style={{ flexGrow: 0 }}` around a single control). A window nests only in a
|
|
274
|
+
window, so a nested `<window>` under a provider written directly inside a
|
|
275
|
+
window is handed on to that window, and takes the palette with it. Further
|
|
276
|
+
down — a provider inside a `<box>` — there is no window to hand it to, and the
|
|
277
|
+
error says to move the provider.
|
|
278
|
+
|
|
279
|
+
Widgets plant the merged palette on their own root node too, so a `$token`
|
|
280
|
+
in a style you pass one resolves even with no provider anywhere.
|
|
281
|
+
|
|
282
|
+
`examples/themes.js` has three worked palettes — GitHub, macOS and Windows,
|
|
283
|
+
each in light and dark — and `npm run examples:theming` switches between
|
|
284
|
+
them at runtime.
|
|
285
|
+
|
|
286
|
+
## System icons
|
|
287
|
+
|
|
288
|
+
The glyphs the widgets here are drawn with, exported so an application and a
|
|
289
|
+
third-party widget can use the same ones — a toolbar you write and a `Select`
|
|
290
|
+
you did not should not disagree about which way a chevron points.
|
|
291
|
+
|
|
292
|
+
```jsx
|
|
293
|
+
import { Icon } from 'react-x11';
|
|
294
|
+
|
|
295
|
+
<Icon name="chevronDown" />
|
|
296
|
+
<Icon name="check" size={10} color={theme.accentText} />
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
| name | |
|
|
300
|
+
| ------------------------------------------------------ | ------------------------------------ |
|
|
301
|
+
| `chevronRight` `chevronLeft` `chevronDown` `chevronUp` | more this way: menus, twisties, sort |
|
|
302
|
+
| `check` `dash` `dot` | chosen, partly chosen, one of many |
|
|
303
|
+
| `close` `plus` `moreVertical` | dismiss, add, overflow |
|
|
304
|
+
| `eye` `eyeOff` | reveal a secret, hide it again |
|
|
305
|
+
|
|
306
|
+
| prop | |
|
|
307
|
+
| ------- | ----------------------------------------------------- |
|
|
308
|
+
| `name` | one of the above; anything else throws |
|
|
309
|
+
| `size` | the mark, px. Default: a shade under `theme.fontSize` |
|
|
310
|
+
| `color` | the ink. Default: `theme.text` |
|
|
311
|
+
|
|
312
|
+
Everything else goes to the host `<canvas>`, so a clickable one is
|
|
313
|
+
`<Icon name="close" onClick={…} focusable />`.
|
|
314
|
+
|
|
315
|
+
**`size` is the mark, not a grid it sits in.** This is the one place the set
|
|
316
|
+
departs from how lucide and its descendants are drawn: theirs put about 14px
|
|
317
|
+
of ink in a 24px box, so you reach for `size={20}` next to 14px text. Here
|
|
318
|
+
the ink runs corner to corner, so `size` is what you actually see and the
|
|
319
|
+
number to pick is the number you want — the `Select` chevron is
|
|
320
|
+
`capBand(fontSize)` because it should be as wide as the capitals beside it.
|
|
321
|
+
|
|
322
|
+
A chevron is the one glyph whose two axes differ: the arms are at 45°, so it
|
|
323
|
+
is as long as `size` along the way it points across and half that the other
|
|
324
|
+
way. `<Icon name="chevronDown" size={10} />` is 10 wide and 5 tall; the same
|
|
325
|
+
size of `chevronRight` is 5 wide and 10 tall.
|
|
326
|
+
|
|
327
|
+
### Affordances, not nouns
|
|
328
|
+
|
|
329
|
+
The set holds glyphs that say something about **the control**: there is more
|
|
330
|
+
here, this one is chosen, this closes, this is hidden. It holds no nouns —
|
|
331
|
+
no folder, no document, no save, no printer. Those belong to an icon theme
|
|
332
|
+
(lucide, an XDG icon theme, your own art), they are unbounded in number, and
|
|
333
|
+
a widget set that starts shipping them has taken on a design system.
|
|
334
|
+
|
|
335
|
+
That is also the answer to "why is X missing": if X names a thing rather
|
|
336
|
+
than an action the control affords, it is not going to be here. Bringing
|
|
337
|
+
your own set is expected and supported — a `<svg>` or a `<canvas>` goes
|
|
338
|
+
anywhere an `<Icon>` does, and `ContextMenu`'s `icon` takes either. The
|
|
339
|
+
platform's own set is there too:
|
|
340
|
+
[`<image src={{ symbol }}>`](elements.md#src-symbol---the-platforms-own-icons)
|
|
341
|
+
draws an SF Symbol on macOS and an icon from the desktop's icon theme
|
|
342
|
+
elsewhere, in the text colour.
|
|
343
|
+
|
|
344
|
+
The **drawings are not themable** for the same reason: the geometry is the
|
|
345
|
+
widget set's vocabulary, and an application that wants a different chevron
|
|
346
|
+
wants an icon library. Colour and size are yours; the shape is not.
|
|
347
|
+
|
|
348
|
+
### Colour inherits; size does not
|
|
349
|
+
|
|
350
|
+
`color` travels down the tree
|
|
351
|
+
([styling.md](styling.md#inheritance-the-ink-the-face-and-the-size)), so an
|
|
352
|
+
icon takes the ink of whatever it is written inside and needs nothing said
|
|
353
|
+
at the call site:
|
|
354
|
+
|
|
355
|
+
```jsx
|
|
356
|
+
<box style={{ color: theme.textMuted, ':hover': { color: theme.accent } }}>
|
|
357
|
+
<text>Open recent</text>
|
|
358
|
+
<Icon name="chevronRight" />
|
|
359
|
+
</box>
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
That covers the hover case too. `:hover` marks the row, `color` is
|
|
363
|
+
inherited, and the label and the icon both follow — which is why
|
|
364
|
+
`ContextMenu`'s submenu chevron carries no colour of its own any more. The `color` prop is for saying something the surrounding text does
|
|
365
|
+
not: a destructive action's mark, or a check drawn on an accent fill.
|
|
366
|
+
|
|
367
|
+
**`size` does not inherit, and is deliberately not `fontSize`.** A glyph is
|
|
368
|
+
a drawing rather than a letter — no baseline to sit on, no ascent to be
|
|
369
|
+
measured against — so it takes its default from the palette's `fontSize` and
|
|
370
|
+
stays put when a label beside it shrinks. Pass `size` for the one icon that
|
|
371
|
+
has to be bigger.
|
|
372
|
+
|
|
373
|
+
### What it costs to draw one
|
|
374
|
+
|
|
375
|
+
Each glyph is a drawing over `<canvas mono>`
|
|
376
|
+
([elements.md](elements.md#canvas)), which is a promise that everything it
|
|
377
|
+
paints is one colour it did not choose — so one drawing serves every state a
|
|
378
|
+
control puts it in, and the paint cache can keep one rendered copy of
|
|
379
|
+
`chevronDown` at 12px for every chevron on the screen at once.
|
|
380
|
+
|
|
381
|
+
The entry is **coverage**, so the colour is applied at composite time and
|
|
382
|
+
stays out of the key: one rendered copy of `chevronDown` at 12px serves the
|
|
383
|
+
resting row, the highlighted row, the disabled one and both colour schemes —
|
|
384
|
+
the trick the glyph cache runs on text, and the one `<svg>` gets for a
|
|
385
|
+
`fill="currentColor"` document. The size cannot leave the key, since a
|
|
386
|
+
coverage surface is pixels at a fixed size, so one icon at two sizes is two
|
|
387
|
+
entries.
|
|
388
|
+
|
|
389
|
+
The drawings are module-level, so re-rendering a list invalidates none of
|
|
390
|
+
its icons: `<canvas>` compares `onDraw` by identity, and a fresh closure per
|
|
391
|
+
render is a repaint per glyph.
|
|
392
|
+
|
|
393
|
+
`icons` is the map of raw drawings, for a widget that wants the glyph
|
|
394
|
+
without the component:
|
|
395
|
+
|
|
396
|
+
```jsx
|
|
397
|
+
import { icons } from 'react-x11';
|
|
398
|
+
|
|
399
|
+
<canvas
|
|
400
|
+
mono
|
|
401
|
+
cacheKey="check"
|
|
402
|
+
onDraw={icons.check}
|
|
403
|
+
style={{ width: 12, height: 12, color: '$textMuted' }}
|
|
404
|
+
/>;
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
They are **decoration by default** — `aria-hidden`, because the meaning is
|
|
408
|
+
already on the control, in its `role` and its `aria-expanded`. Name one
|
|
409
|
+
(`aria-hidden={false} aria-label="Close"`) only when the icon _is_ the
|
|
410
|
+
control and nothing else says so.
|
|
411
|
+
|
|
412
|
+
## Basic controls
|
|
413
|
+
|
|
414
|
+
`Button`, `Checkbox`, `Radio`/`RadioGroup`, `Switch` and `ProgressBar` share
|
|
415
|
+
one piece of plumbing, `useControl(disabled, onActivate)`: it makes the
|
|
416
|
+
control focusable, activates it on click — and so on Space and Enter, which
|
|
417
|
+
are a click on anything with an `onClick` unless Ctrl, Alt or Super make
|
|
418
|
+
them a shortcut's ([events.md](events.md#space-and-enter-are-a-click)), the
|
|
419
|
+
widgets having no key mapping of their own any more — sets the pointer cursor,
|
|
420
|
+
and expresses hover, press and focus
|
|
421
|
+
feedback as `:hover`/`:active`/`:focus` style blocks rather than React state
|
|
422
|
+
— so moving the pointer over a control repaints one node instead of
|
|
423
|
+
rendering.
|
|
424
|
+
|
|
425
|
+
`Button`, `Checkbox`, `Switch` and `ProgressBar` take `style`, merged after
|
|
426
|
+
their own so an override wins by position, and forward any remaining props
|
|
427
|
+
to the host box. `Radio` is the exception: it takes only the props listed
|
|
428
|
+
below, and the group around it carries the layout.
|
|
429
|
+
|
|
430
|
+
```jsx
|
|
431
|
+
import { Button, Checkbox, RadioGroup, Radio, Switch, ProgressBar } from 'react-x11';
|
|
432
|
+
|
|
433
|
+
<Button primary onPress={save}>
|
|
434
|
+
Save
|
|
435
|
+
</Button>
|
|
436
|
+
<Checkbox checked={wrap} onChange={(ev) => setWrap(ev.value)}>Wrap lines</Checkbox>
|
|
437
|
+
<Switch checked={live} onChange={(ev) => setLive(ev.value)} />
|
|
438
|
+
<ProgressBar value={0.4} style={{ width: 200 }} />
|
|
439
|
+
|
|
440
|
+
<RadioGroup value={size} onChange={(ev) => setSize(ev.value)}>
|
|
441
|
+
<Radio value="s">Small</Radio>
|
|
442
|
+
<Radio value="m">Medium</Radio>
|
|
443
|
+
</RadioGroup>;
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Label text is the children (or a `label` prop); a bare string is wrapped in
|
|
447
|
+
a `<text>` for you, so `<Button>Save</Button>` needs no `<text>`.
|
|
448
|
+
|
|
449
|
+
### Native bezels
|
|
450
|
+
|
|
451
|
+
On a backend that renders the platform's own control bezels — today the
|
|
452
|
+
**Cocoa** backend, never X11 — `Button`, `Checkbox`, `Radio`, `Switch`,
|
|
453
|
+
`Slider` and `Select` wear AppKit's pixels by default, while interaction,
|
|
454
|
+
focus, keyboard and accessibility stay this library's shared implementation.
|
|
455
|
+
The mechanism is a bezel _image_ drawn through the ordinary paint path, not
|
|
456
|
+
an embedded `NSView`: the press model and the event routing are behaviour
|
|
457
|
+
this project considers part of its identity, and a real `NSControl` would
|
|
458
|
+
take them over wholesale. [macos.md](macos.md#native-controls) is the
|
|
459
|
+
design record.
|
|
460
|
+
|
|
461
|
+
**Default native, seam out**, at three levels:
|
|
462
|
+
|
|
463
|
+
```jsx
|
|
464
|
+
<ThemeProvider value={{ controls: 'drawn' }}>
|
|
465
|
+
{' '}
|
|
466
|
+
{/* the app's identity */}
|
|
467
|
+
<Button native={false}>Brand</Button> {/* one control */}
|
|
468
|
+
</ThemeProvider>;
|
|
469
|
+
|
|
470
|
+
const canGoNative = useSupports('nativeControls'); // what the backend has
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
`controls` is `'auto'` (bezels where the backend has them), `'native'` (ask
|
|
474
|
+
for them, and hear about it in development where there are none) or
|
|
475
|
+
`'drawn'` (this library's rendering everywhere). It lives in the theme
|
|
476
|
+
rather than in `style` because whether an app looks native is an
|
|
477
|
+
app-identity decision, not a per-node one. `native={false}` is the escape
|
|
478
|
+
hatch for the one custom-branded control — and a control naming its own
|
|
479
|
+
colours in `style` should also say it, because a bezel the system draws
|
|
480
|
+
ignores them.
|
|
481
|
+
|
|
482
|
+
Under a native bezel, `style` sizes the box the control _sits in_ rather
|
|
483
|
+
than the control itself. A `<Button>` or a `<Select>` keeps AppKit's own
|
|
484
|
+
height and is centred in whatever footprint the layout gives it, so
|
|
485
|
+
`style={{ height: 44 }}`, a `height: '100%'` in a taller parent, a
|
|
486
|
+
`flexGrow: 1` or a taller neighbour in a stretching row leave a 22pt
|
|
487
|
+
control in a 44pt slot instead of stretching a bezel the system draws at
|
|
488
|
+
one size. The slack around it is not part of the control: a press up there
|
|
489
|
+
does nothing, as it does in AppKit. Drawn controls stretch as they always
|
|
490
|
+
have — their chrome is drawn to the box and their label is centred in it,
|
|
491
|
+
so a stretched drawn control is merely roomy.
|
|
492
|
+
|
|
493
|
+
The one thing that makes a native button taller is its own label. Squeezed
|
|
494
|
+
narrower than its label, a `<Button>` wraps it at its words — native or
|
|
495
|
+
drawn — and grows by the lines it gained, which on macOS is drawn with
|
|
496
|
+
AppKit's flexible-height push bezel, the one it has for a title that wraps.
|
|
497
|
+
A row that should never wrap its buttons wants `flexWrap: 'wrap'` on the
|
|
498
|
+
row instead, so the buttons move to a second row rather than their labels
|
|
499
|
+
to a second line.
|
|
500
|
+
|
|
501
|
+
### The press state
|
|
502
|
+
|
|
503
|
+
Every control here activates on the **release** — that is what a click is.
|
|
504
|
+
So every one of them also has a distinct look while it is being _held_,
|
|
505
|
+
because otherwise a click a user takes half a second over is half a second
|
|
506
|
+
of a control that has visibly not heard them, and the change, when it comes,
|
|
507
|
+
reads as the machine being slow rather than the hand being unhurried.
|
|
508
|
+
|
|
509
|
+
Four states, all different: resting, hovered, held, and hovered again on the
|
|
510
|
+
release. The held one is drawn on the press **even though the press itself
|
|
511
|
+
does nothing** — it acknowledges the input, it does not promise the outcome.
|
|
512
|
+
A press dragged off the control drops it, and picking the control back up
|
|
513
|
+
restores it, so the way it looks always agrees with whether releasing there
|
|
514
|
+
would activate anything.
|
|
515
|
+
|
|
516
|
+
Nothing is needed to get this: it is what the widgets do. Writing a control
|
|
517
|
+
of your own, `:active` is the state block for it —
|
|
518
|
+
[styling.md](styling.md#inline-pseudo-states) — and the palette's
|
|
519
|
+
`accentActive`/`surfaceActive`/`textMutedActive`/`dangerActive` are the
|
|
520
|
+
colours.
|
|
521
|
+
|
|
522
|
+
### The change event, and `name`
|
|
523
|
+
|
|
524
|
+
Every value control — `Checkbox`, `Switch`, `RadioGroup`, `Select`,
|
|
525
|
+
`Slider` — calls `onChange(ev)` with a change event, the **same signature**
|
|
526
|
+
`<textinput>` and `<textarea>` use
|
|
527
|
+
([elements.md](elements.md#the-change-event)). The new value is `ev.value`:
|
|
528
|
+
|
|
529
|
+
```jsx
|
|
530
|
+
<Checkbox checked={agreed} onChange={(ev) => setAgreed(ev.value)}>
|
|
531
|
+
I agree
|
|
532
|
+
</Checkbox>
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
```js
|
|
536
|
+
{ type: 'change',
|
|
537
|
+
target: { type: 'checkbox', name: 'agree', value: true, checked: true },
|
|
538
|
+
currentTarget: /* the same object */,
|
|
539
|
+
name: 'agree', value: true }
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
One signature is the point. It is what lets a form library's handler be
|
|
543
|
+
passed straight to any control in the library, with no per-widget adapter:
|
|
544
|
+
|
|
545
|
+
```jsx
|
|
546
|
+
<textinput name="host" value={f.values.host} onChange={f.handleChange} />
|
|
547
|
+
<Checkbox name="agree" checked={f.values.agree} onChange={f.handleChange} />
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
`target` is a plain descriptor rather than a node, and that is the one place
|
|
551
|
+
this differs from the host elements: a widget is several nodes with no single
|
|
552
|
+
element holding its value, so there is nothing honest to point at. Its shape
|
|
553
|
+
is what formik's `handleChange` and react-hook-form's event reader
|
|
554
|
+
destructure — `target.type` is how they tell a checkbox from a text field,
|
|
555
|
+
which is why it is set even though nothing in react-x11 reads it. There is no
|
|
556
|
+
`preventDefault`: the value has already changed by the time the handler runs.
|
|
557
|
+
|
|
558
|
+
**The line is `name`.** A widget that takes one is a form field and reports
|
|
559
|
+
an event. `Tabs`, `Table` and the menus are not form fields — you would never
|
|
560
|
+
register a tab strip with formik — and keep their plain callbacks (`Tabs`
|
|
561
|
+
calls `onChange(id)`).
|
|
562
|
+
|
|
563
|
+
`name` is otherwise inert; it exists so a form library has somewhere to put
|
|
564
|
+
one. See [docs/ecosystem/forms.md](ecosystem/forms.md).
|
|
565
|
+
|
|
566
|
+
### `Button`
|
|
567
|
+
|
|
568
|
+
| prop | |
|
|
569
|
+
| -------------------- | ----------------------------------------------------------------------- |
|
|
570
|
+
| `children` / `label` | the label |
|
|
571
|
+
| `onPress()` | click, Space or Enter |
|
|
572
|
+
| `variant` | `'solid'` (default), `'outline'`, `'ghost'` — the chrome |
|
|
573
|
+
| `size` | `'medium'` (default) or `'small'` — the compact metric |
|
|
574
|
+
| `primary` | speaks in the accent: the fill when solid, the ink and border otherwise |
|
|
575
|
+
| `disabled` | inert, dimmed, not focusable |
|
|
576
|
+
|
|
577
|
+
Chrome and colour are two axes, and they compose. `variant` is how much box
|
|
578
|
+
the button carries — `solid` a fill, `outline` a border on nothing, `ghost`
|
|
579
|
+
neither, for the affordance that sits inside other content (a `✕` on a chip,
|
|
580
|
+
a delete on a form row) and must not add a box to the row it lives in.
|
|
581
|
+
`primary` is whose colours it speaks in. So a dialog footer is an `outline`
|
|
582
|
+
beside a `primary` solid, and a toolbar's loudest icon is
|
|
583
|
+
`primary variant="ghost"` — every combination keeps the widget states, which
|
|
584
|
+
is the point of not hand-drawing the chrome-less ones. All variants keep the
|
|
585
|
+
border _width_, so a mixed row lines up to the pixel.
|
|
586
|
+
|
|
587
|
+
`size="small"` halves the control padding — the toolbar and inline-row
|
|
588
|
+
metric. It is derived from the palette, so a theme that moves `paddingY`
|
|
589
|
+
moves both sizes together, which a hand-made `style={{ height: 22 }}` would
|
|
590
|
+
not.
|
|
591
|
+
|
|
592
|
+
A label with less room than it needs wraps at its words, the lines centred
|
|
593
|
+
under one another, and the button grows to hold them — a row too narrow
|
|
594
|
+
for its buttons squeezes each to its longest word first, as it would any
|
|
595
|
+
paragraph ([Native bezels](#native-bezels) has what that does to a native
|
|
596
|
+
button).
|
|
597
|
+
|
|
598
|
+
An **element** child inherits the resolved label colour — the ink is set on
|
|
599
|
+
the button's box and `color` inherits ([styling.md](styling.md)) — so an
|
|
600
|
+
icon+label button is correct by construction in every state, disabled
|
|
601
|
+
included, with nothing passed to the icon:
|
|
602
|
+
|
|
603
|
+
```jsx
|
|
604
|
+
<Button primary disabled={busy} onPress={run}>
|
|
605
|
+
<Icon name="chevronRight" />
|
|
606
|
+
Continue
|
|
607
|
+
</Button>
|
|
608
|
+
|
|
609
|
+
<Button variant="ghost" size="small" aria-label="Remove" onPress={remove}>
|
|
610
|
+
<Icon name="close" />
|
|
611
|
+
</Button>
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
### `Checkbox`
|
|
615
|
+
|
|
616
|
+
| prop | |
|
|
617
|
+
| -------------------- | ----------------------------------------- |
|
|
618
|
+
| `checked` | current value (controlled) |
|
|
619
|
+
| `onChange(ev)` | a change event; the value is `ev.value` |
|
|
620
|
+
| `name` | field name, for form libraries |
|
|
621
|
+
| `children` / `label` | label to the right of the 16px check well |
|
|
622
|
+
| `disabled` | inert, dimmed |
|
|
623
|
+
|
|
624
|
+
### `Radio` / `RadioGroup`
|
|
625
|
+
|
|
626
|
+
`RadioGroup` takes `value`, `onChange(ev)`, `name`, `style` and any
|
|
627
|
+
box props; each `Radio` takes the `value` it selects, plus `children`/`label`
|
|
628
|
+
and `disabled` — and nothing else, so per-radio styling goes on the group. A
|
|
629
|
+
`Radio` outside a `RadioGroup` throws rather than silently doing nothing.
|
|
630
|
+
`name` lives on the group the way it does in HTML: the group is the field.
|
|
631
|
+
|
|
632
|
+
Arrow keys move the selection through the group in **mount order**,
|
|
633
|
+
wrapping — Up/Left back, Down/Right forward — which is how a native radio
|
|
634
|
+
group behaves; click or Space selects the focused one.
|
|
635
|
+
|
|
636
|
+
### `Switch`
|
|
637
|
+
|
|
638
|
+
`checked`, `onChange(ev)`, `name` and `disabled`, the same semantics
|
|
639
|
+
as `Checkbox` in a sliding pill. The thumb is absolutely positioned and
|
|
640
|
+
animates on `left` (`transition: { left: 120 }`) because `justifyContent`
|
|
641
|
+
would flip between the ends with nothing in between to animate — the worked
|
|
642
|
+
example in [styling.md](styling.md#transitions).
|
|
643
|
+
|
|
644
|
+
### `ProgressBar`
|
|
645
|
+
|
|
646
|
+
Determinate with a `value`, indeterminate without one.
|
|
647
|
+
|
|
648
|
+
| prop | |
|
|
649
|
+
| --------------- | ------------------------------------------------------- |
|
|
650
|
+
| `value` | 0 to 1, clamped |
|
|
651
|
+
| `indeterminate` | working, extent unknown — a block slides across forever |
|
|
652
|
+
| `color` | fill colour (defaults to the theme accent) |
|
|
653
|
+
| `trackColor` | the groove (defaults to the theme track) |
|
|
654
|
+
| `height` | bar thickness, default 8; the corner radius follows it |
|
|
655
|
+
|
|
656
|
+
```jsx
|
|
657
|
+
<ProgressBar value={0.4} style={{ width: 200 }} />
|
|
658
|
+
<ProgressBar indeterminate style={{ width: 200 }} />
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
The determinate fill is expressed as flex ratios rather than a percentage
|
|
662
|
+
width. A percentage child resolves against space that is still being
|
|
663
|
+
measured, which fed back into the track's intrinsic width — a card with a
|
|
664
|
+
fuller bar came out wider than one with an empty bar.
|
|
665
|
+
|
|
666
|
+
`indeterminate` is the worked example of a
|
|
667
|
+
[style loop](styling.md#loops): the block is absolutely positioned and
|
|
668
|
+
travels on the logical `start` edge from `-40%` to `100%` of the track, so it
|
|
669
|
+
mirrors with the text direction, is right at any width without measuring
|
|
670
|
+
anything, and repaints the bar rather than the window. It reports
|
|
671
|
+
`aria-busy` **with no value at all**, which is what tells a screen reader
|
|
672
|
+
that a percentage is not coming.
|
|
673
|
+
|
|
674
|
+
Under [reduced motion](system.md#usedesktopsettings--how-the-desktop-wants-an-app-to-feel)
|
|
675
|
+
the loop never starts — that part is core's, and every loop in every app gets
|
|
676
|
+
it — and the block parks in the middle of the track instead of at its
|
|
677
|
+
off-screen starting point. A still bar that shows nothing would say the app
|
|
678
|
+
had stopped; a full one would say it had finished.
|
|
679
|
+
|
|
680
|
+
## `Select`
|
|
681
|
+
|
|
682
|
+
A dropdown whose menu is a real override-redirect `<popup>` window anchored
|
|
683
|
+
below the trigger.
|
|
684
|
+
|
|
685
|
+
```jsx
|
|
686
|
+
import { Select } from 'react-x11';
|
|
687
|
+
|
|
688
|
+
<Select
|
|
689
|
+
style={{ width: 160 }}
|
|
690
|
+
value={color}
|
|
691
|
+
options={[
|
|
692
|
+
{ value: '#2980b9', label: 'Blue' },
|
|
693
|
+
{ value: '#c0392b', label: 'Red' },
|
|
694
|
+
'green', // shorthand: value === label
|
|
695
|
+
]}
|
|
696
|
+
onChange={(ev) => setColor(ev.value)}
|
|
697
|
+
placeholder="Pick a color…"
|
|
698
|
+
/>;
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
| prop | |
|
|
702
|
+
| ----------------------- | ----------------------------------------- |
|
|
703
|
+
| `options` | array of `{value, label}` or plain values |
|
|
704
|
+
| `value`, `onChange(ev)` | selection |
|
|
705
|
+
| `name` | field name, for form libraries |
|
|
706
|
+
| `placeholder` | trigger text when nothing is selected |
|
|
707
|
+
| `style` + any box props | forwarded to the trigger box |
|
|
708
|
+
| `labelStyle` | the caption's style, over `text` |
|
|
709
|
+
| `chevronStyle` | the chevron's style, over `textMuted` |
|
|
710
|
+
| `native`, `nativeMenu` | the platform's bezel, and its menu |
|
|
711
|
+
|
|
712
|
+
The two style slots restyle what is inside the trigger, and `style` restyles
|
|
713
|
+
the trigger itself, so a select that has to wear somebody else's look — a
|
|
714
|
+
toolbar's, or a web page's — is the widget restyled and keeps the menu, the
|
|
715
|
+
keyboard, type-ahead and the focus ring. A borderless select that sits in a
|
|
716
|
+
line of text:
|
|
717
|
+
|
|
718
|
+
```jsx
|
|
719
|
+
<Select
|
|
720
|
+
value={sort}
|
|
721
|
+
options={['Newest', 'Oldest', 'Most replies']}
|
|
722
|
+
onChange={(ev) => setSort(ev.value)}
|
|
723
|
+
style={{
|
|
724
|
+
borderWidth: 0,
|
|
725
|
+
paddingLeft: 0,
|
|
726
|
+
paddingRight: 0,
|
|
727
|
+
backgroundColor: 'transparent',
|
|
728
|
+
':hover': { backgroundColor: 'transparent' },
|
|
729
|
+
}}
|
|
730
|
+
labelStyle={{ color: theme.accent, fontWeight: 'bold' }}
|
|
731
|
+
chevronStyle={{ color: theme.accent }}
|
|
732
|
+
/>
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
The caption's `fontSize`, as a number, is the one the chevron is sized
|
|
736
|
+
against, since the chevron is as tall as the capitals beside it and an
|
|
737
|
+
`<Icon>`'s size does not inherit. `chevronStyle={{ display: 'none' }}` leaves
|
|
738
|
+
it out, for a trigger whose arrow is drawn by something else. A select with
|
|
739
|
+
either slot is drawn, where `controls: 'auto'` would otherwise use the
|
|
740
|
+
platform's popup bezel, whose pixels no style reaches. The menu is not
|
|
741
|
+
restyled: it is the same surface a menu is.
|
|
742
|
+
|
|
743
|
+
**The menu can be the platform's.** On macOS, with `@windowkit/appkit` 0.20
|
|
744
|
+
or later, a select under the native bezel drops a real `NSMenu`: the chosen
|
|
745
|
+
row placed over the trigger and checked, the menu at least the trigger's
|
|
746
|
+
width, AppKit's tracking, type-select and VoiceOver. A drawn menu sized as
|
|
747
|
+
one was the best a drawn widget could do; this is the menu itself.
|
|
748
|
+
`nativeMenu` is that choice on its own: it follows the trigger by default,
|
|
749
|
+
`true` asks for the platform's menu under a drawn trigger too — what a
|
|
750
|
+
browser does with a `<select>` a page styled, and what `<Html>` does — set
|
|
751
|
+
at the caption's size and in the family its style names when the system
|
|
752
|
+
has it, and `false` keeps the drawn menu. Elsewhere, and on an older
|
|
753
|
+
bridge, the menu is drawn whatever the prop says. The menu is the
|
|
754
|
+
platform's to dismiss: Escape, a click outside, or the application losing
|
|
755
|
+
focus, and the select hears a pick or nothing.
|
|
756
|
+
|
|
757
|
+
Behavior: the menu opens on the **press** — Space and Enter toggle it too;
|
|
758
|
+
Escape, focus loss, or picking closes it; the option list scrolls when taller
|
|
759
|
+
than 220px; the trigger participates in Tab traversal.
|
|
760
|
+
|
|
761
|
+
**A caption is one line.** The trigger is as wide as the form made it and the
|
|
762
|
+
value in it can be any length, so a caption with no room left ends in a `…`
|
|
763
|
+
rather than wrapping to a second line — which there is no height for: the
|
|
764
|
+
trigger is one row of label, and under a native popup bezel it is AppKit's
|
|
765
|
+
own fixed height, so the wrapped line drew outside the control. The menu is
|
|
766
|
+
sized to its longest option rather than to the trigger, so its rows are
|
|
767
|
+
whole; where the screen is narrower than that longest label and the sheet is
|
|
768
|
+
clamped to it, they end in a `…` too.
|
|
769
|
+
|
|
770
|
+
The menu is the **same surface a menu is**: an ARGB popup rounded at
|
|
771
|
+
`radiusPopup` with a hairline border where the display composites, and the
|
|
772
|
+
active option is the same pill at `radiusPopupItem`, inset from the sheet's
|
|
773
|
+
edge. A dropdown and a menu are one kind of thing, and two shapes for it
|
|
774
|
+
would only say that the widgets were written at different times.
|
|
775
|
+
|
|
776
|
+
Opening on the press rather than the release is deliberate, and it is the
|
|
777
|
+
one control whose answer to a press is more than a colour: a dropdown exists
|
|
778
|
+
to be looked at, so waiting for the button to come back up before showing it
|
|
779
|
+
wastes the whole time the button is down. It is what every desktop toolkit
|
|
780
|
+
does. A press while the menu is up dismisses it through the popup's pointer
|
|
781
|
+
grab, so the two never fight over the toggle.
|
|
782
|
+
|
|
783
|
+
Keyboard, while the trigger is focused (the popup is override-redirect and
|
|
784
|
+
never takes focus, so the trigger keeps handling keys with the menu open):
|
|
785
|
+
|
|
786
|
+
| key | closed | open |
|
|
787
|
+
| ----------------- | -------------- | --------------------------------------- |
|
|
788
|
+
| `Down` / `Up` | opens the menu | move the active option, wrapping around |
|
|
789
|
+
| `Home` / `End` | — | first / last option |
|
|
790
|
+
| `Enter` / `Space` | opens the menu | pick the active option |
|
|
791
|
+
| `Escape` | — | close without picking |
|
|
792
|
+
|
|
793
|
+
The menu opens with the current value active, hovering an option makes it
|
|
794
|
+
active (pointer and keyboard share one highlight), and the active option is
|
|
795
|
+
scrolled into view.
|
|
796
|
+
|
|
797
|
+
**PageUp/PageDown** move by a menu viewport (`MAX_MENU_HEIGHT / ITEM_HEIGHT`
|
|
798
|
+
options), clamping at the ends rather than wrapping the way the arrows do.
|
|
799
|
+
|
|
800
|
+
**Losing the window** closes it too. That is not the same event as the
|
|
801
|
+
trigger's own blur: a window losing the window manager's focus leaves the
|
|
802
|
+
node inside it focused and merely stops it looking active — so without this
|
|
803
|
+
the menu would still be up over an application you have switched away from,
|
|
804
|
+
holding the pointer grab it opened with. The menus do the same.
|
|
805
|
+
|
|
806
|
+
**Type-ahead.** Typing letters jumps to the matching option: with the menu
|
|
807
|
+
open it moves the highlight, and with it closed it changes the value
|
|
808
|
+
outright, the way a native select does. Keystrokes within 700ms accumulate
|
|
809
|
+
into one query (`b`,`l` finds _blueberry_, not _banana_); repeating a
|
|
810
|
+
single letter cycles through the options starting with it.
|
|
811
|
+
|
|
812
|
+
### Theming
|
|
813
|
+
|
|
814
|
+
`Select` has no provider of its own — it reads the palette from
|
|
815
|
+
[`ThemeProvider`](#theming) like every other widget. The trigger is
|
|
816
|
+
`background`/`text` in a `border` box that turns `borderFocus` on focus or
|
|
817
|
+
while open, the chevron and the placeholder text are `textMuted`, and the menu
|
|
818
|
+
highlight is `hoverBackground`/`hoverText`.
|
|
819
|
+
|
|
820
|
+
## `Calendar` / `DatePicker`
|
|
821
|
+
|
|
822
|
+
Moved to [`@react-x11/components`](ecosystem.md). A date picker is a locale
|
|
823
|
+
problem before it is a widget, and the calendar systems, holiday sets and
|
|
824
|
+
range semantics people need do not belong in a renderer.
|
|
825
|
+
|
|
826
|
+
## `PasswordInput`
|
|
827
|
+
|
|
828
|
+
A masked field whose mask is a **scribble**, not a row of bullets.
|
|
829
|
+
|
|
830
|
+
```jsx
|
|
831
|
+
<PasswordInput
|
|
832
|
+
value={secret}
|
|
833
|
+
onChange={(ev) => setSecret(ev.value)}
|
|
834
|
+
onSubmit={() => signIn()}
|
|
835
|
+
/>
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
| prop | |
|
|
839
|
+
| ----------------------------- | -------------------------------------- |
|
|
840
|
+
| `value` / `defaultValue` | the secret; controlled with `onChange` |
|
|
841
|
+
| `onChange(ev)` | `ev.value` is the new value |
|
|
842
|
+
| `onSubmit(value)` | Enter |
|
|
843
|
+
| `placeholder` | shown while empty (`'Password'`) |
|
|
844
|
+
| `revealable` | show the eye at all, default `true` |
|
|
845
|
+
| `revealed` / `onRevealChange` | drive the reveal yourself |
|
|
846
|
+
| `maxLength` | in code points |
|
|
847
|
+
| `drawMask(ctx, info)` | draw the mask yourself |
|
|
848
|
+
| `disabled` | not focusable, not editable |
|
|
849
|
+
|
|
850
|
+
### Why a scribble
|
|
851
|
+
|
|
852
|
+
Bullets answer the wrong question. They report **how many characters** have
|
|
853
|
+
been typed — countably, from across the room — and they report almost nothing
|
|
854
|
+
about the keystroke that just landed, because one more identical dot at the
|
|
855
|
+
end of a row of identical dots is the least visible change a field could
|
|
856
|
+
make. The feedback is weakest exactly where a password field needs it, and
|
|
857
|
+
strongest exactly where it should not be.
|
|
858
|
+
|
|
859
|
+
So the mask is a single stroke through points chosen by a generator seeded
|
|
860
|
+
from the window id and a hash of the value. Every keystroke reseeds it, so
|
|
861
|
+
**the whole curve moves on every character** — feedback you cannot miss — and
|
|
862
|
+
nothing in the shape is per-character: the pen visits a number of points taken
|
|
863
|
+
from the mask's _width_, where a character is worth about half a point, so no
|
|
864
|
+
part of the stroke can be matched to anything typed.
|
|
865
|
+
|
|
866
|
+
It is a **scribble rather than a waveform**, and that is a decision rather
|
|
867
|
+
than a look. A stroke whose `x` only ever increases is the plot of a
|
|
868
|
+
function, and the eye reads it as one — value against position, meaning in
|
|
869
|
+
the peaks — however wild the `y` is. So the points are laid out one per
|
|
870
|
+
column, which is what makes the stroke cover the width it was given, and then
|
|
871
|
+
visited **out of order**: the pen doubles back, crosses what it has already
|
|
872
|
+
drawn, and leaves loops. The shuffle is local rather than global, because in
|
|
873
|
+
a box seventeen pixels tall a jump right across it is a long shallow scratch,
|
|
874
|
+
and a maskful of those is not a scribble either.
|
|
875
|
+
|
|
876
|
+
What does grow is the width, because a mask that did not would say nothing
|
|
877
|
+
about progress. Each position contributes an advance drawn from a **second,
|
|
878
|
+
window-seeded** stream, so the width only ever grows as you type, never
|
|
879
|
+
twitches when a character is replaced, and is not a clean multiple of
|
|
880
|
+
anything a glance could divide.
|
|
881
|
+
|
|
882
|
+
The honest limit, since a mask that oversells itself is worse than one that
|
|
883
|
+
does not: **this hides a glance, not a recording.** Someone watching the
|
|
884
|
+
field grow keystroke by keystroke still counts the keystrokes, and a long
|
|
885
|
+
password still sits in a visibly different bracket from a short one.
|
|
886
|
+
|
|
887
|
+
### Two states, and what holds across both
|
|
888
|
+
|
|
889
|
+
**Masked**, editing is smaller than `<textinput>`'s, because a scribble has
|
|
890
|
+
nowhere to put a caret: type, Backspace, Ctrl+Backspace / Ctrl+U / Delete to
|
|
891
|
+
clear, Ctrl+V or Shift+Insert to paste, Enter to submit. No caret, no
|
|
892
|
+
selection, no undo history — a rewindable secret is not a feature.
|
|
893
|
+
|
|
894
|
+
**Revealed**, it is an ordinary text input, because that is what it looks
|
|
895
|
+
like and anything else would be a trap. A real `<textinput>` takes the mask's
|
|
896
|
+
place: caret, selection, arrow keys, a click into the middle of the word,
|
|
897
|
+
undo, the edit menu. Focus follows the swap in both directions, and the
|
|
898
|
+
reveal ends when the keyboard leaves the widget — not when it moves between
|
|
899
|
+
the field and the input inside it.
|
|
900
|
+
|
|
901
|
+
What holds in **both** states is that nothing leaves by a selection. Ctrl+C
|
|
902
|
+
and Ctrl+X do nothing, the revealed input carries
|
|
903
|
+
[`sensitive`](elements.md#sensitive) so its menu has no Cut or Copy, and
|
|
904
|
+
neither state ever takes PRIMARY, so a middle click in another window cannot
|
|
905
|
+
spend it. The line is between what is on screen and what is on the clipboard:
|
|
906
|
+
the first stops being visible when the field is hidden, the second is
|
|
907
|
+
readable by every client on the display until something else takes it.
|
|
908
|
+
|
|
909
|
+
While the value is masked it is **never laid out and never drawn**: the mask
|
|
910
|
+
is measured from one reference character, so the secret does not enter ntk's
|
|
911
|
+
shaping cache and its glyphs never reach the X server. Revealing it costs
|
|
912
|
+
what revealing it costs. The eye is a pointer affordance and not a tab stop,
|
|
913
|
+
as GTK's peek icon is; `revealed` + `onRevealChange` put the toggle wherever
|
|
914
|
+
your keyboard can reach it.
|
|
915
|
+
|
|
916
|
+
Caps Lock is reported while it is on, from the modifier state on the keys as
|
|
917
|
+
they arrive — the mistake that a masked field otherwise lets you make four
|
|
918
|
+
times before telling you.
|
|
919
|
+
|
|
920
|
+
### Drawing your own mask
|
|
921
|
+
|
|
922
|
+
`drawMask(ctx, info)` replaces the scribble entirely; `info` is
|
|
923
|
+
`{ width, height, seed, color, length }`, where `width` is the mask width the
|
|
924
|
+
field worked out and `seed` is the value-and-window seed. The scribble itself
|
|
925
|
+
is `strokeScribble` in `src/components/scribble.js`, which is worth reading
|
|
926
|
+
before replacing it — the reasoning for each number is there.
|
|
927
|
+
|
|
928
|
+
```jsx
|
|
929
|
+
<PasswordInput
|
|
930
|
+
drawMask={(ctx, { width, height, color }) => {
|
|
931
|
+
ctx.fillStyle = color;
|
|
932
|
+
ctx.fillRect(0, height / 2 - 1, width, 2);
|
|
933
|
+
}}
|
|
934
|
+
/>
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
### Password managers
|
|
938
|
+
|
|
939
|
+
Paste and typing are the two seams that reach a field on this desktop, and
|
|
940
|
+
both work here: XTEST auto-type arrives as ordinary key events, and Ctrl+V
|
|
941
|
+
takes a secret from the clipboard with control characters stripped so a
|
|
942
|
+
manager's trailing newline stays out of it. Which manager does what, what the
|
|
943
|
+
window title has to do with it, and the seams that are _not_ a field —
|
|
944
|
+
Secret Service, the sandbox portal, AT-SPI — are in
|
|
945
|
+
[desktop.md](desktop.md#password-fields-and-password-managers).
|
|
946
|
+
|
|
947
|
+
## `Slider`
|
|
948
|
+
|
|
949
|
+
A draggable value control.
|
|
950
|
+
|
|
951
|
+
```jsx
|
|
952
|
+
import { Slider } from 'react-x11';
|
|
953
|
+
|
|
954
|
+
<Slider
|
|
955
|
+
value={volume}
|
|
956
|
+
min={0}
|
|
957
|
+
max={100}
|
|
958
|
+
step={5}
|
|
959
|
+
style={{ width: 200 }}
|
|
960
|
+
onChange={(ev) => setVolume(ev.value)}
|
|
961
|
+
/>;
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
| prop | |
|
|
965
|
+
| ----------------------- | ------------------------------------------------------- |
|
|
966
|
+
| `value`, `onChange(ev)` | current value (controlled) |
|
|
967
|
+
| `name` | field name, for form libraries |
|
|
968
|
+
| `min`, `max`, `step` | range and quantisation (defaults 0, 100, 1) |
|
|
969
|
+
| `height` | the bar thickness (default 4); width comes from `style` |
|
|
970
|
+
| `disabled` | inert, dimmed |
|
|
971
|
+
| `thumbStyle` | the thumb's style, over its default |
|
|
972
|
+
| `trackStyle` | the track's style, over `height` and `track` |
|
|
973
|
+
| `fillStyle` | the filled part of the track, over `accent` |
|
|
974
|
+
|
|
975
|
+
The three style slots restyle the drawn parts without giving up what the
|
|
976
|
+
widget does — the drag, the keyboard, the `aria-*` values, RTL and the target
|
|
977
|
+
size. The flat slider most design systems draw is:
|
|
978
|
+
|
|
979
|
+
```jsx
|
|
980
|
+
<Slider
|
|
981
|
+
value={volume}
|
|
982
|
+
onChange={(ev) => setVolume(ev.value)}
|
|
983
|
+
style={{ width: 240 }}
|
|
984
|
+
thumbStyle={{
|
|
985
|
+
width: 14,
|
|
986
|
+
height: 14,
|
|
987
|
+
borderWidth: 0,
|
|
988
|
+
backgroundColor: theme.accent,
|
|
989
|
+
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.3)',
|
|
990
|
+
}}
|
|
991
|
+
trackStyle={{ height: 3, marginStart: 7, marginEnd: 7 }}
|
|
992
|
+
/>
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
The thumb's `width` and `height`, as numbers, are the sizes the widget works
|
|
996
|
+
with: the drag's travel is the track less one thumb, and the control is as tall
|
|
997
|
+
as the taller of thumb and track, with its target still grown to 24. A
|
|
998
|
+
`marginStart` and `marginEnd` of half a thumb on the track are what let the
|
|
999
|
+
thumb overhang its ends. A slider with any slot is drawn, where `controls: 'auto'` would
|
|
1000
|
+
otherwise use the platform's bezel, whose pixels no style reaches.
|
|
1001
|
+
|
|
1002
|
+
Dragging uses [pointer capture](events.md#pointer-capture): the press
|
|
1003
|
+
captures, so the thumb keeps following a pointer that has wandered far
|
|
1004
|
+
outside the widget, and releasing out there still ends the drag.
|
|
1005
|
+
|
|
1006
|
+
Keyboard: arrows step, `Home`/`End` jump to the ends, `PageUp`/`PageDown`
|
|
1007
|
+
move by ten steps.
|
|
1008
|
+
|
|
1009
|
+
The thumb is centred on its value, so the usable travel is the track width
|
|
1010
|
+
minus one thumb width — otherwise `min` and `max` would be unreachable.
|
|
1011
|
+
|
|
1012
|
+
## `Tooltip`
|
|
1013
|
+
|
|
1014
|
+
A hover hint in a `<popup>`, so it can extend past the owner window.
|
|
1015
|
+
|
|
1016
|
+
```jsx
|
|
1017
|
+
import { Tooltip } from 'react-x11';
|
|
1018
|
+
|
|
1019
|
+
<Tooltip label="Back to zero">
|
|
1020
|
+
<Button onPress={reset}>Reset</Button>
|
|
1021
|
+
</Tooltip>;
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
| prop | |
|
|
1025
|
+
| ----------------------- | ---------------------------------------------------------------------- |
|
|
1026
|
+
| `label` | the hint: a string, or an element (nothing shows without it) |
|
|
1027
|
+
| `direction` | `'auto'` (default), `'top'`, `'bottom'`, `'left'`, `'right'` |
|
|
1028
|
+
| `delay` | ms of hover before showing (default 500) |
|
|
1029
|
+
| `fontSize` | label size, also used to size the popup |
|
|
1030
|
+
| `width`, `height` | pin an axis of the bubble exactly; anything not pinned is measured |
|
|
1031
|
+
| `maxWidth`, `maxHeight` | cap what an element label measures out to, inside the screen's own cap |
|
|
1032
|
+
|
|
1033
|
+
Wraps its children in a row box carrying the hover handlers and the anchor
|
|
1034
|
+
ref, so it composes around any element. Hides immediately on leave and on
|
|
1035
|
+
mousedown — a tooltip lingering over the menu you just opened is the
|
|
1036
|
+
classic annoyance.
|
|
1037
|
+
|
|
1038
|
+
**One at a time**, per connection: a hint belongs to where the pointer is,
|
|
1039
|
+
and there is one pointer. A trigger taking the hover dismisses whatever is
|
|
1040
|
+
showing straight away rather than at the end of its own delay, so two are
|
|
1041
|
+
never up together saying different things about the same place. This does
|
|
1042
|
+
not fight the safe-polygon grace that lets a hint with content in it be
|
|
1043
|
+
reached — a trigger underneath an open hint cannot be hovered, the popup
|
|
1044
|
+
being a window above it.
|
|
1045
|
+
|
|
1046
|
+
`direction` is which side of the trigger it opens on. The default, `'auto'`,
|
|
1047
|
+
takes the first side the hint fits on, preferring above — measured against
|
|
1048
|
+
the **screen**, because a popup is a real X window and the screen is what
|
|
1049
|
+
bounds it. A named side is a preference rather than a promise either way:
|
|
1050
|
+
it still flips to its opposite instead of opening off-screen. (`placement`
|
|
1051
|
+
is the older name for the same prop and still wins where it is given.)
|
|
1052
|
+
|
|
1053
|
+
A **string** `label` is measured and the popup sized around it, because a
|
|
1054
|
+
`<popup>` is a real X window and needs its size before layout. An
|
|
1055
|
+
**element** label is measured too — nothing to size, nothing to guess: the
|
|
1056
|
+
popup is rendered once [`hidden`](elements.md#hidden--realized-laid-out-not-on-screen)
|
|
1057
|
+
at its natural size (a real layout of the real content, capped at the screen
|
|
1058
|
+
and at `maxWidth`/`maxHeight`), the size is read back, and the same
|
|
1059
|
+
placement math runs before anything is mapped. Both commits land in the same
|
|
1060
|
+
task and the popup maps last, so it is only ever on screen placed and at its
|
|
1061
|
+
final size. The element gets the bubble to fill, with no padding imposed on
|
|
1062
|
+
it:
|
|
1063
|
+
|
|
1064
|
+
```jsx
|
|
1065
|
+
<Tooltip
|
|
1066
|
+
label={
|
|
1067
|
+
<box style={{ flexGrow: 1, padding: 10, gap: 7 }}>
|
|
1068
|
+
<text style={{ color: '#f5f6fa' }}>Scratch volume</text>
|
|
1069
|
+
<ProgressBar value={used} />
|
|
1070
|
+
</box>
|
|
1071
|
+
}
|
|
1072
|
+
maxWidth={260}
|
|
1073
|
+
direction="right"
|
|
1074
|
+
>
|
|
1075
|
+
<box>…</box>
|
|
1076
|
+
</Tooltip>
|
|
1077
|
+
```
|
|
1078
|
+
|
|
1079
|
+
`width`/`height` still pin an axis exactly: give both and the measuring pass
|
|
1080
|
+
is skipped entirely (the guaranteed-fit mode element labels used to
|
|
1081
|
+
require); give one and the other is measured for it, so `width={340}` is a
|
|
1082
|
+
fixed column whose height fits the message. The size is settled at open —
|
|
1083
|
+
content that changes size while the hint is up keeps the bubble it opened
|
|
1084
|
+
with until the next open, which is a hint's lifetime away.
|
|
1085
|
+
|
|
1086
|
+
Anything renders in there, widgets included — it is a real tree in a real
|
|
1087
|
+
window, not a rich-text label. `examples/tooltips.jsx` has both kinds.
|
|
1088
|
+
|
|
1089
|
+
**The palette inside the bubble is upside down.** A tooltip is drawn in the
|
|
1090
|
+
palette's ink so that it reads as a label over the desktop rather than as
|
|
1091
|
+
another panel of the app — so inside it, `$background` is the bubble and
|
|
1092
|
+
`$text` is the ink on it, published through a `ThemeProvider` so that
|
|
1093
|
+
`useTheme()` agrees. Write the content the way you would write anything
|
|
1094
|
+
else and it is legible in both schemes; hard-code a light text colour and
|
|
1095
|
+
it will be invisible on the light bubble a dark palette gives it. `textMuted`
|
|
1096
|
+
is derived for the same reason — the palette's own is a grey chosen against
|
|
1097
|
+
the app's background, not against this one — and `$surface` is the bubble
|
|
1098
|
+
too, so a card written for the app does not light up inside a tooltip.
|
|
1099
|
+
|
|
1100
|
+
Where the display composites, the popup is an ARGB window: a bubble rounded
|
|
1101
|
+
at `radiusTooltip`, no border, and a small arrow pointing back at the middle
|
|
1102
|
+
of the trigger with everything neither covers left transparent. Without a
|
|
1103
|
+
compositor it is the square opaque rectangle it has always been and there is
|
|
1104
|
+
no arrow — those pixels would be black rather than empty.
|
|
1105
|
+
|
|
1106
|
+
## `Dialog`
|
|
1107
|
+
|
|
1108
|
+
A dialog in a `<popup>`, centred over the owner window — and one the
|
|
1109
|
+
**window manager knows is a dialog**: it is a managed window with
|
|
1110
|
+
`WM_TRANSIENT_FOR` pointing at its owner, so it is framed, movable, closable
|
|
1111
|
+
through the WM, out of the taskbar and alt-tab list, and (on a full EWMH
|
|
1112
|
+
window manager) stacked above its owner and iconified with it.
|
|
1113
|
+
|
|
1114
|
+
```jsx
|
|
1115
|
+
import { Button, Dialog } from 'react-x11';
|
|
1116
|
+
|
|
1117
|
+
<Dialog
|
|
1118
|
+
open={confirming}
|
|
1119
|
+
title="Clear the form?"
|
|
1120
|
+
onClose={() => setConfirming(false)}
|
|
1121
|
+
actions={
|
|
1122
|
+
<>
|
|
1123
|
+
<Button label="Cancel" onPress={() => setConfirming(false)} />
|
|
1124
|
+
<Button primary autoFocus label="Clear" onPress={clear} />
|
|
1125
|
+
</>
|
|
1126
|
+
}
|
|
1127
|
+
>
|
|
1128
|
+
The name and the greeting below it will be discarded.
|
|
1129
|
+
</Dialog>;
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
| prop | |
|
|
1133
|
+
| ---------- | ---------------------------------------------------------------------------------------------------- |
|
|
1134
|
+
| `open` | renders nothing when false; the popup exists only while true |
|
|
1135
|
+
| `title` | names the dialog, drawn once: the frame's caption when managed, a bold in-content heading when not |
|
|
1136
|
+
| `children` | body content; strings become `<text>` |
|
|
1137
|
+
| `actions` | elements for the right-aligned button row |
|
|
1138
|
+
| `onClose` | Escape, or the window manager's close button |
|
|
1139
|
+
| `managed` | `false` for the override-redirect popup 1.x shipped (below) |
|
|
1140
|
+
| `width` | popup width (default 360); `'auto'` for the content's |
|
|
1141
|
+
| `height` | popup height (default `'auto'`: the content's, capped at the monitor — [below](#size-and-placement)) |
|
|
1142
|
+
|
|
1143
|
+
The focus behaviour is the **renderer's**, not the component's: `trapFocus`
|
|
1144
|
+
keeps Tab inside the dialog, stops presses elsewhere from moving focus, and
|
|
1145
|
+
hands focus back to whatever had it — usually the button that opened the
|
|
1146
|
+
dialog — when it closes. See
|
|
1147
|
+
[Focus scopes](events.md#focus-scopes-modals). Put `autoFocus` on a control
|
|
1148
|
+
inside to pick the first stop; with nothing to focus, the dialog surface
|
|
1149
|
+
takes focus itself (`tabIndex={-1}`) so Escape and Tab work immediately.
|
|
1150
|
+
|
|
1151
|
+
Escape closes because keys go to the focused node inside the popup and
|
|
1152
|
+
bubble out through the popup's place in the JSX tree; so does the window
|
|
1153
|
+
manager's close button, through `onCloseRequest`. **Pointer modality is not
|
|
1154
|
+
enforced** — widgets in the owner window stay clickable behind the dialog —
|
|
1155
|
+
so it is for confirmations, not for guarding state. (`_NET_WM_STATE_MODAL`
|
|
1156
|
+
is the mechanism that would enforce it, and it means nothing without the
|
|
1157
|
+
`WM_TRANSIENT_FOR` this now sets; that is why the property had to come
|
|
1158
|
+
first.)
|
|
1159
|
+
|
|
1160
|
+
### `managed`
|
|
1161
|
+
|
|
1162
|
+
| `managed` | what you get |
|
|
1163
|
+
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1164
|
+
| `true` (default) | a WM-managed window: frame, titlebar, movable, WM close button, transient for its owner, out of the taskbar. A press outside does **not** close it. |
|
|
1165
|
+
| `false` | the override-redirect popup: no frame, not movable, a pointer grab, and a press anywhere outside calls `onClose`. |
|
|
1166
|
+
|
|
1167
|
+
The `title` follows the frame: managed, it is the frame's caption and the
|
|
1168
|
+
content starts with the body; unmanaged there is no frame, so the title is
|
|
1169
|
+
drawn as a bold heading at the top of the content instead. It is never drawn
|
|
1170
|
+
twice, and it names the dialog for accessibility either way.
|
|
1171
|
+
|
|
1172
|
+
They are one choice, not two: a client-side pointer grab over a window the
|
|
1173
|
+
window manager is trying to let the user drag swallows the press that would
|
|
1174
|
+
start the drag, so `grab` and the frame turn on and off together. A managed
|
|
1175
|
+
dialog staying open when you click elsewhere is not a regression — it is what
|
|
1176
|
+
a dialog does. `managed={false}` is the right shape for a transient
|
|
1177
|
+
confirmation on a display with no window manager at all.
|
|
1178
|
+
|
|
1179
|
+
The owner is resolved automatically: `Dialog` already keeps an out-of-flow
|
|
1180
|
+
`<box>` inside the owner window for placement, and a ref to any drawn node
|
|
1181
|
+
resolves to the window that owns it
|
|
1182
|
+
([`transientFor`](elements.md#transientfor--a-window-that-belongs-to-another)).
|
|
1183
|
+
|
|
1184
|
+
### Size and placement
|
|
1185
|
+
|
|
1186
|
+
**The height is the content's** by default (`height="auto"`): ten lines and
|
|
1187
|
+
a row of buttons make a dialog as tall as ten lines and a row of buttons. The
|
|
1188
|
+
popup measures its content, centres itself over the owner window at that
|
|
1189
|
+
size and only then opens — it is born the right size in the right place,
|
|
1190
|
+
never shown at a guess and corrected
|
|
1191
|
+
([`placement: 'center'`](elements.md#centred-over-something)).
|
|
1192
|
+
|
|
1193
|
+
- **Capped at the monitor.** Content taller than the usable height of the
|
|
1194
|
+
monitor caps the dialog there; the body scrolls, and the `actions` row stays
|
|
1195
|
+
pinned at the bottom of the window. The body is a tab stop only while it has
|
|
1196
|
+
something to scroll. A numeric `height` scrolls the same way when the
|
|
1197
|
+
content needs more than it, where the overflow, buttons included, used to
|
|
1198
|
+
be cut off at the bottom edge (#940).
|
|
1199
|
+
- **Following its content.** A dialog whose content changes while it is open
|
|
1200
|
+
re-fits to it, growing from its **top edge**: the title and the field the
|
|
1201
|
+
user is looking at stay where they are, and only the bottom moves. It moves
|
|
1202
|
+
up only as far as it takes to stay on the monitor. A managed dialog the user
|
|
1203
|
+
dragged aside grows where they put it.
|
|
1204
|
+
- **Centred once,** over where the owner window was when the dialog opened,
|
|
1205
|
+
and not tracked after that: moving the owner does not drag the dialog with
|
|
1206
|
+
it, which is the window manager's call on a managed one.
|
|
1207
|
+
|
|
1208
|
+
`width` stays a number by default, 360: a body of prose wraps to it, where a
|
|
1209
|
+
natural width would be as wide as its longest sentence. `width="auto"` is
|
|
1210
|
+
there for a dialog whose content has a width of its own (a grid of fields, an
|
|
1211
|
+
image). A number for either axis is that size exactly.
|
|
1212
|
+
|
|
1213
|
+
`centerRect(node, {width, height})` is the same centring as a function,
|
|
1214
|
+
exported alongside `anchorRect` for window-centred popups of your own that
|
|
1215
|
+
know their size; one that does not uses `<popup anchor={{ rect, placement:
|
|
1216
|
+
'center' }}>` and lets the window work it out.
|
|
1217
|
+
|
|
1218
|
+
## `MenuBar` / `ContextMenu`
|
|
1219
|
+
|
|
1220
|
+
Pull-down and right-click menus, both rendered in `<popup>` windows so they
|
|
1221
|
+
escape the owner window, both anchored with `useAnchor`.
|
|
1222
|
+
|
|
1223
|
+
On the Cocoa backend with native controls, the popups are sized as NSMenu
|
|
1224
|
+
sizes its own — 22pt rows, the 13pt menu font — so a context menu sits
|
|
1225
|
+
beside the system's like one of them ([macos.md](macos.md#menus-the-global-menu-finally-at-home)).
|
|
1226
|
+
|
|
1227
|
+
```jsx
|
|
1228
|
+
import { MenuBar, ContextMenu } from 'react-x11';
|
|
1229
|
+
|
|
1230
|
+
<MenuBar
|
|
1231
|
+
menus={[
|
|
1232
|
+
{
|
|
1233
|
+
label: 'File',
|
|
1234
|
+
items: [
|
|
1235
|
+
{ label: 'New', shortcut: [['Control', 'N']], onSelect: newFile },
|
|
1236
|
+
{ type: 'separator' },
|
|
1237
|
+
{ label: 'Save As…', enabled: false },
|
|
1238
|
+
{
|
|
1239
|
+
label: 'Wrap lines',
|
|
1240
|
+
toggleType: 'checkmark',
|
|
1241
|
+
toggleState: wrap ? 1 : 0,
|
|
1242
|
+
onSelect: toggleWrap,
|
|
1243
|
+
},
|
|
1244
|
+
],
|
|
1245
|
+
},
|
|
1246
|
+
]}
|
|
1247
|
+
/>;
|
|
1248
|
+
|
|
1249
|
+
<ContextMenu items={items} style={{ flexGrow: 1 }}>
|
|
1250
|
+
<text>Right-click me</text>
|
|
1251
|
+
</ContextMenu>;
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
Item shape: `{ label, onSelect, type, items, enabled, visible, shortcut,
|
|
1255
|
+
toggleType, toggleState, icon, iconName, disposition, key }`. Both take an
|
|
1256
|
+
`onSelect(item)` prop as well, fired after the item's own. A bar menu opens on
|
|
1257
|
+
the **press**, for the reason `Select` does.
|
|
1258
|
+
|
|
1259
|
+
**A context menu's items can come from the right-click.** The click records
|
|
1260
|
+
only where it happened; what the menu shows, how big it is and where it fits
|
|
1261
|
+
are taken from the `items` of the render that follows. So a row that says
|
|
1262
|
+
which thing was right-clicked in its own `onContextMenu`, with the items
|
|
1263
|
+
derived from that, gets a menu for that thing, sized for it, on the first
|
|
1264
|
+
right-click. Items still empty in that render open nothing: no popup, no
|
|
1265
|
+
pointer grab, and the focus left where the press put it.
|
|
1266
|
+
|
|
1267
|
+
```jsx
|
|
1268
|
+
const [target, setTarget] = useState(null);
|
|
1269
|
+
|
|
1270
|
+
<ContextMenu items={target ? itemsFor(target) : []}>
|
|
1271
|
+
{rows.map((row) => (
|
|
1272
|
+
<box key={row.id} onContextMenu={() => setTarget(row)}>
|
|
1273
|
+
<text>{row.name}</text>
|
|
1274
|
+
</box>
|
|
1275
|
+
))}
|
|
1276
|
+
</ContextMenu>;
|
|
1277
|
+
```
|
|
1278
|
+
|
|
1279
|
+
**`shortcut` is a binding, not a label.** It is a list of alternatives, each a
|
|
1280
|
+
list of modifier tokens ending in the key — `[['Control', 'S']]` — and a
|
|
1281
|
+
mounted menu answers it: Ctrl+S fires that item's `onSelect` without the menu
|
|
1282
|
+
being opened, gated by the item's own `enabled` and `visible`, and it keeps
|
|
1283
|
+
working when the desktop's panel has taken the menu over. One array is then
|
|
1284
|
+
the drawn menu, the exported dbusmenu payload, the `aria-keyshortcuts`
|
|
1285
|
+
announcement and the binding, so there is nothing to keep in step by hand. The
|
|
1286
|
+
chord rules, the vocabulary the key tokens come from, `useAccelerator` for a
|
|
1287
|
+
shortcut that is not in a menu, and `accelerators={false}` for an app with its
|
|
1288
|
+
own dispatcher are all in [events.md](events.md#accelerators).
|
|
1289
|
+
|
|
1290
|
+
**The vocabulary is `com.canonical.dbusmenu`'s**, and that is not incidental.
|
|
1291
|
+
On a desktop whose panel shows application menus, `MenuBar` hands this array
|
|
1292
|
+
straight over and stops drawing — no configuration, no second authoring model,
|
|
1293
|
+
the same line of JSX either way. `type: 'separator'` rather than
|
|
1294
|
+
`separator: true`, `enabled: false` rather than `disabled: true`,
|
|
1295
|
+
`toggleType`/`toggleState` rather than a bare `checked`, and `shortcut` as a
|
|
1296
|
+
list of modifier tokens rather than a display string, all follow from that.
|
|
1297
|
+
`toggleState` is `0`, `1` or `-1`, the last being indeterminate. See
|
|
1298
|
+
[globalmenu.md](globalmenu.md), which also covers `globalMenu={false}`.
|
|
1299
|
+
|
|
1300
|
+
**The sheet.** Where the display composites, a menu is an ARGB window
|
|
1301
|
+
rounded at `radiusPopup` with a hairline border, and the highlight on a row
|
|
1302
|
+
is a pill: inset from the popup's edge by the list's own padding and rounded
|
|
1303
|
+
a step tighter (`radiusPopupItem`), so it reads as sitting _on_ the sheet
|
|
1304
|
+
rather than cutting across it. The fill is `hoverBackground` — the palette's
|
|
1305
|
+
selection colour, shared with `Select`'s active option and `Table`'s current
|
|
1306
|
+
row, so one token moves every highlight in the app. Without a compositor
|
|
1307
|
+
both go square, which is what those pixels can honestly be.
|
|
1308
|
+
|
|
1309
|
+
**One selection at a time.** The bar item, the row under it and the row
|
|
1310
|
+
under that are one trail, and only its deepest level wears the selection
|
|
1311
|
+
colour; everything behind is drawn in `surfaceActive`.
|
|
1312
|
+
They are still chosen — they are the way back — but two selection-coloured
|
|
1313
|
+
rows in two menus claim the same thing twice, and only one of them is where
|
|
1314
|
+
the keys are going. A submenu that is open with _nothing_ selected in it has
|
|
1315
|
+
not taken over yet: the pointer is still on the row that opened it, so that
|
|
1316
|
+
row stays lit — which is why a menu bar item stays coloured until you touch
|
|
1317
|
+
a row in the menu it opened, and goes quiet then.
|
|
1318
|
+
|
|
1319
|
+
The bar item wears the same pill as the rows, at `radiusPopupItem` — the same
|
|
1320
|
+
padding around the same capitals, so a title and the first row of the menu it
|
|
1321
|
+
opens are one shape in one size, inset into the bar by a few pixels of margin
|
|
1322
|
+
that sit outside that padding.
|
|
1323
|
+
|
|
1324
|
+
**Titles that do not fit.** A bar narrower than its menus paints as many as
|
|
1325
|
+
fit and moves the rest behind a chevron at the end of it — the system set's
|
|
1326
|
+
`moreVertical`, drawn rather than a `»`, so it is a mark and not a font's
|
|
1327
|
+
opinion of one. The chevron is an ordinary bar entry whose items are the
|
|
1328
|
+
menus that were cut, so each row opens the menu it stands for as a submenu,
|
|
1329
|
+
and it is a real stop for Left/Right: **the keyboard walks exactly what is
|
|
1330
|
+
painted**, never a title laid out past the window's edge. Widen the window
|
|
1331
|
+
and the titles come back one at a time. The bar measures itself, so the
|
|
1332
|
+
first frame of a new bar always shows everything and the cut lands on the
|
|
1333
|
+
next — and none of this happens on a desktop whose panel has taken the menu,
|
|
1334
|
+
where there is no bar of ours to overflow.
|
|
1335
|
+
|
|
1336
|
+
**Icons.** `icon` fills the column left of the label — the same column a
|
|
1337
|
+
`toggleType` mark uses, so an item that is both checked and iconned shows
|
|
1338
|
+
the check: the check is state, the icon is only identity. `icon` is drawn by
|
|
1339
|
+
react-x11 and never crosses the bus; `iconName` is the icon-theme name a
|
|
1340
|
+
panel uses ([globalmenu.md](globalmenu.md)). One column rather than
|
|
1341
|
+
two, because a second would indent every label in the menu to reserve room
|
|
1342
|
+
for icons most items do not have.
|
|
1343
|
+
|
|
1344
|
+
The column is there for the menus that draw in it and gone from the ones
|
|
1345
|
+
that do not. What counts is what a row actually paints: one icon, or one
|
|
1346
|
+
toggle that is _on_, anywhere in a menu indents every label in it — and a
|
|
1347
|
+
menu of plain commands, or of checkboxes that all happen to be off, has no
|
|
1348
|
+
column at all and its labels sit where a label sits.
|
|
1349
|
+
|
|
1350
|
+
So ticking the first item in such a menu moves its labels sideways, once.
|
|
1351
|
+
Toolkits that reserve the column unconditionally are buying stillness in
|
|
1352
|
+
that one frame, and paying for it with an indent on every menu that has a
|
|
1353
|
+
toggle anywhere in it — this one makes the other trade. A menu that already
|
|
1354
|
+
has a mark in it keeps the column whatever happens to the rest.
|
|
1355
|
+
|
|
1356
|
+
Pass a **function** for anything real. It is called with the colour the
|
|
1357
|
+
row's label is being drawn in and the size the column allows, which is what
|
|
1358
|
+
lets one drawing follow its row into the highlight and into the disabled
|
|
1359
|
+
grey:
|
|
1360
|
+
|
|
1361
|
+
```jsx
|
|
1362
|
+
const save = ({ color, size }) => (
|
|
1363
|
+
<svg source={SAVE_SVG} style={{ width: size, height: size, color }} />
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
{ label: 'Save', icon: save, shortcut: [['Control', 'S']], onSelect: onSave }
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
Paint the SVG in `currentColor` and the renderer caches it as coverage
|
|
1370
|
+
rather than as pixels, so recolouring per row is a composite and every
|
|
1371
|
+
colour of the icon shares one rendered copy
|
|
1372
|
+
([elements.md](elements.md#svg)). A [system icon](#system-icons) is already
|
|
1373
|
+
that, and takes the same two arguments:
|
|
1374
|
+
|
|
1375
|
+
```jsx
|
|
1376
|
+
{ label: 'Close', icon: (p) => <Icon name="close" {...p} />, onSelect: onClose }
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
A **string** icon is drawn as text, which is a one-liner — but it is only as
|
|
1380
|
+
good as the font, and `✂` or `⏻` is an empty box on a machine without them.
|
|
1381
|
+
An element is rendered as-is. `examples/menu.jsx` has a worked set.
|
|
1382
|
+
|
|
1383
|
+
The **toggle marks** are system icons for that reason: a checked item is a
|
|
1384
|
+
`check`, a radio one a `dot`, and the indeterminate third state a `dash`.
|
|
1385
|
+
They used to be `✓`, `●` and `–` set in the row's font, which is the same
|
|
1386
|
+
gamble this paragraph tells you not to take.
|
|
1387
|
+
|
|
1388
|
+
**Submenus.** Give an item its own `items` and it becomes a submenu parent,
|
|
1389
|
+
marked with a `chevronRight` and opening to the side:
|
|
1390
|
+
|
|
1391
|
+
```jsx
|
|
1392
|
+
{ label: 'Export', items: [
|
|
1393
|
+
{ label: 'PNG', onSelect: exportPng },
|
|
1394
|
+
{ label: 'SVG', onSelect: exportSvg },
|
|
1395
|
+
] }
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
Nesting is unlimited. Each level is its own `<popup>` with `placement:
|
|
1399
|
+
'right'`, so it flips to the left near a screen edge like any other anchored
|
|
1400
|
+
popup. It hangs off the **menu's** outer edge and lines up with the row that
|
|
1401
|
+
opened it — two different nodes, which is what `anchorRect`'s `alignTo` is
|
|
1402
|
+
for. Anchoring both to the row would put the submenu inside its parent by
|
|
1403
|
+
the menu's border and padding, and the two would overlap.
|
|
1404
|
+
|
|
1405
|
+
Lined up on the **items**, not on the boxes: a submenu whose top edge is
|
|
1406
|
+
level with its parent row starts its first item a border and a padding
|
|
1407
|
+
lower, and what the eye lines up is the text. `anchorRect`'s `alignOffset`
|
|
1408
|
+
is the shift that pays for the submenu's own chrome, so the row you came
|
|
1409
|
+
from and the row you arrive at are on one line.
|
|
1410
|
+
|
|
1411
|
+
**Keyboard.** Up/Down move the active item, **skipping separators and
|
|
1412
|
+
disabled entries** and wrapping; Home/End jump to the ends; Right opens a
|
|
1413
|
+
submenu (selecting its first item) and Left leaves one; Enter/Space
|
|
1414
|
+
activate — or open a submenu, for a parent row; Escape closes **one level
|
|
1415
|
+
at a time**. In a `MenuBar`, Left/Right walk between menus _when there is
|
|
1416
|
+
no submenu to move through_, and with one menu open, hovering another
|
|
1417
|
+
switches to it. Hovering a submenu parent opens it with nothing selected
|
|
1418
|
+
inside.
|
|
1419
|
+
|
|
1420
|
+
**PageUp/PageDown** step ten rows and then settle on the nearest selectable
|
|
1421
|
+
entry in the direction of travel, so a page never lands on a separator or a
|
|
1422
|
+
disabled row. Menus size to their content rather than scrolling, so the
|
|
1423
|
+
stride is fixed — deriving one from the menu height would just equal
|
|
1424
|
+
Home/End.
|
|
1425
|
+
|
|
1426
|
+
**Type-ahead.** Typing letters jumps to the entry whose label starts with
|
|
1427
|
+
them, in whichever level is deepest open. Keystrokes within 700ms
|
|
1428
|
+
accumulate into one query, so `c`,`a` finds _Carrot_ rather than jumping to
|
|
1429
|
+
_Apple_ first; repeating a single letter cycles through the entries
|
|
1430
|
+
starting with it. Disabled entries and separators are never matched.
|
|
1431
|
+
|
|
1432
|
+
Open state is a single path of active indices — one per open level — so
|
|
1433
|
+
moving the selection at any level truncates the path and closes deeper
|
|
1434
|
+
levels for free.
|
|
1435
|
+
|
|
1436
|
+
Both keep focus on a node in the _owner_ window — the popup is
|
|
1437
|
+
override-redirect and never takes focus — which is the same arrangement
|
|
1438
|
+
`Select` uses. `ContextMenu`'s wrapper is focusable for that reason and
|
|
1439
|
+
takes focus when the menu opens.
|
|
1440
|
+
|
|
1441
|
+
Switching between `MenuBar` menus reuses the same X window and moves it
|
|
1442
|
+
rather than destroying and recreating one, so there is no flicker.
|
|
1443
|
+
|
|
1444
|
+
### Safe-polygon hover
|
|
1445
|
+
|
|
1446
|
+
Reaching a submenu means moving the pointer _diagonally_ across the rows in
|
|
1447
|
+
between, and reaching a tooltip means leaving the trigger it belongs to — so
|
|
1448
|
+
naive hover handling closes both just as the user aims at them. `MenuBar`,
|
|
1449
|
+
`ContextMenu` and `Tooltip` therefore use
|
|
1450
|
+
[floating-ui's safePolygon](https://floating-ui.com/docs/usehover#safepolygon)
|
|
1451
|
+
idea: the triangle between where the pointer was and the near edge of the
|
|
1452
|
+
open surface counts as still hovering the parent.
|
|
1453
|
+
|
|
1454
|
+
While the pointer is inside that triangle, hover changes are held back —
|
|
1455
|
+
but only for `SAFE_HOVER_DELAY` (320 ms), so a pointer that stops there
|
|
1456
|
+
still means what it landed on. Leaving the triangle switches immediately,
|
|
1457
|
+
and reaching the surface keeps it open for as long as the pointer stays.
|
|
1458
|
+
|
|
1459
|
+
The helpers are exported for widgets of your own:
|
|
1460
|
+
`movingToward(point, apex, rect)`, `safePolygon(apex, rect, buffer)`,
|
|
1461
|
+
`pointInPolygon(point, polygon)` and `screenPoint(ev)`. All coordinates are
|
|
1462
|
+
**screen** coordinates, because the trigger and the popup are different X
|
|
1463
|
+
windows.
|
|
1464
|
+
|
|
1465
|
+
## `Tabs`
|
|
1466
|
+
|
|
1467
|
+
One panel visible at a time, switched by a strip of tabs.
|
|
1468
|
+
|
|
1469
|
+
```jsx
|
|
1470
|
+
<Tabs
|
|
1471
|
+
items={[
|
|
1472
|
+
{ id: 'general', label: 'General', content: <GeneralPage /> },
|
|
1473
|
+
{ id: 'advanced', label: 'Advanced', content: () => <AdvancedPage /> },
|
|
1474
|
+
{ id: 'legacy', label: 'Legacy', disabled: true },
|
|
1475
|
+
]}
|
|
1476
|
+
/>
|
|
1477
|
+
```
|
|
1478
|
+
|
|
1479
|
+
| prop | |
|
|
1480
|
+
| ------------------------ | -------------------------------------------------- |
|
|
1481
|
+
| `items` | `{ id, label, content, disabled }[]` |
|
|
1482
|
+
| `value` / `defaultValue` | selected id — controlled with `value` + `onChange` |
|
|
1483
|
+
| `onChange(id)` | a tab was chosen |
|
|
1484
|
+
| `orientation` | `'horizontal'` (default) or `'vertical'` |
|
|
1485
|
+
| `manual` | arrows move focus only; Enter or Space commits |
|
|
1486
|
+
|
|
1487
|
+
`content` may be a node or a function. A function is called only while that
|
|
1488
|
+
tab is selected, which is how to avoid building a panel nobody is looking
|
|
1489
|
+
at. Items with no `content` at all make `Tabs` a pure navigator — useful
|
|
1490
|
+
when the panel lives elsewhere, as in `examples/app.jsx`, where the strip is
|
|
1491
|
+
in one half of a `SplitPane` and the panel in the other.
|
|
1492
|
+
|
|
1493
|
+
The strip is a **single tab stop**. Left/Right (Up/Down when vertical) move
|
|
1494
|
+
and wrap, Home/End jump to the ends, disabled tabs are skipped. Arrows
|
|
1495
|
+
select as they move, the way a desktop notebook behaves; `manual` splits
|
|
1496
|
+
focus from selection, which is what you want when a panel is expensive.
|
|
1497
|
+
|
|
1498
|
+
## `Table`
|
|
1499
|
+
|
|
1500
|
+
A grid with a header that stays put, resizable columns, and only the rows in
|
|
1501
|
+
view actually built.
|
|
1502
|
+
|
|
1503
|
+
```jsx
|
|
1504
|
+
<Table
|
|
1505
|
+
columns={[
|
|
1506
|
+
{ id: 'name', label: 'Name', width: 220 },
|
|
1507
|
+
{ id: 'size', label: 'Size', width: 90, align: 'right' },
|
|
1508
|
+
]}
|
|
1509
|
+
rows={files}
|
|
1510
|
+
onSelect={(id, row) => open(row)}
|
|
1511
|
+
/>
|
|
1512
|
+
```
|
|
1513
|
+
|
|
1514
|
+
| prop | |
|
|
1515
|
+
| ------------------------------ | --------------------------------------------------- |
|
|
1516
|
+
| `columns` | `{ id, label, width, align, value, render }[]` |
|
|
1517
|
+
| `rows` | `{ id, … }[]` — `id` identifies the row |
|
|
1518
|
+
| `rowHeight` | every row is this tall (24 by default) |
|
|
1519
|
+
| `sort` / `defaultSort` | `{ column, direction }`; reported by `onSortChange` |
|
|
1520
|
+
| `selected` / `defaultSelected` | selected row id; reported by `onSelect(id, row)` |
|
|
1521
|
+
| `onActivate(id, row)` | a double click, or Enter on the selection |
|
|
1522
|
+
| `onColumnResize(id, width)` | after a header drag |
|
|
1523
|
+
|
|
1524
|
+
`value(row)` feeds sorting and the default cell text; `render(row, { selected,
|
|
1525
|
+
column })` replaces the cell contents entirely.
|
|
1526
|
+
|
|
1527
|
+
A `render` is **told when its row is selected**, because the selection is a
|
|
1528
|
+
filled bar and a colour picked against the resting background disappears
|
|
1529
|
+
into it — a directory in the accent, a failure in red. Fall back to
|
|
1530
|
+
`hoverText` there and let the glyph carry the meaning:
|
|
1531
|
+
|
|
1532
|
+
```jsx
|
|
1533
|
+
render: (row, { selected }) => (
|
|
1534
|
+
<text style={{ color: selected ? '$hoverText' : statusColour(row) }}>
|
|
1535
|
+
{row.state}
|
|
1536
|
+
</text>
|
|
1537
|
+
);
|
|
1538
|
+
```
|
|
1539
|
+
|
|
1540
|
+
**Rows must all be `rowHeight` tall.** That is the price of the table only
|
|
1541
|
+
building what is on screen: with ten thousand rows it mounts the twenty or
|
|
1542
|
+
so in the viewport and swaps them as you scroll, and everything above and
|
|
1543
|
+
below is a single spacer box, so the scrollbar still measures the whole
|
|
1544
|
+
list. Sorting a hundred thousand rows is still the caller's problem — pass
|
|
1545
|
+
`sort` and sort the data yourself when that matters.
|
|
1546
|
+
|
|
1547
|
+
The **table** holds the focus, not the row: a row is unmounted as soon as it
|
|
1548
|
+
scrolls out of view, and focus would go with it. Up/Down move the selection,
|
|
1549
|
+
PageUp/PageDown by a viewport, Home/End to the ends, and the selection is
|
|
1550
|
+
kept on screen without building the rows in between. A click selects, a
|
|
1551
|
+
double click activates — the same `onActivate` Enter fires, counted from
|
|
1552
|
+
`ev.detail` like any other multi-click.
|
|
1553
|
+
|
|
1554
|
+
The header scrolls sideways with the body but never vertically. Between two
|
|
1555
|
+
headers there is a hairline rule and, just left of it, a grab band seven
|
|
1556
|
+
pixels wide: the separator you see and the handle you hit are not the same
|
|
1557
|
+
size, because a boundary wants to be thin and a handle wants to be easy. The
|
|
1558
|
+
band is invisible until the pointer is on it and takes the accent while it is
|
|
1559
|
+
held, and it is a **sibling** of the header rather than a child of it — a
|
|
1560
|
+
click fires on the nearest common ancestor of press and release, so a handle
|
|
1561
|
+
inside the header would end every resize with a sort of the column that was
|
|
1562
|
+
just dragged. The handle takes focus on the press, so Left/Right resize by
|
|
1563
|
+
16px from the keyboard as well.
|
|
1564
|
+
|
|
1565
|
+
## `Tree`
|
|
1566
|
+
|
|
1567
|
+
Moved to [`@react-x11/components`](ecosystem.md): a tree wants virtualisation,
|
|
1568
|
+
drag-to-reorder and lazy children, none of which core has a reason to carry.
|
|
1569
|
+
|
|
1570
|
+
## `SplitPane`
|
|
1571
|
+
|
|
1572
|
+
Two panes with a divider you can drag.
|
|
1573
|
+
|
|
1574
|
+
```jsx
|
|
1575
|
+
<SplitPane direction="row" defaultSize={220} min={120} minSecond={300}>
|
|
1576
|
+
<Sidebar />
|
|
1577
|
+
<Editor />
|
|
1578
|
+
</SplitPane>
|
|
1579
|
+
```
|
|
1580
|
+
|
|
1581
|
+
| prop | |
|
|
1582
|
+
| ---------------------- | ----------------------------------------------- |
|
|
1583
|
+
| `direction` | `'row'` (default) or `'column'` |
|
|
1584
|
+
| `size` / `defaultSize` | the **first** pane's width or height, in pixels |
|
|
1585
|
+
| `onResize(size)` | after a drag or a key step |
|
|
1586
|
+
| `min` / `minSecond` | how small either pane may get |
|
|
1587
|
+
|
|
1588
|
+
Only the first pane's size is stored; the second takes what is left, so a
|
|
1589
|
+
window resize can never leave a gap. The drag clamps against the container
|
|
1590
|
+
as it is laid out at that moment, so the limits stay honest when the window
|
|
1591
|
+
changes underneath it.
|
|
1592
|
+
|
|
1593
|
+
The divider is focusable: arrows move it by 16px, Home/End drive it to
|
|
1594
|
+
either limit. Dragging captures the pointer, so it keeps tracking after the
|
|
1595
|
+
pointer leaves the six pixels it started on, and it keeps the grip where it
|
|
1596
|
+
was taken rather than jumping to the pointer.
|
|
1597
|
+
|
|
1598
|
+
## `Canvas3D`
|
|
1599
|
+
|
|
1600
|
+
Moved to [`@react-x11/components/three`](ecosystem.md) as `<Canvas>`, with
|
|
1601
|
+
the scene graph it wrapped. Core keeps the `<glarea>` surface underneath it
|
|
1602
|
+
— see [gl.md](gl.md) and `examples/viewer3d.jsx`.
|
|
1603
|
+
|
|
1604
|
+
## `useAnchor(ref)` / `anchorRect(node, options)`
|
|
1605
|
+
|
|
1606
|
+
The placement math behind `Select` and `Tooltip`, exported for building
|
|
1607
|
+
your own popup-based widgets.
|
|
1608
|
+
|
|
1609
|
+
```jsx
|
|
1610
|
+
const ref = useRef(null);
|
|
1611
|
+
const measure = useAnchor(ref);
|
|
1612
|
+
|
|
1613
|
+
// screen coordinates for a <popup>, given the size you intend to use
|
|
1614
|
+
const rect = measure({ placement: 'bottom', align: 'center', width, height });
|
|
1615
|
+
// -> { x, y, width, height, placement }
|
|
1616
|
+
```
|
|
1617
|
+
|
|
1618
|
+
| option | |
|
|
1619
|
+
| ----------------- | ---------------------------------------------------------------------------------- |
|
|
1620
|
+
| `placement` | `'bottom'` (default), `'top'`, `'start'`, `'end'`, `'left'`, `'right'`, `'center'` |
|
|
1621
|
+
| `align` | `'start'` (default), `'center'`, `'end'` on the cross axis |
|
|
1622
|
+
| `offset` | gap from the anchor in px (default 2) |
|
|
1623
|
+
| `at` | a rect **inside** the node to anchor to instead — a caret, a cell |
|
|
1624
|
+
| `width`, `height` | size of the popup you are positioning |
|
|
1625
|
+
| `alignTo` | node the alignment reads, when it is not the anchor |
|
|
1626
|
+
|
|
1627
|
+
`placement` is a **preference, not a promise**: a menu near the bottom of
|
|
1628
|
+
the screen flips above its trigger rather than opening off-screen, and the
|
|
1629
|
+
result is clamped into the screen either way. The side actually used comes
|
|
1630
|
+
back as `placement`. Where screen geometry is unavailable it places without
|
|
1631
|
+
clamping.
|
|
1632
|
+
|
|
1633
|
+
`'center'` is no side: the popup goes **over** the anchor, middle on
|
|
1634
|
+
middle, and is only clamped — `align` and `offset` mean nothing to it. It is
|
|
1635
|
+
what a dialog is placed with ([`Dialog`](#size-and-placement)).
|
|
1636
|
+
|
|
1637
|
+
`'start'` and `'end'` are the **logical** sides and are what a submenu
|
|
1638
|
+
wants: it opens away from the edge its parent's rows begin at, which is
|
|
1639
|
+
leftwards in a mirrored menu, and still flips at the screen edge from there.
|
|
1640
|
+
`'left'`/`'right'` stay physical. `align` mirrors the same way, but only
|
|
1641
|
+
when the popup is above or below its trigger — with one beside it the
|
|
1642
|
+
alignment axis is vertical, and nothing vertical mirrors. The direction is
|
|
1643
|
+
read off the anchoring node, so a menu inside a mirrored panel needs no
|
|
1644
|
+
argument.
|
|
1645
|
+
|
|
1646
|
+
`alignTo` takes the two axes from **different nodes**: the placement edge
|
|
1647
|
+
from the anchor, the alignment from `alignTo`. A submenu is the case that
|
|
1648
|
+
needs it — it belongs against the outer edge of the menu it comes out of,
|
|
1649
|
+
but level with the row that opened it, and that row is inset by the menu's
|
|
1650
|
+
border and padding. Both nodes have to be in the same window.
|
|
1651
|
+
|
|
1652
|
+
### `at` — anchoring to part of a node
|
|
1653
|
+
|
|
1654
|
+
`at` is a rect in the **anchor node's own coordinates**, and it becomes the
|
|
1655
|
+
anchor: the side that flips, the edge that aligns, the gap `offset` leaves,
|
|
1656
|
+
the rect tracking keeps in view. A caret is the case it exists for — a
|
|
1657
|
+
moving point inside one element — and a table cell, a chart datapoint or a
|
|
1658
|
+
highlighted span are the same shape.
|
|
1659
|
+
|
|
1660
|
+
```jsx
|
|
1661
|
+
const measure = useAnchor(editorRef);
|
|
1662
|
+
const rect = measure({
|
|
1663
|
+
at: { x: caretX, y: lineTop, width: 1, height: lineHeight },
|
|
1664
|
+
placement: 'bottom',
|
|
1665
|
+
width: 240,
|
|
1666
|
+
height: rows * 22,
|
|
1667
|
+
});
|
|
1668
|
+
```
|
|
1669
|
+
|
|
1670
|
+
`width` and `height` are optional (`{x, y}` alone is a point), and `width`
|
|
1671
|
+
on the popup then defaults to the sub-rect's rather than the node's.
|
|
1672
|
+
|
|
1673
|
+
Node-relative rather than screen-relative on purpose: the offset stays true
|
|
1674
|
+
through everything that moves the node, so `useAnchorTracking` follows a
|
|
1675
|
+
caret with no extra work — and its out-of-view test becomes the caret's,
|
|
1676
|
+
which is what you want, since an editor's own lines scroll away long before
|
|
1677
|
+
the editor does.
|
|
1678
|
+
|
|
1679
|
+
### `anchorScreenRect(app, rect, options)` — a rect with no node behind it
|
|
1680
|
+
|
|
1681
|
+
The same placement against a rect on the **screen**: the item a tray click
|
|
1682
|
+
reports, or where the pointer was. `rect` and the answer are logical screen
|
|
1683
|
+
pixels, `{x, y}` alone is a point, and the popup is kept on the monitor the
|
|
1684
|
+
rect is on — the one the **whole rect** overlaps most, and the nearest where
|
|
1685
|
+
it overlaps none, because a tray item's frame starts just outside its own
|
|
1686
|
+
display. With no node to ask, `options.scale` is the display's — it
|
|
1687
|
+
defaults to 1 — and `options.direction` decides what `'start'` and `'end'`
|
|
1688
|
+
mean. A `<popup>` does this itself with
|
|
1689
|
+
[`anchor={{ rect }}`](elements.md#a-popover-under-a-tray-item).
|
|
1690
|
+
|
|
1691
|
+
**A popup that has to measure itself first anchors from the other side.**
|
|
1692
|
+
Rows sized to their labels, a menu as wide as its widest item: the size is
|
|
1693
|
+
not known until the popup's content is laid out, and by then React has
|
|
1694
|
+
already rendered. `<popup anchor={{ to: ref, at }}>` hands the placement to
|
|
1695
|
+
the popup, which does it in the one place the size is known —
|
|
1696
|
+
[elements.md](elements.md#anchor--a-popup-that-places-itself).
|
|
1697
|
+
|
|
1698
|
+
### `anchorArea(node)`
|
|
1699
|
+
|
|
1700
|
+
The rect a popup anchored to `node` may be placed in: the usable part of the
|
|
1701
|
+
monitor that node is on — per-monitor, `_NET_WORKAREA` taken off it, the
|
|
1702
|
+
same answer [`<window width="auto">`](elements.md#natural-size) is capped
|
|
1703
|
+
by. `null` where there is no display to ask.
|
|
1704
|
+
|
|
1705
|
+
What needs it is a surface sizing itself: a menu measured to its longest
|
|
1706
|
+
label is never usefully wider than the screen it opens on, and `anchorRect`
|
|
1707
|
+
can slide a popup back from an edge but nothing rescues one that does not
|
|
1708
|
+
fit. `Select` and `ContextMenu` both cap themselves this way.
|
|
1709
|
+
|
|
1710
|
+
## `useDropTarget(options)` / `useDragSource(options)`
|
|
1711
|
+
|
|
1712
|
+
The render-state layer over the drag-and-drop props, for the cases a
|
|
1713
|
+
`':drag-over'` or `':dragging'` style block cannot cover — a hint label, a
|
|
1714
|
+
disabled sibling, a drag preview that follows the pointer.
|
|
1715
|
+
|
|
1716
|
+
```jsx
|
|
1717
|
+
const { dropProps, isOver, isAccepted } = useDropTarget({
|
|
1718
|
+
accept: ['files'],
|
|
1719
|
+
onDrop: (e) => setFiles(e.files),
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
const { dragProps, isDragging, position } = useDragSource({
|
|
1723
|
+
data: { 'text/plain': label },
|
|
1724
|
+
actions: ['copy', 'move'],
|
|
1725
|
+
});
|
|
1726
|
+
```
|
|
1727
|
+
|
|
1728
|
+
Spread `dropProps` / `dragProps` on any drawn element. Both are thin — the
|
|
1729
|
+
props they return are the same ones you can write by hand — so reach for
|
|
1730
|
+
them when the render changes, and for the plain props when it does not.
|
|
1731
|
+
`position` is `{ x, y, accepted }` in screen coordinates while a drag is in
|
|
1732
|
+
flight, which is what a `<popup dragPreview>` follows. Full reference:
|
|
1733
|
+
[drag-and-drop.md](drag-and-drop.md).
|
|
1734
|
+
|
|
1735
|
+
---
|
|
1736
|
+
|
|
1737
|
+
The other components — `Button`, `Checkbox`, `Radio`/`RadioGroup`,
|
|
1738
|
+
`Switch`, `ProgressBar` — are demoed together in `examples/widgets.jsx`.
|
|
1739
|
+
|
|
1740
|
+
The `Select` source (`src/components/Select.js`) is the reference for building
|
|
1741
|
+
your own: hover/focus state with `useState`, a `<popup>` for anything that
|
|
1742
|
+
must escape the window bounds, and a ref to the trigger node for anchoring
|
|
1743
|
+
(`node.abs` + `node.root.window.x/y`).
|
|
1744
|
+
|
|
1745
|
+
The window's screen position comes from the server (`TranslateCoordinates`),
|
|
1746
|
+
refreshed when the window is realized and whenever it is configured — not
|
|
1747
|
+
from `window.x`/`y`. Once a reparenting window manager has put the window
|
|
1748
|
+
inside its frame, those are relative to the _frame_, and a popup placed with
|
|
1749
|
+
them lands near the corner of the screen instead of under its trigger.
|