react-x11 2.51.1 → 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.
Files changed (91) hide show
  1. package/README.md +87 -54
  2. package/docs/README.md +410 -0
  3. package/docs/accessibility.md +628 -0
  4. package/docs/appearance.md +416 -0
  5. package/docs/architecture/accessibility.md +470 -0
  6. package/docs/architecture/animation.md +707 -0
  7. package/docs/architecture/container-queries.md +525 -0
  8. package/docs/architecture/custom-layout.md +334 -0
  9. package/docs/architecture/drag-and-drop.md +941 -0
  10. package/docs/architecture/element-layer-contents.md +622 -0
  11. package/docs/architecture/frame-pacing.md +255 -0
  12. package/docs/architecture/grid-layout.md +358 -0
  13. package/docs/architecture/protocol-efficiency.md +357 -0
  14. package/docs/architecture/video.md +1149 -0
  15. package/docs/architecture/windowed-regions.md +854 -0
  16. package/docs/click-to-component.md +125 -0
  17. package/docs/clipboard.md +281 -0
  18. package/docs/components.md +1749 -0
  19. package/docs/dbus.md +277 -0
  20. package/docs/debugging.md +464 -0
  21. package/docs/desktop-calendar.md +284 -0
  22. package/docs/desktop.md +648 -0
  23. package/docs/devtools.md +250 -0
  24. package/docs/drag-and-drop.md +663 -0
  25. package/docs/ecosystem/animation.md +394 -0
  26. package/docs/ecosystem/data-fetching.md +201 -0
  27. package/docs/ecosystem/dev-tooling.md +436 -0
  28. package/docs/ecosystem/forms.md +313 -0
  29. package/docs/ecosystem/headless.md +219 -0
  30. package/docs/ecosystem/i18n.md +89 -0
  31. package/docs/ecosystem/icons.md +410 -0
  32. package/docs/ecosystem/layout.md +216 -0
  33. package/docs/ecosystem/routing.md +146 -0
  34. package/docs/ecosystem/state.md +361 -0
  35. package/docs/ecosystem/testing.md +500 -0
  36. package/docs/ecosystem/theming.md +540 -0
  37. package/docs/ecosystem.md +473 -0
  38. package/docs/elements.md +2762 -0
  39. package/docs/embedding.md +288 -0
  40. package/docs/events.md +1025 -0
  41. package/docs/extending.md +2242 -0
  42. package/docs/eyedropper.md +223 -0
  43. package/docs/filedialog.md +258 -0
  44. package/docs/frame.md +580 -0
  45. package/docs/gl.md +170 -0
  46. package/docs/globalmenu.md +278 -0
  47. package/docs/glx.md +137 -0
  48. package/docs/macos.md +3295 -0
  49. package/docs/notifications.md +164 -0
  50. package/docs/packaging.md +611 -0
  51. package/docs/permissions.md +148 -0
  52. package/docs/react-features.md +430 -0
  53. package/docs/remote.md +223 -0
  54. package/docs/scale.md +339 -0
  55. package/docs/security.md +132 -0
  56. package/docs/styling.md +1928 -0
  57. package/docs/system.md +599 -0
  58. package/docs/testing.md +514 -0
  59. package/docs/typescript.md +117 -0
  60. package/docs/uri-schemes.md +380 -0
  61. package/docs/wayland-backend.md +591 -0
  62. package/docs/wayland.md +545 -0
  63. package/docs/windows-embedding.md +271 -0
  64. package/docs/windows-gl.md +221 -0
  65. package/docs/windows-integrations.md +233 -0
  66. package/docs/windows.md +1404 -0
  67. package/package.json +9 -2
  68. package/src/bus.js +39 -26
  69. package/src/cli/create.d.ts +43 -0
  70. package/src/cli/create.js +305 -0
  71. package/src/cli/env.js +484 -0
  72. package/src/cli/index.js +29 -2
  73. package/src/cli/templates/AGENTS.template.md +195 -0
  74. package/src/cli/templates/DESIGN.template.md +269 -0
  75. package/src/desktopintegration.js +2 -1
  76. package/src/desktopsettings.js +193 -24
  77. package/src/filedialog.js +3 -3
  78. package/src/keyboardstate.js +28 -0
  79. package/src/node.d.ts +10 -10
  80. package/src/portal.js +31 -0
  81. package/src/screencolor.js +5 -10
  82. package/src/types/system.d.ts +8 -6
  83. package/src/wayland/app.js +59 -0
  84. package/src/wayland/clipboard.js +14 -7
  85. package/src/wayland/connection.js +3 -0
  86. package/src/wayland/context2d.js +129 -10
  87. package/src/wayland/protocols/index.json +6 -0
  88. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  89. package/src/wayland/seat.js +28 -1
  90. package/src/wayland/swapchain.js +23 -0
  91. 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.