react-x11 2.51.1 → 2.53.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 (121) hide show
  1. package/README.md +94 -61
  2. package/docs/README.md +256 -0
  3. package/docs/accessibility.md +575 -0
  4. package/docs/appearance.md +317 -0
  5. package/docs/click-to-component.md +92 -0
  6. package/docs/clipboard.md +262 -0
  7. package/docs/components.md +1231 -0
  8. package/docs/dbus.md +205 -0
  9. package/docs/debugging.md +371 -0
  10. package/docs/desktop-calendar.md +247 -0
  11. package/docs/desktop.md +523 -0
  12. package/docs/devtools.md +133 -0
  13. package/docs/drag-and-drop.md +585 -0
  14. package/docs/ecosystem/animation.md +360 -0
  15. package/docs/ecosystem/data-fetching.md +186 -0
  16. package/docs/ecosystem/dev-tooling.md +393 -0
  17. package/docs/ecosystem/forms.md +277 -0
  18. package/docs/ecosystem/headless.md +194 -0
  19. package/docs/ecosystem/i18n.md +86 -0
  20. package/docs/ecosystem/icons.md +377 -0
  21. package/docs/ecosystem/layout.md +206 -0
  22. package/docs/ecosystem/routing.md +135 -0
  23. package/docs/ecosystem/state.md +344 -0
  24. package/docs/ecosystem/testing.md +474 -0
  25. package/docs/ecosystem/theming.md +494 -0
  26. package/docs/ecosystem.md +439 -0
  27. package/docs/elements.md +2079 -0
  28. package/docs/embedding.md +239 -0
  29. package/docs/events.md +742 -0
  30. package/docs/extending.md +1700 -0
  31. package/docs/eyedropper.md +164 -0
  32. package/docs/filedialog.md +222 -0
  33. package/docs/frame.md +436 -0
  34. package/docs/gl.md +173 -0
  35. package/docs/globalmenu.md +195 -0
  36. package/docs/macos.md +592 -0
  37. package/docs/notifications.md +150 -0
  38. package/docs/packaging.md +468 -0
  39. package/docs/permissions.md +143 -0
  40. package/docs/react-features.md +405 -0
  41. package/docs/remote.md +179 -0
  42. package/docs/scale.md +225 -0
  43. package/docs/security.md +132 -0
  44. package/docs/styling.md +1710 -0
  45. package/docs/system.md +519 -0
  46. package/docs/testing.md +494 -0
  47. package/docs/typescript.md +75 -0
  48. package/docs/uri-schemes.md +340 -0
  49. package/docs/wayland-backend.md +332 -0
  50. package/docs/windows-integrations.md +177 -0
  51. package/docs/windows.md +380 -0
  52. package/package.json +11 -3
  53. package/src/Reconciler.js +2 -2
  54. package/src/a11y.js +1 -1
  55. package/src/backend/context2d.js +3 -3
  56. package/src/bootstrap.js +1 -1
  57. package/src/bus.js +39 -26
  58. package/src/cli/create.d.ts +43 -0
  59. package/src/cli/create.js +305 -0
  60. package/src/cli/env.js +484 -0
  61. package/src/cli/index.js +29 -2
  62. package/src/cli/templates/AGENTS.template.md +195 -0
  63. package/src/cli/templates/DESIGN.template.md +269 -0
  64. package/src/cocoa/app.js +6 -6
  65. package/src/cocoa/bezels.js +1 -1
  66. package/src/cocoa/fonts.js +1 -1
  67. package/src/cocoa/globalmenu.js +1 -1
  68. package/src/cocoa/keymap.js +1 -1
  69. package/src/cocoa/panewindow.js +1 -1
  70. package/src/cocoa/player.js +1 -1
  71. package/src/cocoa/presenter.js +2 -2
  72. package/src/cocoa/promotion.js +1 -1
  73. package/src/cocoa/relaunch.js +1 -1
  74. package/src/cocoa/threaded.js +1 -1
  75. package/src/cocoa/video.js +1 -1
  76. package/src/cocoa/window.js +4 -4
  77. package/src/components/Menu.js +7 -2
  78. package/src/components/Switch.js +1 -1
  79. package/src/components/native.js +1 -1
  80. package/src/desktopintegration.js +2 -1
  81. package/src/desktopsettings.js +193 -24
  82. package/src/dnd.js +2 -2
  83. package/src/filedialog.js +3 -3
  84. package/src/glnodes.js +1 -1
  85. package/src/grid.js +1 -1
  86. package/src/keyboardstate.js +28 -0
  87. package/src/layouts.js +1 -1
  88. package/src/node.d.ts +10 -10
  89. package/src/nodes/animation.js +1 -1
  90. package/src/nodes/invalidate.js +1 -1
  91. package/src/nodes/layouthost.js +1 -1
  92. package/src/nodes/video.js +19 -8
  93. package/src/nodes/window/droptarget.js +1 -1
  94. package/src/pacing.js +1 -1
  95. package/src/portal.js +31 -0
  96. package/src/refresh/index.js +2 -1
  97. package/src/screencolor.js +5 -10
  98. package/src/types/system.d.ts +8 -6
  99. package/src/types/video.d.ts +5 -2
  100. package/src/videobackend.js +28 -0
  101. package/src/videoframes.js +29 -16
  102. package/src/wayland/app.js +86 -0
  103. package/src/wayland/clipboard.js +14 -7
  104. package/src/wayland/connection.js +3 -0
  105. package/src/wayland/context2d.js +129 -10
  106. package/src/wayland/protocols/index.json +6 -0
  107. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  108. package/src/wayland/seat.js +28 -1
  109. package/src/wayland/swapchain.js +23 -0
  110. package/src/wayland/video.js +376 -0
  111. package/src/wayland/xkb.js +137 -0
  112. package/src/win32/a11y.js +1 -1
  113. package/src/win32/app.js +2 -2
  114. package/src/win32/fonts.js +1 -1
  115. package/src/win32/glarea.js +4 -4
  116. package/src/win32/overlay.js +1 -1
  117. package/src/win32/panewindow.js +3 -3
  118. package/src/win32/shell.js +1 -1
  119. package/src/win32/window.js +2 -2
  120. package/src/windowid.js +1 -1
  121. package/src/xvideo.js +189 -0
