react-x11 1.2.0 → 2.0.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 (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -0,0 +1,140 @@
1
+ // ClientMessage, delivered to the element it was addressed to.
2
+ //
3
+ // ClientMessage is the carrier of every convention layered over the core
4
+ // protocol: EWMH's requests to the window manager, ICCCM's WM_PROTOCOLS,
5
+ // XEmbed, XDND, the system tray, and whatever two copies of one application
6
+ // agree between themselves. react-x11 speaks a few of those itself — XDND in
7
+ // `src/dnd.js`, WM_DELETE_WINDOW through ntk's `close` — and an application
8
+ // that speaks one core does not had exactly one route: subscribe to
9
+ // `X.on('event')`, which is every event on the connection for every window,
10
+ // and filter. `src/xsettings.js` does that internally and it is the right
11
+ // shape *there*, because a settings daemon's window is nobody's element. It
12
+ // is the wrong shape to hand an application: it is not scoped to a window,
13
+ // it does not go away when the window unmounts, and it is expressed in atom
14
+ // ids rather than names.
15
+ //
16
+ // So `<window onClientMessage>` is the seam, and this is what fills it.
17
+ //
18
+ // ## The type is a name, and that costs a round trip once
19
+ //
20
+ // `message_type` is an atom, and an atom is a number that means nothing away
21
+ // from the server that issued it. Comparing against one means interning it
22
+ // first, which is asynchronous, so the obvious handler cannot be written as a
23
+ // `switch` — it has to wait for an atom table to arrive before it can tell
24
+ // one message from another, and that table is the boilerplate this seam
25
+ // exists to delete.
26
+ //
27
+ // Most of the time the name is already there: node-x11 keeps a per-connection
28
+ // id → name table filled from every InternAtom and GetAtomName reply, so an
29
+ // atom this application has ever *named* — which is every atom in a protocol
30
+ // it sends, advertises or owns anything for — resolves synchronously and the
31
+ // message is dispatched in the turn it arrived in.
32
+ //
33
+ // A protocol this application only ever *receives* has no such moment, and
34
+ // that case is the whole point of the feature: a tray host does not send
35
+ // `_NET_SYSTEM_TRAY_OPCODE`, it is sent one. So an unknown atom is resolved
36
+ // with `GetAtomName` — and **every message behind it waits**, which is the
37
+ // part that is not optional. The protocols carried this way are chunked
38
+ // (`_NET_SYSTEM_TRAY_BEGIN_MESSAGE` and the `_NET_SYSTEM_TRAY_MESSAGE_DATA`
39
+ // pieces that reassemble by arrival order alone) or sequenced (XEmbed), so a
40
+ // round trip that let a later message overtake an earlier one would corrupt
41
+ // them in a way no handler could detect. The same FIFO gate `src/dnd.js`
42
+ // runs its own messages through, for the same reason.
43
+ //
44
+ // The cost is one round trip per message *type* per connection — node-x11
45
+ // caches the reply, so the second `_NET_SYSTEM_TRAY_OPCODE` is synchronous
46
+ // like everything else. `messageType` is therefore null only for an atom the
47
+ // server itself does not know, which is a broken sender rather than a case to
48
+ // design around; `atom` carries the id regardless.
49
+
50
+ /** X's event code for ClientMessage, for `ev.type`. */
51
+ const CLIENT_MESSAGE = 33;
52
+
53
+ /** Per-connection id → name lookups in flight or resolved, negatives kept:
54
+ * a sender repeating a bogus atom must not repeat the round trip. */
55
+ const nameCaches = new WeakMap();
56
+
57
+ /** The name of an atom, or a promise for it. Never rejects. */
58
+ function atomName(X, id) {
59
+ const known = X?.atom_names?.[id];
60
+ if (known !== undefined) return known;
61
+ if (typeof X?.GetAtomName !== 'function') return null;
62
+ let cache = nameCaches.get(X);
63
+ if (!cache) nameCaches.set(X, (cache = new Map()));
64
+ const hit = cache.get(id);
65
+ if (hit) return hit;
66
+ const pending = new Promise((resolve) =>
67
+ X.GetAtomName(id, (err, name) => resolve(err ? null : name)),
68
+ );
69
+ cache.set(id, pending);
70
+ return pending;
71
+ }
72
+
73
+ /**
74
+ * The stream of ClientMessages for one window: names each message's type and
75
+ * hands it to `dispatch` in arrival order.
76
+ *
77
+ * `dispatch` is the caller's, so the priority and the paint stay with the
78
+ * other window events in nodes.js; what lives here is the naming and the
79
+ * ordering it has to preserve.
80
+ */
81
+ export function createClientMessages(node, dispatch) {
82
+ let queued = 0;
83
+ let chain = Promise.resolve();
84
+
85
+ /**
86
+ * The event a handler receives. Not a `SyntheticEvent`: a ClientMessage is
87
+ * addressed to a *window*, so there is no node under it, nothing to hit
88
+ * test and no chain to bubble along. Same shape `onResize` has, for the
89
+ * same reason.
90
+ */
91
+ const build = (raw, messageType) => ({
92
+ type: CLIENT_MESSAGE,
93
+ messageType,
94
+ atom: raw.message_type,
95
+ format: raw.format,
96
+ data: raw.data,
97
+ window: node.window,
98
+ target: node.window,
99
+ nativeEvent: raw,
100
+ get defaultPrevented() {
101
+ return raw.defaultPrevented === true;
102
+ },
103
+ preventDefault() {
104
+ // Marked on the raw event, because what reads it is a second
105
+ // subscriber to the same ntk stream (`WindowNode._initDnd`) rather
106
+ // than a later step of this dispatch.
107
+ raw.defaultPrevented = true;
108
+ },
109
+ });
110
+
111
+ return {
112
+ /** Take one raw ntk `'message'` event. */
113
+ handle(raw) {
114
+ const name = atomName(node.app?.X, raw.message_type);
115
+ const settled = name === null || typeof name === 'string';
116
+ if (queued === 0 && settled) {
117
+ dispatch(build(raw, name));
118
+ return;
119
+ }
120
+ queued++;
121
+ chain = chain
122
+ .then(() => name)
123
+ .then((resolved) => dispatch(build(raw, resolved)))
124
+ .catch(() => {}) // a handler throw is already reported by callHandler
125
+ .then(() => {
126
+ queued--;
127
+ });
128
+ },
129
+
130
+ /**
131
+ * `null` when every message so far has been dispatched, so a default
132
+ * action can run in the same turn its message arrived in; otherwise the
133
+ * promise after which it has been — which is what lets `preventDefault()`
134
+ * still reach XDND on the one message whose type had to be named first.
135
+ */
136
+ pending() {
137
+ return queued === 0 ? null : chain;
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,121 @@
1
+ // The clipboard an application sees.
2
+ //
3
+ // ntk's `app.clipboard` is the ICCCM selection machinery: it owns
4
+ // selections, answers conversions, and speaks INCR. This is the layer above
5
+ // it, and it adds exactly three things, none of which belong upstream:
6
+ //
7
+ // 1. the type vocabulary (transfer.js) — so `read('text')` finds GTK's
8
+ // UTF8_STRING and `readFiles()` parses a file manager's uri-list,
9
+ // instead of every app rediscovering the same table;
10
+ // 2. an ICCCM timestamp by default (inputtime.js), so a copy is stamped
11
+ // with the keystroke that caused it rather than with "now";
12
+ // 3. a way in from React (useClipboard), since a component has no route
13
+ // to the connection.
14
+ //
15
+ // Everything on the wire is still ntk's. `root.app.clipboard` remains the
16
+ // documented escape hatch for anything this does not cover.
17
+
18
+ import { lastInputTime } from './inputtime.js';
19
+ import { decodeData, parseUriList, resolveType } from './transfer.js';
20
+
21
+ /** Ask the owner what it has, so a group name can be resolved against a
22
+ * real offer rather than guessed at one failed conversion per guess. */
23
+ async function offeredTypes(clipboard, options) {
24
+ try {
25
+ return await clipboard.targets(options);
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ /**
32
+ * The object `useClipboard()` hands back. One per app — it holds no state
33
+ * of its own, so making a second is harmless.
34
+ */
35
+ export function createClipboard(app) {
36
+ const raw = () => {
37
+ const c = app?.clipboard;
38
+ if (!c) throw new Error('react-x11: this app has no clipboard');
39
+ return c;
40
+ };
41
+ // every call takes the same three, and every one of them has a default
42
+ // worth not repeating
43
+ const opts = ({ selection = 'CLIPBOARD', timeout, time } = {}) => ({
44
+ selection,
45
+ ...(timeout === undefined ? {} : { timeout }),
46
+ time: time === undefined ? lastInputTime(app) : time,
47
+ });
48
+
49
+ return {
50
+ /** Take a selection and serve `data` — a string, or a map of type name
51
+ * to string/bytes for offering several flavours of one thing. */
52
+ write(data, options) {
53
+ return raw().write(data, opts(options));
54
+ },
55
+
56
+ /** Shorthand for the common case; offered as UTF8_STRING and STRING. */
57
+ writeText(text, options) {
58
+ return raw().write(String(text), opts(options));
59
+ },
60
+
61
+ /** Give the selection back, so nothing is served for it any more. */
62
+ clear(selection = 'CLIPBOARD') {
63
+ return raw().clear(selection);
64
+ },
65
+
66
+ /** What the current owner can convert to. `[]` when nothing owns it. */
67
+ targets(options) {
68
+ return raw().targets(opts(options));
69
+ },
70
+
71
+ /** The plain text of a selection, ntk's UTF8_STRING → STRING walk.
72
+ * `read('text')` is the interop-hardened version of this. */
73
+ readText(options) {
74
+ return raw().read(opts(options));
75
+ },
76
+
77
+ /**
78
+ * One type, decoded: a concrete name (`'image/png'`) or a group
79
+ * (`'text'`, `'files'`, `'uris'`) resolved against what the owner
80
+ * actually offers. Text-ish types come back as a string, everything
81
+ * else as bytes. `null` when the owner has nothing of that kind —
82
+ * which is a question, not an error, unlike `readText()` on an empty
83
+ * clipboard.
84
+ */
85
+ async read(type, options) {
86
+ const o = opts(options);
87
+ const offered = await offeredTypes(raw(), o);
88
+ const target = resolveType(type, offered);
89
+ if (!offered.includes(target)) return null;
90
+ const data = await raw().read({ ...o, target });
91
+ return typeof data === 'string' ? data : decodeData(data, target);
92
+ },
93
+
94
+ /**
95
+ * Files copied in a file manager, parsed (RFC 2483). `[]` when the
96
+ * clipboard holds no file flavour, so a paste handler can call it
97
+ * without asking first.
98
+ */
99
+ async readFiles(options) {
100
+ const list = await this.read('files', options);
101
+ return typeof list === 'string' ? parseUriList(list) : [];
102
+ },
103
+
104
+ /**
105
+ * Call `handler` whenever the selection changes hands — an XFixes
106
+ * subscription, not a poll. `ev.owner === 0` means nothing is on the
107
+ * clipboard, which is the case an edit menu wants.
108
+ *
109
+ * Rejects on a server without XFixes; every X server since about 2004
110
+ * has it, so treat that as "this server is unusual" rather than as a
111
+ * path to code around.
112
+ */
113
+ watch(selectionOrHandler, maybeHandler) {
114
+ const [selection, handler] =
115
+ typeof selectionOrHandler === 'function'
116
+ ? ['CLIPBOARD', selectionOrHandler]
117
+ : [selectionOrHandler, maybeHandler];
118
+ return raw().watch(selection, handler);
119
+ },
120
+ };
121
+ }
@@ -7,57 +7,172 @@ import { labelContent, useControl, useTheme } from './theme.js';
7
7
 
8
8
  const h = React.createElement;
9
9
 
10
+ const VARIANTS = ['solid', 'outline', 'ghost'];
11
+ const SIZES = ['medium', 'small'];
12
+
10
13
  /**
11
- * <Button onPress primary disabled …boxProps>label</Button> — the standard
12
- * push button the examples kept re-implementing: hover/focus feedback,
13
- * Space/Enter activation, pointer cursor.
14
+ * <Button onPress variant size primary disabled …boxProps>label</Button> —
15
+ * the standard push button the examples kept re-implementing: hover/press/
16
+ * focus feedback, Space/Enter activation, pointer cursor.
17
+ *
18
+ * Two axes rather than one list of looks. `variant` is how much chrome the
19
+ * button carries — `solid` a fill, `outline` a border on nothing, `ghost`
20
+ * neither, for the affordance that sits *inside* other content (a `✕` on a
21
+ * chip, a jump arrow beside a value) and must not add a box to the row it
22
+ * lives in. `primary` is whose colours it speaks in: the accent as the fill
23
+ * when there is one, the accent as ink and border when there is not. The
24
+ * axes compose, so a dialog footer's secondary action is `variant="outline"`
25
+ * next to a `primary` solid, and a toolbar's loudest icon is
26
+ * `primary variant="ghost"` — one component, one set of states.
27
+ *
28
+ * `size="small"` is the compact metric a toolbar or an inline row wants:
29
+ * half the control padding, everything else in proportion. In the component
30
+ * rather than in a `style` because the padding is derived from the palette —
31
+ * a theme that moves `paddingY` moves both sizes together, where a hand-made
32
+ * `{ height: 22 }` would be left behind.
33
+ *
34
+ * `onPress` fires on the **release**, as a click does everywhere — so the
35
+ * button darkens on the press instead, and keeps the darker fill for as long
36
+ * as the button is held. Without that the whole of a slow click is a control
37
+ * that has not answered: press, nothing, nothing, and then the action. The
38
+ * press state is what makes an unhurried click feel immediate, and it costs
39
+ * one node's repaint because it is a style block rather than React state.
14
40
  */
15
41
  export function Button({
16
42
  children,
17
43
  label,
18
44
  onPress,
19
45
  primary = false,
46
+ variant = 'solid',
47
+ size = 'medium',
20
48
  disabled = false,
49
+ style,
21
50
  ...boxProps
22
51
  }) {
52
+ // Before the hook on purpose, as `<Icon name>` does it: an unknown value
53
+ // never renders, so the message lands on the call site — where TypeScript
54
+ // is not there to catch the typo, silence would be an outline button.
55
+ if (!VARIANTS.includes(variant)) {
56
+ throw new Error(
57
+ `<Button variant="${variant}">: one of ${VARIANTS.join(', ')}`,
58
+ );
59
+ }
60
+ if (!SIZES.includes(size)) {
61
+ throw new Error(`<Button size="${size}">: one of ${SIZES.join(', ')}`);
62
+ }
23
63
  const theme = useTheme();
24
- const { hover, focused, props } = useControl(disabled, onPress);
25
- const background = disabled
26
- ? theme.surfaceHover
64
+ const { props, style: controlStyle } = useControl(disabled, onPress, {
65
+ styled: true,
66
+ });
67
+ const solid = variant === 'solid';
68
+ const ghost = variant === 'ghost';
69
+ const small = size === 'small';
70
+ const background = !solid
71
+ ? // `transparent` rather than the ground's colour: an outline or ghost
72
+ // button sits on whatever it sits on — a toolbar, a card, a table row —
73
+ // and naming a fill would give it a box on any ground but one
74
+ 'transparent'
75
+ : disabled
76
+ ? theme.surfaceHover
77
+ : primary
78
+ ? theme.accent
79
+ : theme.surface;
80
+ const color = disabled
81
+ ? theme.textMuted
27
82
  : primary
28
- ? hover
29
- ? theme.accentHover
83
+ ? solid
84
+ ? theme.accentText
30
85
  : theme.accent
31
- : hover
32
- ? theme.surfaceHover
33
- : theme.background;
34
- const color = disabled ? theme.dim : primary ? theme.accentText : theme.text;
86
+ : theme.text;
87
+ // A ghost button keeps the border *width* and loses only the colour, so
88
+ // every variant is the same sum and a mixed row lines up — a field padded
89
+ // with `$paddingY` is exactly this tall too (docs/styling.md).
90
+ const borderColor = ghost
91
+ ? 'transparent'
92
+ : disabled || !primary
93
+ ? theme.border
94
+ : theme.accent;
35
95
  return h(
36
96
  'box',
37
97
  {
38
- flexDirection: 'row',
39
- alignItems: 'center',
40
- justifyContent: 'center',
41
- gap: 8,
42
- paddingTop: 8,
43
- paddingBottom: 8,
44
- paddingLeft: 16,
45
- paddingRight: 16,
46
- borderRadius: 4,
47
- borderWidth: 1,
48
- borderColor: disabled
49
- ? theme.border
50
- : focused
51
- ? primary
52
- ? theme.accentHover
53
- : theme.borderActive
54
- : primary
55
- ? theme.accent
56
- : theme.border,
57
- backgroundColor: background,
98
+ theme,
99
+ role: 'button',
58
100
  ...props,
59
101
  ...boxProps,
102
+ style: [
103
+ controlStyle,
104
+ {
105
+ flexDirection: 'row',
106
+ alignItems: 'center',
107
+ justifyContent: 'center',
108
+ gap: small ? 6 : 8,
109
+ paddingTop: small ? Math.round(theme.paddingY / 2) : theme.paddingY,
110
+ paddingBottom: small
111
+ ? Math.round(theme.paddingY / 2)
112
+ : theme.paddingY,
113
+ paddingLeft: small ? Math.round(theme.paddingX / 2) : theme.paddingX,
114
+ paddingRight: small ? Math.round(theme.paddingX / 2) : theme.paddingX,
115
+ borderRadius: small ? theme.radiusSmall : theme.radius,
116
+ borderWidth: theme.borderWidth,
117
+ borderColor,
118
+ backgroundColor: background,
119
+ // The label ink goes on the box, not on the label: `color` is
120
+ // inherited (docs/styling.md), so an element child — an <Icon>,
121
+ // a <text> — takes the same answer a string child always got,
122
+ // and an icon+label button dims as one thing when disabled.
123
+ color,
124
+ },
125
+ // All three as state blocks: a repaint of one node each, where React
126
+ // state re-rendered the button and its label to change a colour.
127
+ // `:focus-visible` rather than `:focus` is the difference between
128
+ // "you clicked here" and "your keyboard is here", and only the
129
+ // second is worth a ring.
130
+ !disabled &&
131
+ (solid
132
+ ? {
133
+ ':hover': {
134
+ backgroundColor: primary
135
+ ? theme.accentHover
136
+ : theme.surfaceHover,
137
+ borderColor: primary ? theme.accentHover : theme.border,
138
+ },
139
+ // the border follows the fill: a dark pressed face inside a
140
+ // resting-coloured ring reads as a rendering bug, not a press
141
+ ':active': {
142
+ backgroundColor: primary
143
+ ? theme.accentActive
144
+ : theme.surfaceActive,
145
+ borderColor: primary ? theme.accentActive : theme.textMuted,
146
+ },
147
+ ':focus-visible': {
148
+ borderColor: primary ? theme.accentHover : theme.borderFocus,
149
+ },
150
+ }
151
+ : {
152
+ // Outline and ghost answer the pointer with the neutral wash
153
+ // whichever colours they speak in: the surface steps are the
154
+ // hover-and-press ramp for anything without an accent fill,
155
+ // and the background paints under the border band, so a ghost
156
+ // button's wash covers the full box.
157
+ ':hover': { backgroundColor: theme.surfaceHover },
158
+ ':active': {
159
+ backgroundColor: theme.surfaceActive,
160
+ ...(ghost
161
+ ? null
162
+ : {
163
+ borderColor: primary
164
+ ? theme.accentActive
165
+ : theme.textMuted,
166
+ }),
167
+ },
168
+ // …which on a ghost button makes the border appear, and that
169
+ // is right: the keyboard has no hover, so the ring is how a
170
+ // chrome-less control says "your keyboard is here".
171
+ ':focus-visible': { borderColor: theme.borderFocus },
172
+ }),
173
+ style,
174
+ ],
60
175
  },
61
- labelContent(children ?? label, { color }),
176
+ labelContent(children ?? label),
62
177
  );
63
178
  }
@@ -3,67 +3,101 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React from 'react';
6
+ import { changeEvent } from './change.js';
7
+ import { Icon } from './Icon.js';
6
8
  import { labelContent, useControl, useTheme } from './theme.js';
7
9
 
8
10
  const h = React.createElement;
9
11
 
12
+ /** The mark inside the 16px well. */
13
+ const MARK = 11;
14
+
10
15
  /**
11
16
  * <Checkbox checked onChange disabled>label</Checkbox> — 16px check well +
12
- * label row; click or Space toggles (onChange receives the next value).
17
+ * label row; click or Space toggles. `onChange(ev)` gets a change event, the
18
+ * same shape `<textinput>` fires: the next value is `ev.value`, and
19
+ * `ev.target` carries `name`/`checked` for a form library.
20
+ *
21
+ * The tick only appears on the release, so the well takes a pressed fill on
22
+ * the press to cover the gap. This is the case `useControl` keeps in React
23
+ * state rather than a style block: the press lands anywhere along the row —
24
+ * usually the label — and `:active` marks that node and its ancestors, which
25
+ * the well is not. One render per press, for a control whose next act is to
26
+ * re-render with a new `checked` anyway.
13
27
  */
14
28
  export function Checkbox({
15
29
  children,
16
30
  label,
17
31
  checked = false,
18
32
  onChange,
33
+ name,
19
34
  disabled = false,
35
+ style,
20
36
  ...boxProps
21
37
  }) {
22
38
  const theme = useTheme();
23
- const { focused, props } = useControl(disabled, () => onChange?.(!checked));
24
- const fill = disabled ? theme.dim : theme.accent;
39
+ const {
40
+ hover,
41
+ focused,
42
+ pressed,
43
+ props,
44
+ style: controlStyle,
45
+ } = useControl(disabled, () =>
46
+ onChange?.(changeEvent('checkbox', name, !checked)),
47
+ );
48
+ const fill = disabled
49
+ ? theme.textMuted
50
+ : pressed
51
+ ? theme.accentActive
52
+ : hover
53
+ ? theme.accentHover
54
+ : theme.accent;
55
+ // An empty well has no fill to step, so it shows the two states in two
56
+ // different places: hovering firms the ring up, and pressing greys the
57
+ // inside as well. Three looks, which is the point — a hover the press
58
+ // cannot be told apart from says nothing about the press.
59
+ const empty = {
60
+ borderColor:
61
+ pressed || hover
62
+ ? theme.textMuted
63
+ : focused
64
+ ? theme.borderFocus
65
+ : theme.border,
66
+ backgroundColor: pressed ? theme.surfaceActive : theme.surface,
67
+ };
25
68
  return h(
26
69
  'box',
27
70
  {
28
- flexDirection: 'row',
29
- alignItems: 'center',
30
- gap: 8,
71
+ theme,
72
+ role: 'checkbox',
73
+ 'aria-checked': checked,
31
74
  ...props,
32
75
  ...boxProps,
76
+ style: [
77
+ controlStyle,
78
+ { flexDirection: 'row', alignItems: 'center', gap: 8 },
79
+ style,
80
+ ],
33
81
  },
34
82
  h(
35
83
  'box',
36
84
  {
37
- width: 16,
38
- height: 16,
39
- borderRadius: 3,
40
- borderWidth: 1,
41
- borderColor: checked
42
- ? fill
43
- : focused
44
- ? theme.borderActive
45
- : theme.border,
46
- backgroundColor: checked ? fill : theme.background,
47
- alignItems: 'center',
48
- justifyContent: 'center',
85
+ style: {
86
+ width: 16,
87
+ height: 16,
88
+ borderRadius: theme.radiusSmall,
89
+ borderWidth: theme.borderWidth,
90
+ borderColor: checked ? fill : empty.borderColor,
91
+ backgroundColor: checked ? fill : empty.backgroundColor,
92
+ alignItems: 'center',
93
+ justifyContent: 'center',
94
+ },
49
95
  },
50
96
  checked &&
51
- h('canvas', {
52
- width: 10,
53
- height: 8,
54
- onDraw: (ctx) => {
55
- ctx.strokeStyle = theme.accentText;
56
- ctx.lineWidth = 2;
57
- ctx.beginPath();
58
- ctx.moveTo(1, 4);
59
- ctx.lineTo(3.5, 6.5);
60
- ctx.lineTo(9, 1);
61
- ctx.stroke();
62
- },
63
- }),
97
+ h(Icon, { name: 'check', size: MARK, color: theme.accentText }),
64
98
  ),
65
99
  labelContent(children ?? label, {
66
- color: disabled ? theme.dim : theme.text,
100
+ color: disabled ? theme.textMuted : theme.text,
67
101
  }),
68
102
  );
69
103
  }