@@ -0,0 +1,1231 @@
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` (its window's size), `ContextMenu`'s
15
+ `fontSize` (it measures labels with it). Everything else is forwarded to the
16
+ host box. See [styling.md](styling.md).
17
+
18
+ The widgets are plain React over the host elements, exported from the package
19
+ root. All are screen-reader ready: roles, names, states and AT-driven control
20
+ are built in, and an `aria-label` (or a label child) is the only thing a
21
+ widget can need from you — [accessibility.md](accessibility.md) lists what
22
+ each one announces.
23
+
24
+ ## Theming
25
+
26
+ **Widgets follow the desktop with nothing declared** — dark on a dark desktop,
27
+ light on a light one. See [system appearance](appearance.md) for how that is
28
+ worked out and how to pin or override it.
29
+
30
+ `<ThemeProvider value={palette}>` gives every widget beneath it a palette,
31
+ layered over the scheme in force, so a partial one names only what your app
32
+ changes. It carries **shape as well as colour**:
33
+
34
+ | token | |
35
+ | --------------------------------------------------------------- | ------------------------------------ |
36
+ | `background` `surface` | the ground, and what is raised on it |
37
+ | `text` `textMuted` `border` | ink, secondary ink, edges |
38
+ | `accent` `accentHover` `accentText` | primary buttons, checks, fills |
39
+ | `hoverBackground` `hoverText` | selected rows, menu highlights |
40
+ | `surfaceHover` `track` `borderFocus` | hover fills, tracks, focus |
41
+ | `selection` `caret` `link` | selected text, the caret, links |
42
+ | `danger` `success` `warning` `info` (+`…Text`) | what the app says with colour |
43
+ | `accentActive` `surfaceActive` `textMutedActive` `dangerActive` | the pressed step of each fill |
44
+ | `radius` `radiusSmall` `borderWidth` | control shape |
45
+ | `radiusPopup` `radiusPopupItem` `radiusTooltip` | floating-surface shape |
46
+ | `fontSize` `paddingX` `paddingY` | control size |
47
+ | `fontFamily` `monoFamily` | the app's two faces |
48
+ | `direction` | which way the app reads |
49
+
50
+ ### The ground and what is on it
51
+
52
+ `background` is the **window**, the fill under everything. `surface` is what
53
+ is raised off it: a control's fill, a card, the sheet of a menu or dialog.
54
+ The light palette makes them the same colour; the dark one lifts `surface` a
55
+ step. **`surface` follows `background` unless you name it**, so only a design
56
+ that really raises its cards has a second token to fill in.
57
+ `surfaceHover` and `surfaceActive` are that surface's interaction steps.
58
+
59
+ ```jsx
60
+ <ThemeProvider value={{ background: '#f6f8fa', surface: '#ffffff' }}>
61
+ ```
62
+
63
+ ### What the app says with colour
64
+
65
+ `danger`, `success`, `warning` and `info` — failed, worked, look at this, a
66
+ note — colour alerts, badges, validation messages and toasts, and keep a
67
+ hard-coded `#e74c3c` (a bruise on a dark desktop) out of app code. Each
68
+ clears 4.5:1 against its palette's ground, so it works as **ink as well as
69
+ fill**:
70
+
71
+ ```jsx
72
+ <text style={{ color: '$danger' }}>Password too short</text>
73
+ <box style={{ backgroundColor: '$danger' }}>
74
+ <text style={{ color: '$dangerText' }}>Delete</text>
75
+ </box>
76
+ ```
77
+
78
+ For a tinted panel, tint the fill and leave the ink alone:
79
+
80
+ ```jsx
81
+ import { tint } from 'react-x11/style';
82
+ <box style={{ backgroundColor: tint(theme.danger, 0.12) }}>
83
+ <text style={{ color: '$danger' }}>Could not save</text>
84
+ </box>;
85
+ ```
86
+
87
+ Only `danger` has `…Hover` and `…Active` — the only status fill anyone
88
+ presses. The `…Text` four (and `accentText`) are derived from their fill as
89
+ the more legible of the palette's `text` and `background`, so a palette
90
+ almost never sets them: a yellow `warning` gets dark letters unasked.
91
+
92
+ `fontFamily` and `fontSize` are the app's type, and text that names neither
93
+ **takes them** — a `<text>`, a `<textinput>`, a widget's label — so
94
+ `<ThemeProvider value={{ fontFamily: 'Inter', fontSize: 16 }}>` sets it once,
95
+ and a `Select`'s rows grow with it. A style's `fontSize`/`fontFamily` still
96
+ wins. Both are CSS-style family lists (`'"JetBrains Mono", monospace'`).
97
+ `MenuBar` and `ContextMenu` keep a `fontSize` prop (they measure labels
98
+ before they have a window to measure in), and `Table` keeps `rowHeight`
99
+ (every row must be the same height to skip the ones off screen).
100
+
101
+ `monoFamily` is read by nothing unstyled: it is there so every code surface
102
+ in an app can say `fontFamily: '$monoFamily'` and be set from one place.
103
+
104
+ ### Which way it reads
105
+
106
+ `direction` (`'ltr'` or `'rtl'`) is **seeded from the locale**, so an app
107
+ started under an RTL locale is mirrored unconfigured. Set it here to mirror a
108
+ whole UI:
109
+
110
+ ```jsx
111
+ <ThemeProvider value={{ direction: settings.rtl ? 'rtl' : 'ltr' }}>
112
+ ```
113
+
114
+ The provider plants the `direction` style property in the node tree, so
115
+ boxes **and** widgets mirror together. A bare
116
+ `<box style={{ direction: 'rtl' }}>` mirrors only the layout, not the widget
117
+ decisions read through `useDirection()` — which way an arrow key steps, a
118
+ chevron points or a submenu opens. `Button`, `Checkbox`, `Radio`, `Switch` and
119
+ `ProgressBar` need nothing more; the rest mirror one thing each:
120
+
121
+ | widget | what mirrors |
122
+ | ------------------------- | --------------------------------------------------- |
123
+ | `Slider` | the drag, and Left/Right (Up/Down do not) |
124
+ | `Tabs` | Left/Right walk the strip the way it is drawn |
125
+ | `Table` | column resizing, the sort mark, the header's scroll |
126
+ | `SplitPane` | which pane is first, the drag and its arrows |
127
+ | `MenuBar` / `ContextMenu` | submenus open leftwards, and their arrows |
128
+ | `Select`, `PasswordInput` | the field's own insets follow the text |
129
+
130
+ `<textinput>` and `<textarea>` mirror inside too: value, placeholder and caret
131
+ sit against the start edge, and the value is shaped at the box's base level
132
+ ([styling.md](styling.md#inside-an-editable-field)); Left/Right and Home/End
133
+ still step through the string, not across the screen. The style property and
134
+ logical edges: [styling.md](styling.md#direction-and-the-logical-edges).
135
+
136
+ ### Size and shape
137
+
138
+ `paddingY` is the space **you can see**, above the capitals and below the
139
+ baseline: every widget label is trimmed to its letters
140
+ ([styling.md](styling.md#measuring-text-to-its-letters)), so a control is its
141
+ label band plus `paddingY` twice — a larger number than CSS would need, and
142
+ the same in every typeface. Menu rows, `Select` options and `<textinput>`
143
+ follow the rule, so a field, a `Button` and a `Select` on one row match in
144
+ height.
145
+
146
+ The `…Active` four are the colour **while held**, derived one step past the
147
+ hover (`accent` → `accentHover` → `accentActive`), so a palette rarely sets
148
+ them; one that is set wins. See [The press state](#the-press-state).
149
+
150
+ The popup radii follow the **text size**, not `radius`: `radiusPopup` is half
151
+ the body size (7px at 14px), and `radiusPopupItem` (a menu row's highlight)
152
+ and `radiusTooltip` (the bubble) step in from it. Naming one pins it.
153
+ `radiusPopupItem` is a **ceiling**: a row's corner is `radiusPopup` less the
154
+ sheet's border and the row's inset, so the curves stay concentric — round the
155
+ sheet to round the highlights. Rounded corners only show where the display
156
+ composites (`'@supports transparency'`, [styling.md](styling.md)); elsewhere
157
+ the widgets stay square.
158
+
159
+ ### `ThemeProvider` and `useTheme()`
160
+
161
+ Widgets read the palette as React context through `useTheme()`; a `$token` in
162
+ a style resolves against the nearest `theme` **prop** in the node tree
163
+ ([styling.md](styling.md#theme-tokens)). `ThemeProvider` feeds both, so this
164
+ paints the palette, and an inner provider merges over an outer one:
165
+
166
+ ```jsx
167
+ <ThemeProvider value={dark}>
168
+ <box style={{ backgroundColor: '$background' }}>…</box>
169
+ </ThemeProvider>
170
+ ```
171
+
172
+ `useTheme()` returns the complete palette in force — the object token
173
+ resolution sees. It indexes like a bag of tokens: `theme[name]` needs no cast;
174
+ named tokens keep their types, anything else is `unknown`. What a provider
175
+ _accepts_ stays closed (`Partial<Theme>`), so a palette typo is caught.
176
+
177
+ ```jsx
178
+ function Panel() {
179
+ const theme = useTheme();
180
+ return <box style={{ borderColor: theme.border }} />;
181
+ }
182
+ ```
183
+
184
+ **Above the windows**, at the root of the tree, a provider draws nothing and
185
+ every window under it takes the palette — background, the fallback type of
186
+ unstyled `<text>`, the `$token`s — however the windows arrive (a component
187
+ that renders one, a closed window, several, a nested provider):
188
+
189
+ ```jsx
190
+ root.render(
191
+ <ThemeProvider value={brand} colorScheme={settings.theme}>
192
+ <App />
193
+ </ThemeProvider>,
194
+ );
195
+ ```
196
+
197
+ **Inside a window** it is a `<box>` styled `{ flexGrow: 1 }`; pass `style` to
198
+ change that (`{ flexGrow: 0 }` around a single control). A nested `<window>`
199
+ under a provider written directly in a window takes the palette with it; a
200
+ provider deeper inside a `<box>` has no window to hand it to, and the error
201
+ says to move it. Widgets plant the palette on their own root node too, so a
202
+ `$token` in a style you pass one resolves with no provider at all.
203
+
204
+ `examples/themes.js` has GitHub, macOS and Windows palettes in light and dark;
205
+ `npm run examples:theming` switches between them.
206
+
207
+ ## System icons
208
+
209
+ The glyphs the widgets are drawn with, exported so your toolbar and a
210
+ `Select` agree about which way a chevron points.
211
+
212
+ ```jsx
213
+ import { Icon } from 'react-x11';
214
+
215
+ <Icon name="chevronDown" />
216
+ <Icon name="check" size={10} color={theme.accentText} />
217
+ ```
218
+
219
+ | name | |
220
+ | ------------------------------------------------------ | ------------------------------------ |
221
+ | `chevronRight` `chevronLeft` `chevronDown` `chevronUp` | more this way: menus, twisties, sort |
222
+ | `check` `dash` `dot` | chosen, partly chosen, one of many |
223
+ | `close` `plus` `moreVertical` | dismiss, add, overflow |
224
+ | `eye` `eyeOff` | reveal a secret, hide it again |
225
+
226
+ | prop | |
227
+ | ------- | ----------------------------------------------------- |
228
+ | `name` | one of the above; anything else throws |
229
+ | `size` | the mark, px. Default: a shade under `theme.fontSize` |
230
+ | `color` | the ink. Default: `theme.text` |
231
+
232
+ Everything else goes to the host `<canvas>`, so a clickable one is
233
+ `<Icon name="close" onClick={…} focusable />`.
234
+
235
+ **`size` is the mark**, ink corner to corner — not a 24px grid with 14px of
236
+ ink in it as in lucide-style sets — so pick the size you want to see. A
237
+ chevron is the one glyph whose axes differ (45° arms):
238
+ `<Icon name="chevronDown" size={10} />` is 10 wide and 5 tall, and
239
+ `chevronRight` at the same size is 5 wide and 10 tall.
240
+
241
+ ### Affordances, not nouns
242
+
243
+ The set holds glyphs about **the control** — more here, chosen, close,
244
+ hidden — and no nouns (folder, document, printer). Bring your own icon set:
245
+ a `<svg>` or `<canvas>` goes anywhere an `<Icon>` does, and `ContextMenu`'s
246
+ `icon` takes either. The platform's own set is
247
+ [`<image src={{ symbol }}>`](elements.md#src-symbol---the-platforms-own-icons):
248
+ an SF Symbol on macOS, the desktop's icon theme elsewhere, in the text colour.
249
+ The drawings are not themable: colour and size are yours, the shape is not.
250
+
251
+ ### Colour inherits; size does not
252
+
253
+ `color` inherits ([styling.md](styling.md#inheritance-the-ink-the-face-and-the-size)),
254
+ so an icon takes the ink it is written inside, hover included; the `color`
255
+ prop is for ink the surrounding text does not have (a destructive mark, a
256
+ check on an accent fill):
257
+
258
+ ```jsx
259
+ <box style={{ color: theme.textMuted, ':hover': { color: theme.accent } }}>
260
+ <text>Open recent</text>
261
+ <Icon name="chevronRight" />
262
+ </box>
263
+ ```
264
+
265
+ **`size` does not inherit, and is not `fontSize`**: it defaults from the
266
+ palette's `fontSize` and stays put when a label beside it shrinks. Pass
267
+ `size` for the one icon that must be bigger.
268
+
269
+ ### What it costs to draw one
270
+
271
+ Each glyph is a module-level drawing over `<canvas mono>`
272
+ ([elements.md](elements.md#canvas)), cached as coverage and coloured at
273
+ composite time, so one copy per glyph and size serves every colour and state.
274
+ `icons` is the map of raw drawings, for a widget that wants the glyph without
275
+ the component; keep `onDraw` identity-stable as these are, or every render
276
+ repaints:
277
+
278
+ ```jsx
279
+ import { icons } from 'react-x11';
280
+
281
+ <canvas
282
+ mono
283
+ cacheKey="check"
284
+ onDraw={icons.check}
285
+ style={{ width: 12, height: 12, color: '$textMuted' }}
286
+ />;
287
+ ```
288
+
289
+ Icons are **`aria-hidden` by default** — the meaning is on the control's
290
+ `role` and `aria-expanded`. Name one
291
+ (`aria-hidden={false} aria-label="Close"`) only when the icon _is_ the
292
+ control.
293
+
294
+ ## Basic controls
295
+
296
+ `Button`, `Checkbox`, `Radio`/`RadioGroup`, `Switch` and `ProgressBar` are
297
+ focusable, activate on click — and so on Space and Enter, unless Ctrl, Alt or
298
+ Super make it a shortcut ([events.md](events.md#space-and-enter-are-a-click))
299
+ — set the pointer cursor, and draw hover, press and focus as
300
+ `:hover`/`:active`/`:focus` style blocks, so hovering repaints one node
301
+ rather than rendering. All but `Radio` take `style` (merged after their own)
302
+ and forward other props to the host box; `Radio` takes only its listed props,
303
+ and its group carries the layout.
304
+
305
+ ```jsx
306
+ import { Button, Checkbox, RadioGroup, Radio, Switch, ProgressBar } from 'react-x11';
307
+
308
+ <Button primary onPress={save}>
309
+ Save
310
+ </Button>
311
+ <Checkbox checked={wrap} onChange={(ev) => setWrap(ev.value)}>Wrap lines</Checkbox>
312
+ <Switch checked={live} onChange={(ev) => setLive(ev.value)} />
313
+ <ProgressBar value={0.4} style={{ width: 200 }} />
314
+
315
+ <RadioGroup value={size} onChange={(ev) => setSize(ev.value)}>
316
+ <Radio value="s">Small</Radio>
317
+ <Radio value="m">Medium</Radio>
318
+ </RadioGroup>;
319
+ ```
320
+
321
+ Label text is the children (or a `label` prop); a bare string is wrapped in
322
+ a `<text>` for you. All five are demoed in `examples/widgets.jsx`.
323
+
324
+ ### Native bezels
325
+
326
+ On the **Cocoa** backend (never X11) `Button`, `Checkbox`, `Radio`, `Switch`,
327
+ `Slider` and `Select` wear AppKit's bezels by default; on **win32** all but
328
+ `Switch` wear the system's own while the desktop is light, and are drawn in
329
+ dark mode. Interaction, focus, keyboard and accessibility stay this
330
+ library's. Opt out at three levels:
331
+
332
+ ```jsx
333
+ // the app's identity
334
+ <ThemeProvider value={{ controls: 'drawn' }}>
335
+ {/* one control */}
336
+ <Button native={false}>Brand</Button>
337
+ </ThemeProvider>;
338
+
339
+ const canGoNative = useSupports('nativeControls'); // what the backend has
340
+ ```
341
+
342
+ `controls` (a theme token, since looking native is an app-wide decision) is
343
+ `'auto'` (bezels where the backend has them), `'native'` (ask, and hear about
344
+ it in development where there are none) or `'drawn'`. Give `native={false}`
345
+ to a control that names its own colours in `style` — a system bezel ignores
346
+ them.
347
+
348
+ Under a native bezel, `style` sizes the slot the control _sits in_: a
349
+ `<Button>` or `<Select>` keeps AppKit's height, centred, so
350
+ `height: 44`, `height: '100%'`, `flexGrow: 1` or a taller neighbour in a
351
+ stretching row leave a 22pt control in a 44pt slot, and a press in the slack
352
+ does nothing. Drawn controls stretch to the box. A `<Button>` squeezed
353
+ narrower than its label wraps it at its words and grows by the lines —
354
+ native or drawn (on macOS with AppKit's flexible-height bezel); give the row
355
+ `flexWrap: 'wrap'` to move buttons to a second row instead. Design notes:
356
+ [macos.md](macos.md#native-controls).
357
+
358
+ ### The press state
359
+
360
+ Every control activates on the **release**, and looks distinct while
361
+ **held** — on the press, though the press does nothing yet — so a slow click
362
+ is visibly heard. Four states: resting, hovered, held, hovered again on
363
+ release. Dragging off the control drops the held look and coming back
364
+ restores it, so the look always says whether releasing there would activate.
365
+ For a control of your own, `:active`
366
+ ([styling.md](styling.md#inline-pseudo-states)) is the state block and
367
+ `accentActive`/`surfaceActive`/`textMutedActive`/`dangerActive` the colours.
368
+
369
+ ### The change event, and `name`
370
+
371
+ Every value control — `Checkbox`, `Switch`, `RadioGroup`, `Select`,
372
+ `Slider` — calls `onChange(ev)` with the **same change event**
373
+ `<textinput>` and `<textarea>` use
374
+ ([elements.md](elements.md#the-change-event)); the new value is `ev.value`:
375
+
376
+ ```js
377
+ { type: 'change',
378
+ target: { type: 'checkbox', name: 'agree', value: true, checked: true },
379
+ currentTarget: /* the same object */,
380
+ name: 'agree', value: true }
381
+ ```
382
+
383
+ So a form library's handler goes straight onto any control:
384
+
385
+ ```jsx
386
+ <textinput name="host" value={f.values.host} onChange={f.handleChange} />
387
+ <Checkbox name="agree" checked={f.values.agree} onChange={f.handleChange} />
388
+ ```
389
+
390
+ `target` is a plain descriptor, not a node (a widget has no single node
391
+ holding its value), shaped as formik's `handleChange` and react-hook-form
392
+ read it — `target.type` tells a checkbox from a text field. There is no
393
+ `preventDefault`: the value has already changed.
394
+
395
+ **The line is `name`**: a widget that takes one is a form field and reports
396
+ an event. `Tabs`, `Table` and the menus are not, and keep plain callbacks
397
+ (`Tabs` calls `onChange(id)`). `name` is otherwise inert. See
398
+ [docs/ecosystem/forms.md](ecosystem/forms.md).
399
+
400
+ ### `Button`
401
+
402
+ | prop | |
403
+ | -------------------- | ----------------------------------------------------------------------- |
404
+ | `children` / `label` | the label |
405
+ | `onPress()` | click, Space or Enter |
406
+ | `variant` | `'solid'` (default), `'outline'`, `'ghost'` — the chrome |
407
+ | `size` | `'medium'` (default) or `'small'` — the compact metric |
408
+ | `primary` | speaks in the accent: the fill when solid, the ink and border otherwise |
409
+ | `disabled` | inert, dimmed, not focusable |
410
+
411
+ `variant` and `primary` compose: `solid` is a fill, `outline` a border,
412
+ `ghost` neither (for a `✕` on a chip, or a delete inside a form row); a
413
+ dialog footer is `outline` beside `primary`, a toolbar's loudest icon
414
+ `primary variant="ghost"`. Every variant keeps the widget states and the
415
+ border _width_, so a mixed row lines up. `size="small"` halves the control
416
+ padding and follows the palette's `paddingY`, which a hand-set `height`
417
+ would not.
418
+
419
+ A label with too little room wraps at its words, centred, and the button
420
+ grows — a too-narrow row squeezes each button to its longest word first. An
421
+ **element** child inherits the label's colour in every state, disabled
422
+ included:
423
+
424
+ ```jsx
425
+ <Button primary disabled={busy} onPress={run}>
426
+ <Icon name="chevronRight" />
427
+ Continue
428
+ </Button>
429
+
430
+ <Button variant="ghost" size="small" aria-label="Remove" onPress={remove}>
431
+ <Icon name="close" />
432
+ </Button>
433
+ ```
434
+
435
+ ### `Checkbox`
436
+
437
+ | prop | |
438
+ | -------------------- | ----------------------------------------- |
439
+ | `checked` | current value (controlled) |
440
+ | `onChange(ev)` | a change event; the value is `ev.value` |
441
+ | `name` | field name, for form libraries |
442
+ | `children` / `label` | label to the right of the 16px check well |
443
+ | `disabled` | inert, dimmed |
444
+
445
+ ### `Radio` / `RadioGroup`
446
+
447
+ `RadioGroup` takes `value`, `onChange(ev)`, `name`, `style` and any box
448
+ props; each `Radio` takes only its `value`, `children`/`label` and
449
+ `disabled`, so per-radio styling goes on the group. A `Radio` outside a
450
+ `RadioGroup` throws. Arrow keys move the selection in **mount order**,
451
+ wrapping (Up/Left back, Down/Right forward); click or Space selects the
452
+ focused one.
453
+
454
+ ### `Switch`
455
+
456
+ `checked`, `onChange(ev)`, `name` and `disabled`, as `Checkbox`, in a
457
+ sliding pill (the thumb's `left` transition is the worked example in
458
+ [styling.md](styling.md#transitions)).
459
+
460
+ ### `ProgressBar`
461
+
462
+ Determinate with a `value`, indeterminate without one.
463
+
464
+ | prop | |
465
+ | --------------- | ------------------------------------------------------- |
466
+ | `value` | 0 to 1, clamped |
467
+ | `indeterminate` | working, extent unknown — a block slides across forever |
468
+ | `color` | fill colour (defaults to the theme accent) |
469
+ | `trackColor` | the groove (defaults to the theme track) |
470
+ | `height` | bar thickness, default 8; the corner radius follows it |
471
+
472
+ ```jsx
473
+ <ProgressBar value={0.4} style={{ width: 200 }} />
474
+ <ProgressBar indeterminate style={{ width: 200 }} />
475
+ ```
476
+
477
+ The indeterminate block is a [style loop](styling.md#loops) on the logical
478
+ `start` edge, so it mirrors with the text direction and fits any width; it
479
+ reports `aria-busy` **with no value**, telling a screen reader no percentage
480
+ is coming. Under
481
+ [reduced motion](system.md#usedesktopsettings--how-the-desktop-wants-an-app-to-feel)
482
+ it does not move and parks mid-track.
483
+
484
+ ## `Select`
485
+
486
+ A dropdown whose menu is an override-redirect `<popup>` anchored below the
487
+ trigger.
488
+
489
+ ```jsx
490
+ import { Select } from 'react-x11';
491
+
492
+ <Select
493
+ style={{ width: 160 }}
494
+ value={color}
495
+ options={[
496
+ { value: '#2980b9', label: 'Blue' },
497
+ { value: '#c0392b', label: 'Red' },
498
+ 'green', // shorthand: value === label
499
+ ]}
500
+ onChange={(ev) => setColor(ev.value)}
501
+ placeholder="Pick a color…"
502
+ />;
503
+ ```
504
+
505
+ | prop | |
506
+ | ----------------------- | ----------------------------------------- |
507
+ | `options` | array of `{value, label}` or plain values |
508
+ | `value`, `onChange(ev)` | selection |
509
+ | `name` | field name, for form libraries |
510
+ | `placeholder` | trigger text when nothing is selected |
511
+ | `style` + any box props | forwarded to the trigger box |
512
+ | `labelStyle` | the caption's style, over `text` |
513
+ | `chevronStyle` | the chevron's style, over `textMuted` |
514
+ | `native`, `nativeMenu` | the platform's bezel, and its menu |
515
+
516
+ `style` restyles the trigger and the two slots what is inside it, keeping the
517
+ menu, keyboard, type-ahead and focus ring. A borderless select in a line of
518
+ text:
519
+
520
+ ```jsx
521
+ <Select
522
+ value={sort}
523
+ options={['Newest', 'Oldest', 'Most replies']}
524
+ onChange={(ev) => setSort(ev.value)}
525
+ style={{
526
+ borderWidth: 0,
527
+ paddingLeft: 0,
528
+ paddingRight: 0,
529
+ backgroundColor: 'transparent',
530
+ ':hover': { backgroundColor: 'transparent' },
531
+ }}
532
+ labelStyle={{ color: theme.accent, fontWeight: 'bold' }}
533
+ chevronStyle={{ color: theme.accent }}
534
+ />
535
+ ```
536
+
537
+ The chevron is sized against the caption's numeric `fontSize`;
538
+ `chevronStyle={{ display: 'none' }}` drops it. A select with either slot is
539
+ drawn even under `controls: 'auto'`. The menu itself is not restyled.
540
+
541
+ **The menu can be the platform's.** On macOS with `@windowkit/appkit` 0.20 or
542
+ later, a select under the native bezel opens a real `NSMenu`: the chosen row
543
+ over the trigger and checked, at least the trigger's width, with AppKit's
544
+ tracking, type-select and VoiceOver. `nativeMenu` makes that choice alone: it
545
+ follows the trigger by default; `true` uses the platform menu under a drawn
546
+ trigger too (as a browser does for a styled `<select>`, and as `<Html>`
547
+ does), at the caption's size and family when the system has it; `false`
548
+ keeps the drawn menu. Elsewhere, or on an older bridge, the menu is drawn. A
549
+ platform menu dismisses itself (Escape, a click outside, the app losing
550
+ focus), and the select hears a pick or nothing.
551
+
552
+ **Behaviour.** The menu opens on the **press**, as in every desktop toolkit;
553
+ a press while it is up closes it. Space and Enter toggle it; Escape, focus
554
+ loss, losing the window (switching applications) or picking close it. The
555
+ list scrolls past 220px, is sized to its longest option (rows end in `…`
556
+ only on a screen narrower than that), and is drawn as a menu is. The
557
+ trigger is a Tab stop, and a caption with no room ends in `…` rather than
558
+ wrapping.
559
+
560
+ Keys go to the focused trigger, menu open or not:
561
+
562
+ | key | closed | open |
563
+ | --------------------- | -------------- | --------------------------------------- |
564
+ | `Down` / `Up` | opens the menu | move the active option, wrapping around |
565
+ | `Home` / `End` | — | first / last option |
566
+ | `PageUp` / `PageDown` | — | a menu's height of options, clamped |
567
+ | `Enter` / `Space` | opens the menu | pick the active option |
568
+ | `Escape` | — | close without picking |
569
+
570
+ The menu opens with the current value active; hovering an option makes it
571
+ active (one highlight for pointer and keyboard), and it is scrolled into view.
572
+ **Type-ahead**: letters typed within 700ms form one query (`b`,`l` finds
573
+ _blueberry_, not _banana_), and a repeated single letter cycles; with the
574
+ menu open this moves the highlight, closed it changes the value, as a native
575
+ select does.
576
+
577
+ ### Theming
578
+
579
+ `Select` reads the palette from [`ThemeProvider`](#theming) like every
580
+ widget: the trigger is `background`/`text` in a `border` box that turns
581
+ `borderFocus` when focused or open; chevron and placeholder are `textMuted`;
582
+ the menu highlight is `hoverBackground`/`hoverText`.
583
+
584
+ ## `Calendar` / `DatePicker`
585
+
586
+ Moved to [`@react-x11/components`](ecosystem.md): a date picker is a locale
587
+ problem before it is a widget.
588
+
589
+ ## `PasswordInput`
590
+
591
+ A masked field whose mask is a **scribble**, not a row of bullets.
592
+
593
+ ```jsx
594
+ <PasswordInput
595
+ value={secret}
596
+ onChange={(ev) => setSecret(ev.value)}
597
+ onSubmit={() => signIn()}
598
+ />
599
+ ```
600
+
601
+ | prop | |
602
+ | ----------------------------- | -------------------------------------- |
603
+ | `value` / `defaultValue` | the secret; controlled with `onChange` |
604
+ | `onChange(ev)` | `ev.value` is the new value |
605
+ | `onSubmit(value)` | Enter |
606
+ | `placeholder` | shown while empty (`'Password'`) |
607
+ | `revealable` | show the eye at all, default `true` |
608
+ | `revealed` / `onRevealChange` | drive the reveal yourself |
609
+ | `maxLength` | in code points |
610
+ | `drawMask(ctx, info)` | draw the mask yourself |
611
+ | `disabled` | not focusable, not editable |
612
+
613
+ ### Why a scribble
614
+
615
+ Bullets show how many characters were typed and barely show the keystroke
616
+ that just landed. The scribble is the reverse: **the whole stroke changes on
617
+ every character**, nothing in it is per-character, and its width only grows
618
+ (never twitching when a character is replaced, never a clean multiple of
619
+ anything). **It hides a glance, not a recording**: someone watching it grow
620
+ still counts keystrokes. Design notes:
621
+ [design/components.md](../design/components.md#why-a-scribble).
622
+
623
+ ### Two states, and what holds across both
624
+
625
+ **Masked**: type, Backspace, Ctrl+Backspace / Ctrl+U / Delete to clear,
626
+ Ctrl+V or Shift+Insert to paste, Enter to submit. No caret, no selection, no
627
+ undo history. **Revealed**: a real `<textinput>` takes its place — caret,
628
+ selection, arrows, clicks, undo, the edit menu. Focus follows the swap both
629
+ ways, and the reveal ends when the keyboard leaves the widget.
630
+
631
+ In **both**, nothing leaves by a selection: Ctrl+C and Ctrl+X do nothing, the
632
+ revealed input is [`sensitive`](elements.md#sensitive) (no Cut or Copy in its
633
+ menu), and neither state takes PRIMARY. While masked the value is **never
634
+ laid out or drawn**, so its glyphs never reach the display server. The eye is
635
+ a pointer affordance, not a tab stop; `revealed` + `onRevealChange` put the
636
+ toggle where the keyboard can reach it. Caps Lock is reported while it is on.
637
+
638
+ ### Drawing your own mask
639
+
640
+ `drawMask(ctx, info)` replaces the scribble; `info` is
641
+ `{ width, height, seed, color, length }`, with the mask width the field
642
+ worked out and the value-and-window seed. The scribble is `strokeScribble` in
643
+ `src/components/scribble.js`, with the reasoning for each number.
644
+
645
+ ```jsx
646
+ <PasswordInput
647
+ drawMask={(ctx, { width, height, color }) => {
648
+ ctx.fillStyle = color;
649
+ ctx.fillRect(0, height / 2 - 1, width, 2);
650
+ }}
651
+ />
652
+ ```
653
+
654
+ ### Password managers
655
+
656
+ XTEST auto-type arrives as ordinary key events, and Ctrl+V pastes with
657
+ control characters stripped (so a manager's trailing newline stays out).
658
+ Which manager does what, the window title's part, and Secret Service, the
659
+ portal and AT-SPI:
660
+ [desktop.md](desktop.md#password-fields-and-password-managers).
661
+
662
+ ## `Slider`
663
+
664
+ A draggable value control.
665
+
666
+ ```jsx
667
+ import { Slider } from 'react-x11';
668
+
669
+ <Slider
670
+ value={volume}
671
+ min={0}
672
+ max={100}
673
+ step={5}
674
+ style={{ width: 200 }}
675
+ onChange={(ev) => setVolume(ev.value)}
676
+ />;
677
+ ```
678
+
679
+ | prop | |
680
+ | ----------------------- | ------------------------------------------------------- |
681
+ | `value`, `onChange(ev)` | current value (controlled) |
682
+ | `name` | field name, for form libraries |
683
+ | `min`, `max`, `step` | range and quantisation (defaults 0, 100, 1) |
684
+ | `height` | the bar thickness (default 4); width comes from `style` |
685
+ | `disabled` | inert, dimmed |
686
+ | `thumbStyle` | the thumb's style, over its default |
687
+ | `trackStyle` | the track's style, over `height` and `track` |
688
+ | `fillStyle` | the filled part of the track, over `accent` |
689
+
690
+ The style slots restyle the parts and keep the drag, keyboard, `aria-*`
691
+ values, RTL and target size. A flat slider:
692
+
693
+ ```jsx
694
+ <Slider
695
+ value={volume}
696
+ onChange={(ev) => setVolume(ev.value)}
697
+ style={{ width: 240 }}
698
+ thumbStyle={{
699
+ width: 14,
700
+ height: 14,
701
+ borderWidth: 0,
702
+ backgroundColor: theme.accent,
703
+ boxShadow: '0 1px 3px rgba(0, 0, 0, 0.3)',
704
+ }}
705
+ trackStyle={{ height: 3, marginStart: 7, marginEnd: 7 }}
706
+ />
707
+ ```
708
+
709
+ The thumb is centred on its value, so the travel is the track less one thumb
710
+ (numeric `thumbStyle` `width`/`height` are what the widget uses); the control
711
+ is as tall as the taller of thumb and track, its target at least 24. Half a
712
+ thumb of `marginStart`/`marginEnd` on the track lets the thumb overhang the
713
+ ends. A slider with any slot is drawn even under `controls: 'auto'`.
714
+
715
+ Dragging uses [pointer capture](events.md#pointer-capture), so it follows
716
+ the pointer anywhere and ends wherever it is released. Keyboard: arrows
717
+ step, `Home`/`End` jump to the ends, `PageUp`/`PageDown` move ten steps.
718
+
719
+ ## `Tooltip`
720
+
721
+ A hover hint in a `<popup>`, so it can extend past the owner window.
722
+
723
+ ```jsx
724
+ import { Tooltip } from 'react-x11';
725
+
726
+ <Tooltip label="Back to zero">
727
+ <Button onPress={reset}>Reset</Button>
728
+ </Tooltip>;
729
+ ```
730
+
731
+ | prop | |
732
+ | ----------------------- | ---------------------------------------------------------------------- |
733
+ | `label` | the hint: a string, or an element (nothing shows without it) |
734
+ | `direction` | `'auto'` (default), `'top'`, `'bottom'`, `'left'`, `'right'` |
735
+ | `delay` | ms of hover before showing (default 500) |
736
+ | `fontSize` | label size, also used to size the popup |
737
+ | `width`, `height` | pin an axis of the bubble exactly; anything not pinned is measured |
738
+ | `maxWidth`, `maxHeight` | cap what an element label measures out to, inside the screen's own cap |
739
+
740
+ It wraps its children in a row box carrying the hover handlers and anchor ref,
741
+ so it composes around any element, and hides at once on leave and on
742
+ mousedown. **One at a time** per connection: a trigger taking the hover
743
+ dismisses any showing hint immediately. `direction` is a preference: `'auto'`
744
+ takes the first side that fits on the **screen**, preferring above, and a
745
+ named side still flips rather than open off-screen. (`placement` is the older
746
+ name and still wins where given.)
747
+
748
+ An **element** `label` is measured like a string one: rendered
749
+ [`hidden`](elements.md#hidden--realized-laid-out-not-on-screen) at its
750
+ natural size (capped by the screen and `maxWidth`/`maxHeight`) and placed
751
+ before it maps, so it never appears misplaced. It fills the bubble with no
752
+ padding imposed; anything renders there, widgets included
753
+ (`examples/tooltips.jsx`):
754
+
755
+ ```jsx
756
+ <Tooltip
757
+ label={
758
+ <box style={{ flexGrow: 1, padding: 10, gap: 7 }}>
759
+ <text style={{ color: '#f5f6fa' }}>Scratch volume</text>
760
+ <ProgressBar value={used} />
761
+ </box>
762
+ }
763
+ maxWidth={260}
764
+ direction="right"
765
+ >
766
+ <box>…</box>
767
+ </Tooltip>
768
+ ```
769
+
770
+ `width` and `height` together skip measuring; one alone fixes that axis and
771
+ measures the other (`width={340}` is a column whose height fits). The size is
772
+ settled at open and kept until the next.
773
+
774
+ **The palette inside is inverted**: the bubble is drawn in the palette's ink,
775
+ so inside it `$background` (and `$surface`) is the bubble and `$text` the ink,
776
+ published through a `ThemeProvider` so `useTheme()` agrees, with `textMuted`
777
+ derived to match. Write content with tokens and it reads in both schemes; a
778
+ hard-coded light text colour vanishes on the light bubble of a dark palette.
779
+ Where the display composites the bubble is rounded at `radiusTooltip`, with
780
+ no border and a small arrow at the trigger's middle; elsewhere it is a
781
+ square rectangle with no arrow.
782
+
783
+ ## `Dialog`
784
+
785
+ A `<popup>` centred over its owner that the **window manager knows is a
786
+ dialog**: managed and transient for its owner (`WM_TRANSIENT_FOR` on X11) —
787
+ framed, movable, closable through the WM, out of the taskbar and alt-tab, and
788
+ on a full EWMH window manager stacked above its owner and iconified with it.
789
+
790
+ ```jsx
791
+ import { Button, Dialog } from 'react-x11';
792
+
793
+ <Dialog
794
+ open={confirming}
795
+ title="Clear the form?"
796
+ onClose={() => setConfirming(false)}
797
+ actions={
798
+ <>
799
+ <Button label="Cancel" onPress={() => setConfirming(false)} />
800
+ <Button primary autoFocus label="Clear" onPress={clear} />
801
+ </>
802
+ }
803
+ >
804
+ The name and the greeting below it will be discarded.
805
+ </Dialog>;
806
+ ```
807
+
808
+ | prop | |
809
+ | ---------- | ------------------------------------------------------------------- |
810
+ | `open` | renders nothing when false; the popup exists only while true |
811
+ | `title` | names the dialog; drawn once ([below](#managed)) |
812
+ | `children` | body content; strings become `<text>` |
813
+ | `actions` | elements for the right-aligned button row |
814
+ | `onClose` | Escape, or the window manager's close button |
815
+ | `managed` | `false` for an unframed override-redirect popup ([below](#managed)) |
816
+ | `width` | popup width (default 360); `'auto'` for the content's |
817
+ | `height` | default `'auto'`, the content's ([below](#size-and-placement)) |
818
+
819
+ Focus is the renderer's `trapFocus` ([Focus scopes](events.md#focus-scopes-modals)):
820
+ Tab stays inside, presses elsewhere do not move focus, and focus returns to
821
+ whatever had it (usually the opening button) on close. `autoFocus` picks the
822
+ first stop; with nothing focusable the dialog surface takes focus
823
+ (`tabIndex={-1}`), so Escape and Tab work at once. **Pointer modality is not
824
+ enforced** — the owner stays clickable — so it is for confirmations, not
825
+ guarding state.
826
+
827
+ ### `managed`
828
+
829
+ | `managed` | what you get |
830
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------- |
831
+ | `true` (default) | frame, titlebar (the `title`), movable, WM close button, transient for its owner; a press outside does not close it |
832
+ | `false` | no frame, not movable, a pointer grab, the `title` as a bold heading; a press outside calls `onClose` |
833
+
834
+ The frame and the grab go together (a grab would swallow the press that
835
+ starts a window drag). `managed={false}` suits a display with no window
836
+ manager. Either way the `title` names the dialog for accessibility, and the
837
+ owner is found automatically
838
+ ([`transientFor`](elements.md#transientfor--a-window-that-belongs-to-another)).
839
+
840
+ ### Size and placement
841
+
842
+ **The height is the content's** (`height="auto"`): the dialog measures its
843
+ content, centres over the owner at that size, and only then opens
844
+ ([`placement: 'center'`](elements.md#centred-over-something)).
845
+
846
+ - **Capped at the monitor**: taller content scrolls in the body while the
847
+ `actions` row stays pinned; the body is a tab stop only while it can
848
+ scroll. A numeric `height` scrolls the same way.
849
+ - **Following its content**: content that changes while open re-fits the
850
+ dialog from its **top edge**, moving it up only to stay on the monitor; a
851
+ managed dialog the user dragged grows where they put it.
852
+ - **Centred once**, over where the owner was at open; moving the owner does
853
+ not move it.
854
+
855
+ `width` defaults to 360 so prose wraps; `width="auto"` is for content with a
856
+ width of its own (a grid of fields, an image). A number is exact on either
857
+ axis. `centerRect(node, {width, height})` is the same centring as a function,
858
+ for popups of your own that know their size; one that does not uses
859
+ `<popup anchor={{ rect, placement: 'center' }}>`.
860
+
861
+ ## `MenuBar` / `ContextMenu`
862
+
863
+ Pull-down and right-click menus in `<popup>` windows, anchored with
864
+ `useAnchor`. On the Cocoa backend with native controls they are sized as
865
+ `NSMenu` sizes its own — 22pt rows, the 13pt menu font
866
+ ([macos.md](macos.md#menus-the-global-menu-finally-at-home)).
867
+
868
+ ```jsx
869
+ import { MenuBar, ContextMenu } from 'react-x11';
870
+
871
+ <MenuBar
872
+ menus={[
873
+ {
874
+ label: 'File',
875
+ items: [
876
+ { label: 'New', shortcut: [['Control', 'N']], onSelect: newFile },
877
+ { type: 'separator' },
878
+ { label: 'Save As…', enabled: false },
879
+ {
880
+ label: 'Wrap lines',
881
+ toggleType: 'checkmark',
882
+ toggleState: wrap ? 1 : 0,
883
+ onSelect: toggleWrap,
884
+ },
885
+ ],
886
+ },
887
+ ]}
888
+ />;
889
+
890
+ <ContextMenu items={items} style={{ flexGrow: 1 }}>
891
+ <text>Right-click me</text>
892
+ </ContextMenu>;
893
+ ```
894
+
895
+ Item shape: `{ label, onSelect, type, items, enabled, visible, shortcut,
896
+ toggleType, toggleState, icon, iconName, disposition, key }`. Both take an
897
+ `onSelect(item)` prop too, fired after the item's own. A bar menu opens on
898
+ the **press**, like `Select`.
899
+
900
+ **The vocabulary is `com.canonical.dbusmenu`'s** — `type: 'separator'`,
901
+ `enabled: false`, `toggleType`/`toggleState` (`0`, `1`, or `-1` for
902
+ indeterminate), `shortcut` as modifier tokens — because on a desktop whose
903
+ panel shows application menus, `MenuBar` hands the array over and stops
904
+ drawing, with the same JSX ([globalmenu.md](globalmenu.md), also for
905
+ `globalMenu={false}`).
906
+
907
+ **`shortcut` is a binding, not a label**: `[['Control', 'S']]` makes Ctrl+S
908
+ fire the item's `onSelect` while the menu is mounted, unopened, gated by its
909
+ `enabled` and `visible`, and still when a panel has taken the menu over. The
910
+ same array is the drawn shortcut, the dbusmenu payload and the
911
+ `aria-keyshortcuts` announcement. Chord rules, key names, `useAccelerator`
912
+ and `accelerators={false}`: [events.md](events.md#accelerators).
913
+
914
+ **A context menu's items can come from the right-click.** The click records
915
+ only where it happened; the menu's content, size and placement come from the
916
+ `items` of the next render, so items derived in a row's `onContextMenu` give
917
+ a menu for that row on the first right-click. Items still empty open nothing
918
+ — no popup, no grab, focus left where the press put it.
919
+
920
+ ```jsx
921
+ const [target, setTarget] = useState(null);
922
+
923
+ <ContextMenu items={target ? itemsFor(target) : []}>
924
+ {rows.map((row) => (
925
+ <box key={row.id} onContextMenu={() => setTarget(row)}>
926
+ <text>{row.name}</text>
927
+ </box>
928
+ ))}
929
+ </ContextMenu>;
930
+ ```
931
+
932
+ **Look.** Where the display composites, a menu is rounded (`radiusPopup`,
933
+ hairline border) and the row highlight an inset pill (`radiusPopupItem`) in
934
+ `hoverBackground`, the token shared with `Select` and `Table` highlights;
935
+ without a compositor both are square. Only the deepest open level wears
936
+ `hoverBackground`; the bar item and parent rows behind it are
937
+ `surfaceActive`. A bar too narrow for its titles moves the rest behind a
938
+ `moreVertical` entry whose rows open them as submenus; the keyboard walks
939
+ only what is painted. Design notes:
940
+ [design/components.md](../design/components.md#menubar--contextmenu).
941
+
942
+ **Icons.** `icon` fills the column left of the label, shared with the
943
+ `toggleType` mark (a checked item with an icon shows the check). It is drawn
944
+ locally and never crosses the bus; `iconName` is the icon-theme name a panel
945
+ uses. The column exists only while some row paints in it, so ticking the
946
+ first item of an unticked menu shifts its labels once. Pass a **function**:
947
+ it gets the row's ink and the column size, so one drawing follows the
948
+ highlight and the disabled grey —
949
+
950
+ ```jsx
951
+ const save = ({ color, size }) => (
952
+ <svg source={SAVE_SVG} style={{ width: size, height: size, color }} />
953
+ );
954
+
955
+ { label: 'Save', icon: save, shortcut: [['Control', 'S']], onSelect: onSave }
956
+ { label: 'Close', icon: (p) => <Icon name="close" {...p} />, onSelect: onClose }
957
+ ```
958
+
959
+ — an SVG painted in `currentColor` is cached once for every colour
960
+ ([elements.md](elements.md#svg)), as a [system icon](#system-icons) is. A
961
+ **string** icon is drawn as text and is only as good as the font (`✂` or `⏻`
962
+ is an empty box without one); an element renders as-is. Toggle marks are
963
+ the `check`, `dot` and `dash` icons. `examples/menu.jsx` has a worked set.
964
+
965
+ **Submenus.** An item with its own `items` is a submenu parent, marked with a
966
+ `chevronRight`. Nesting is unlimited; each level is a `<popup>` opening to the
967
+ end side, its first item level with its parent row, flipping at a screen
968
+ edge.
969
+
970
+ ```jsx
971
+ { label: 'Export', items: [
972
+ { label: 'PNG', onSelect: exportPng },
973
+ { label: 'SVG', onSelect: exportSvg },
974
+ ] }
975
+ ```
976
+
977
+ **Keyboard.** Up/Down move, **skipping separators and disabled entries**,
978
+ wrapping; Home/End jump to the ends; PageUp/PageDown step ten rows, then to
979
+ the nearest selectable one (menus size to content, never scroll); Right opens
980
+ a submenu at its first item and Left leaves it; Enter/Space activate or open;
981
+ Escape closes **one level at a time**. In a `MenuBar`, Left/Right move between
982
+ menus when there is no submenu to move through, and with one menu open,
983
+ hovering another switches to it. Hovering a parent opens its submenu with
984
+ nothing selected. **Type-ahead** works in the deepest open level: 700ms
985
+ queries (`c`,`a` finds _Carrot_), a repeated letter cycles, disabled entries
986
+ and separators never match.
987
+
988
+ Focus stays in the _owner_ window, as with `Select`; `ContextMenu`'s wrapper
989
+ is focusable and takes focus when its menu opens, but is not a Tab stop
990
+ (`tabIndex={-1}`). Losing the window closes an open menu.
991
+
992
+ ### Safe-polygon hover
993
+
994
+ `MenuBar`, `ContextMenu` and `Tooltip` keep a submenu or hint open while the
995
+ pointer crosses towards it, using
996
+ [floating-ui's safePolygon](https://floating-ui.com/docs/usehover#safepolygon)
997
+ idea: the triangle between where the pointer was and the near edge of the
998
+ open surface counts as still hovering the parent. Inside it, hover changes
999
+ wait at most `SAFE_HOVER_DELAY` (320 ms); leaving it switches at once, and
1000
+ reaching the surface keeps it open.
1001
+
1002
+ The helpers are exported for widgets of your own:
1003
+ `movingToward(point, apex, rect)`, `safePolygon(apex, rect, buffer)`,
1004
+ `pointInPolygon(point, polygon)` and `screenPoint(ev)`, all in **screen**
1005
+ coordinates, since trigger and popup are different windows.
1006
+
1007
+ ## `Tabs`
1008
+
1009
+ One panel visible at a time, switched by a strip of tabs.
1010
+
1011
+ ```jsx
1012
+ <Tabs
1013
+ items={[
1014
+ { id: 'general', label: 'General', content: <GeneralPage /> },
1015
+ { id: 'advanced', label: 'Advanced', content: () => <AdvancedPage /> },
1016
+ { id: 'legacy', label: 'Legacy', disabled: true },
1017
+ ]}
1018
+ />
1019
+ ```
1020
+
1021
+ | prop | |
1022
+ | ------------------------ | -------------------------------------------------- |
1023
+ | `items` | `{ id, label, content, disabled }[]` |
1024
+ | `value` / `defaultValue` | selected id — controlled with `value` + `onChange` |
1025
+ | `onChange(id)` | a tab was chosen |
1026
+ | `orientation` | `'horizontal'` (default) or `'vertical'` |
1027
+ | `manual` | arrows move focus only; Enter or Space commits |
1028
+
1029
+ `content` may be a node or a function, called only while its tab is
1030
+ selected. Items with no `content` make `Tabs` a pure navigator (in
1031
+ `examples/app.jsx` the strip is in one `SplitPane` half, the panel in the
1032
+ other). The strip is a **single tab stop**: Left/Right (Up/Down when
1033
+ vertical) move and wrap, Home/End jump, disabled tabs are skipped. Arrows
1034
+ select as they move; `manual` splits focus from selection, for expensive
1035
+ panels.
1036
+
1037
+ ## `Table`
1038
+
1039
+ A grid with a fixed header, resizable columns, and only the rows in view
1040
+ built.
1041
+
1042
+ ```jsx
1043
+ <Table
1044
+ columns={[
1045
+ { id: 'name', label: 'Name', width: 220 },
1046
+ { id: 'size', label: 'Size', width: 90, align: 'right' },
1047
+ ]}
1048
+ rows={files}
1049
+ onSelect={(id, row) => open(row)}
1050
+ />
1051
+ ```
1052
+
1053
+ | prop | |
1054
+ | ------------------------------ | --------------------------------------------------- |
1055
+ | `columns` | `{ id, label, width, align, value, render }[]` |
1056
+ | `rows` | `{ id, … }[]` — `id` identifies the row |
1057
+ | `rowHeight` | every row is this tall (24 by default) |
1058
+ | `sort` / `defaultSort` | `{ column, direction }`; reported by `onSortChange` |
1059
+ | `selected` / `defaultSelected` | selected row id; reported by `onSelect(id, row)` |
1060
+ | `onActivate(id, row)` | a double click, or Enter on the selection |
1061
+ | `onColumnResize(id, width)` | after a header drag |
1062
+
1063
+ `value(row)` feeds sorting and the default cell text; `render(row, { selected,
1064
+ column })` replaces the cell. `selected` is there because a colour picked for
1065
+ the resting row can vanish into the selection bar — fall back to `hoverText`:
1066
+
1067
+ ```jsx
1068
+ render: (row, { selected }) => (
1069
+ <text style={{ color: selected ? '$hoverText' : statusColour(row) }}>
1070
+ {row.state}
1071
+ </text>
1072
+ );
1073
+ ```
1074
+
1075
+ **Every row must be `rowHeight` tall**: only the ~20 rows in view are
1076
+ mounted, swapped as you scroll, with spacers keeping the scrollbar true.
1077
+ Sorting a hundred thousand rows is still yours — pass `sort` and sort the
1078
+ data yourself.
1079
+
1080
+ The **table** holds focus, not the row (rows unmount off screen). Up/Down
1081
+ move the selection, PageUp/PageDown by a viewport, Home/End to the ends; a
1082
+ click selects and a double click (`ev.detail`) or Enter fires `onActivate`.
1083
+ The header scrolls sideways with the body, never vertically. A seven-pixel
1084
+ grab band left of each header rule resizes the column (accent while held,
1085
+ no sort on release); it takes focus on the press, so Left/Right then resize
1086
+ by 16px.
1087
+
1088
+ ## `Tree`
1089
+
1090
+ Moved to [`@react-x11/components`](ecosystem.md): virtualisation,
1091
+ drag-to-reorder and lazy children do not belong in core.
1092
+
1093
+ ## `SplitPane`
1094
+
1095
+ Two panes with a divider you can drag.
1096
+
1097
+ ```jsx
1098
+ <SplitPane direction="row" defaultSize={220} min={120} minSecond={300}>
1099
+ <Sidebar />
1100
+ <Editor />
1101
+ </SplitPane>
1102
+ ```
1103
+
1104
+ | prop | |
1105
+ | ---------------------- | ----------------------------------------------- |
1106
+ | `direction` | `'row'` (default) or `'column'` |
1107
+ | `size` / `defaultSize` | the **first** pane's width or height, in pixels |
1108
+ | `onResize(size)` | after a drag or a key step |
1109
+ | `min` / `minSecond` | how small either pane may get |
1110
+
1111
+ Only the first pane's size is stored; the second takes the rest, so a window
1112
+ resize never leaves a gap, and the drag clamps against the container as laid
1113
+ out at that moment. The divider is focusable: arrows move it 16px, Home/End
1114
+ to either limit. A drag captures the pointer and keeps the grip where it was
1115
+ taken.
1116
+
1117
+ ## `Canvas3D`
1118
+
1119
+ Moved to [`@react-x11/components/three`](ecosystem.md) as `<Canvas>`. Core
1120
+ keeps the `<glarea>` surface under it — [gl.md](gl.md),
1121
+ `examples/viewer3d.jsx`.
1122
+
1123
+ ## `useAnchor(ref)` / `anchorRect(node, options)`
1124
+
1125
+ The placement math behind `Select` and `Tooltip`, for popup widgets of your
1126
+ own.
1127
+
1128
+ ```jsx
1129
+ const ref = useRef(null);
1130
+ const measure = useAnchor(ref);
1131
+
1132
+ // screen coordinates for a <popup>, given the size you intend to use
1133
+ const rect = measure({ placement: 'bottom', align: 'center', width, height });
1134
+ // -> { x, y, width, height, placement }
1135
+ ```
1136
+
1137
+ | option | |
1138
+ | ----------------- | ---------------------------------------------------------------------------------- |
1139
+ | `placement` | `'bottom'` (default), `'top'`, `'start'`, `'end'`, `'left'`, `'right'`, `'center'` |
1140
+ | `align` | `'start'` (default), `'center'`, `'end'` on the cross axis |
1141
+ | `offset` | gap from the anchor in px (default 2) |
1142
+ | `at` | a rect **inside** the node to anchor to instead — a caret, a cell |
1143
+ | `width`, `height` | size of the popup you are positioning |
1144
+ | `alignTo` | node the alignment reads, when it is not the anchor |
1145
+ | `alignOffset` | shift along the alignment axis in px, applied before clamping |
1146
+
1147
+ `placement` is a **preference**: near a screen edge the popup flips to the
1148
+ opposite side, and it is clamped into the screen either way; the side used
1149
+ comes back as `placement`. With no screen geometry it does not clamp.
1150
+ `'center'` puts the popup **over** the anchor, middle on middle, only
1151
+ clamped (`align`/`offset` ignored) — what [`Dialog`](#size-and-placement)
1152
+ uses.
1153
+
1154
+ `'start'`/`'end'` are **logical** (a submenu opens towards `'end'`, leftwards
1155
+ when mirrored); `'left'`/`'right'` are physical. `align` mirrors too, but
1156
+ only above or below the anchor. The direction is read off the anchoring
1157
+ node. `alignTo` takes the alignment from another node in the same window — a
1158
+ submenu sits against its menu's outer edge but level with the row that
1159
+ opened it, and `alignOffset` lines its first item up with that row's text.
1160
+
1161
+ ### `at` — anchoring to part of a node
1162
+
1163
+ `at` is a rect in the **anchor node's own coordinates** that becomes the
1164
+ anchor — for a caret, a table cell, a datapoint, a highlighted span. `{x, y}`
1165
+ alone is a point; the popup `width` then defaults to the sub-rect's. Being
1166
+ node-relative, it lets `useAnchorTracking` follow a caret for free, out-of-view
1167
+ test included.
1168
+
1169
+ ```jsx
1170
+ const measure = useAnchor(editorRef);
1171
+ const rect = measure({
1172
+ at: { x: caretX, y: lineTop, width: 1, height: lineHeight },
1173
+ placement: 'bottom',
1174
+ width: 240,
1175
+ height: rows * 22,
1176
+ });
1177
+ ```
1178
+
1179
+ ### `anchorScreenRect(app, rect, options)` — a rect with no node behind it
1180
+
1181
+ The same placement against a **screen** rect in logical pixels — a tray
1182
+ item's frame, where the pointer was; `{x, y}` alone is a point. The popup
1183
+ stays on the monitor the whole rect overlaps most (the nearest if none).
1184
+ `options.scale` is the display's (default 1) and `options.direction` decides
1185
+ `'start'`/`'end'`. A `<popup>` does this itself with
1186
+ [`anchor={{ rect }}`](elements.md#a-popover-under-a-tray-item).
1187
+
1188
+ A popup that must **measure itself first** (rows sized to labels) cannot
1189
+ know its size at render; `<popup anchor={{ to: ref, at }}>` lets the popup
1190
+ place itself once laid out —
1191
+ [elements.md](elements.md#anchor--a-popup-that-places-itself).
1192
+
1193
+ ### `anchorArea(node)`
1194
+
1195
+ The usable part of the monitor `node` is on — panels and docks taken off, the
1196
+ cap [`<window width="auto">`](elements.md#natural-size) uses — or `null` with
1197
+ no display. A surface sizing itself caps itself to it (`Select` and
1198
+ `ContextMenu` do), since `anchorRect` can slide a popup back from an edge but
1199
+ not rescue one too big for the screen.
1200
+
1201
+ ## `useDropTarget(options)` / `useDragSource(options)`
1202
+
1203
+ The render-state layer over the drag-and-drop props, for what a
1204
+ `':drag-over'` or `':dragging'` style block cannot do — a hint label, a
1205
+ disabled sibling, a preview that follows the pointer.
1206
+
1207
+ ```jsx
1208
+ const { dropProps, isOver, isAccepted } = useDropTarget({
1209
+ accept: ['files'],
1210
+ onDrop: (e) => setFiles(e.files),
1211
+ });
1212
+
1213
+ const { dragProps, isDragging, position } = useDragSource({
1214
+ data: { 'text/plain': label },
1215
+ actions: ['copy', 'move'],
1216
+ });
1217
+ ```
1218
+
1219
+ Spread `dropProps` / `dragProps` on any drawn element; they are the same
1220
+ props you could write by hand, so use them when the render changes and the
1221
+ plain props when it does not. `position` is `{ x, y, accepted }` in screen
1222
+ coordinates during a drag — what a `<popup dragPreview>` follows. Full
1223
+ reference: [drag-and-drop.md](drag-and-drop.md).
1224
+
1225
+ ---
1226
+
1227
+ To build a popup widget of your own, `src/components/Select.js` is the
1228
+ reference: state in React, `:hover` style blocks, a `<popup>` for anything
1229
+ that must escape the window, `useAnchor` on the trigger. Do not place a popup
1230
+ from a window's own `x`/`y` — under a reparenting window manager they are
1231
+ frame-relative; `useAnchor` and `ev.screenX`/`screenY` come from the server.