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
@@ -4,6 +4,7 @@
4
4
 
5
5
  import React, { useRef, useState } from 'react';
6
6
  import { useTheme } from './theme.js';
7
+ import { changeEvent } from './change.js';
7
8
  import {
8
9
  XK_DOWN,
9
10
  XK_END,
@@ -18,6 +19,7 @@ import {
18
19
  const h = React.createElement;
19
20
 
20
21
  const SLIDER_THUMB = 16;
22
+ const SLIDER_SLOP = (24 - SLIDER_THUMB) / 2;
21
23
 
22
24
  /**
23
25
  * <Slider value min max step onChange disabled …boxProps> — draggable
@@ -37,9 +39,10 @@ export function Slider({
37
39
  max = 100,
38
40
  step = 1,
39
41
  onChange,
42
+ name,
40
43
  disabled = false,
41
- width,
42
44
  height = 4,
45
+ style,
43
46
  ...boxProps
44
47
  }) {
45
48
  const theme = useTheme();
@@ -60,25 +63,45 @@ export function Slider({
60
63
  const fraction = (clamp(value) - min) / span;
61
64
 
62
65
  const emit = (next) => {
63
- if (next !== value) onChange?.(next);
66
+ if (next !== value) onChange?.(changeEvent('range', name, next));
64
67
  };
65
68
 
66
- /** Pointer x -> value, using the track's laid-out rect. */
69
+ /**
70
+ * Pointer x -> value, using the track's laid-out rect.
71
+ *
72
+ * The direction is read off the **node** rather than the theme: it is the
73
+ * one the track was actually laid out in, which is what the pointer
74
+ * coordinate has to be measured against — a slider inside a `<box
75
+ * style={{ direction: 'rtl' }}>` mirrors without a provider anywhere.
76
+ */
67
77
  const valueAt = (ev) => {
68
78
  const node = trackRef.current;
69
- if (!node?.abs?.width) return value;
79
+ // getClientRects rather than raw `abs`: the event, the thumb constant
80
+ // and this rect must share a unit, and the public rect is logical like
81
+ // they are — `abs` is device (src/scale.js)
82
+ const rect = node?.getClientRects?.()[0];
83
+ if (!rect?.width) return value;
70
84
  // the thumb is centred on the value, so the usable travel is the track
71
85
  // minus one thumb width — otherwise min/max are unreachable at the ends
72
- const travel = Math.max(1, node.abs.width - SLIDER_THUMB);
73
- const x = ev.x - node.abs.x - SLIDER_THUMB / 2;
86
+ const travel = Math.max(1, rect.width - SLIDER_THUMB);
87
+ const x =
88
+ node.direction === 'rtl'
89
+ ? rect.x + rect.width - ev.x - SLIDER_THUMB / 2
90
+ : ev.x - rect.x - SLIDER_THUMB / 2;
74
91
  return quantize(min + (Math.min(travel, Math.max(0, x)) / travel) * span);
75
92
  };
76
93
 
77
94
  const controlProps = disabled
78
- ? {}
95
+ ? { disabled: true }
79
96
  : {
80
97
  focusable: true,
81
- cursor: 'pointer',
98
+ // the AT-SPI Value interface writes land here (Orca adjusting the
99
+ // slider): same clamp/quantize/emit as every other input route
100
+ onAccessibilityAction: ({ action, value: next }) => {
101
+ if (action === 'setValue' && typeof next === 'number') {
102
+ emit(quantize(clamp(next)));
103
+ }
104
+ },
82
105
  onFocus: () => setFocused(true),
83
106
  onBlur: () => setFocused(false),
84
107
  onMouseDown: (ev) => {
@@ -92,12 +115,22 @@ export function Slider({
92
115
  onMouseUp: () => setDragging(false),
93
116
  onKeyDown: (ev) => {
94
117
  const big = (step || 1) * 10;
118
+ // Left and Right name what the *thumb* should do on the screen, so
119
+ // in a mirrored slider Left is the one that raises the value —
120
+ // WAI-ARIA's rule for a horizontal slider, and the only one that
121
+ // matches what the hand sees. Up and Down never mirror, which is
122
+ // why they are not folded in with them.
123
+ const along = trackRef.current?.direction === 'rtl' ? -1 : 1;
95
124
  switch (ev.keysym) {
96
125
  case XK_LEFT:
126
+ emit(quantize(clamp(value - along * (step || 1))));
127
+ return;
128
+ case XK_RIGHT:
129
+ emit(quantize(clamp(value + along * (step || 1))));
130
+ return;
97
131
  case XK_DOWN:
98
132
  emit(quantize(clamp(value - (step || 1))));
99
133
  return;
100
- case XK_RIGHT:
101
134
  case XK_UP:
102
135
  emit(quantize(clamp(value + (step || 1))));
103
136
  return;
@@ -122,24 +155,43 @@ export function Slider({
122
155
  return h(
123
156
  'box',
124
157
  {
158
+ theme,
159
+ role: 'slider',
160
+ 'aria-valuenow': clamp(value),
161
+ 'aria-valuemin': min,
162
+ 'aria-valuemax': max,
163
+ 'aria-orientation': 'horizontal',
125
164
  ref: trackRef,
126
- width,
127
- height: SLIDER_THUMB,
128
- minWidth: 0,
129
- justifyContent: 'center',
130
165
  ...controlProps,
131
166
  ...boxProps,
167
+ style: [
168
+ disabled || { cursor: 'pointer' },
169
+ {
170
+ height: SLIDER_THUMB,
171
+ minWidth: 0,
172
+ justifyContent: 'center',
173
+ // the control is as tall as its thumb and the track inside it sets
174
+ // `pointerEvents: 'none'`, so 16px was the whole target. Grown to
175
+ // WCAG 2.2 SC 2.5.8's 24 on the axis that is short, without moving
176
+ // a pixel of the drawing — a taller slider would misalign every row
177
+ // it sits in.
178
+ hitSlop: { top: SLIDER_SLOP, bottom: SLIDER_SLOP },
179
+ },
180
+ style,
181
+ ],
132
182
  },
133
183
  // track
134
184
  h(
135
185
  'box',
136
186
  {
137
- height,
138
- borderRadius: height / 2,
139
- backgroundColor: theme.track,
140
- flexDirection: 'row',
141
- alignItems: 'center',
142
- pointerEvents: 'none',
187
+ style: {
188
+ height: height,
189
+ borderRadius: height / 2,
190
+ backgroundColor: theme.track,
191
+ flexDirection: 'row',
192
+ alignItems: 'center',
193
+ pointerEvents: 'none',
194
+ },
143
195
  },
144
196
  // flex ratios, not a percentage width: a percentage child resolves
145
197
  // against the space available while the track is still being
@@ -147,31 +199,40 @@ export function Slider({
147
199
  // at value = max the control grew, which moved the handle, which
148
200
  // changed the value, and a drag turned into an oscillation
149
201
  h('box', {
150
- flexGrow: fraction,
151
- flexShrink: 0,
152
- flexBasis: 0,
153
- height,
154
- borderRadius: height / 2,
155
- backgroundColor: disabled ? theme.dim : theme.accent,
202
+ style: {
203
+ flexGrow: fraction,
204
+ flexShrink: 0,
205
+ flexBasis: 0,
206
+ height: height,
207
+ borderRadius: height / 2,
208
+ backgroundColor: disabled ? theme.textMuted : theme.accent,
209
+ },
210
+ }),
211
+ h('box', {
212
+ style: { flexGrow: 1 - fraction, flexShrink: 0, flexBasis: 0 },
156
213
  }),
157
- h('box', { flexGrow: 1 - fraction, flexShrink: 0, flexBasis: 0 }),
158
214
  ),
159
- // thumb, centred on the value within the same travel the math uses
215
+ // thumb, centred on the value within the same travel the math uses.
216
+ // Logical inset and logical margin: the track's two flex ratios already
217
+ // mirror on their own — a `row` runs the other way under `direction:
218
+ // 'rtl'` — and these are what keep the thumb on top of the join.
160
219
  h('box', {
161
- position: 'absolute',
162
- left: `${fraction * 100}%`,
163
- marginLeft: -SLIDER_THUMB * fraction,
164
- width: SLIDER_THUMB,
165
- height: SLIDER_THUMB,
166
- borderRadius: SLIDER_THUMB / 2,
167
- borderWidth: 1,
168
- borderColor: disabled
169
- ? theme.border
170
- : focused || dragging
171
- ? theme.accentHover
172
- : theme.border,
173
- backgroundColor: disabled ? theme.surfaceHover : theme.background,
174
- pointerEvents: 'none',
220
+ style: {
221
+ position: 'absolute',
222
+ start: `${fraction * 100}%`,
223
+ marginStart: -SLIDER_THUMB * fraction,
224
+ width: SLIDER_THUMB,
225
+ height: SLIDER_THUMB,
226
+ borderRadius: SLIDER_THUMB / 2,
227
+ borderWidth: 1,
228
+ borderColor: disabled
229
+ ? theme.border
230
+ : focused || dragging
231
+ ? theme.accentHover
232
+ : theme.border,
233
+ backgroundColor: disabled ? theme.surfaceHover : theme.surface,
234
+ pointerEvents: 'none',
235
+ },
175
236
  }),
176
237
  );
177
238
  }
@@ -0,0 +1,206 @@
1
+ // Widget components built purely on the host primitives — no reconciler
2
+ // support needed. Plain createElement (no JSX) so the library stays
3
+ // build-step-free for consumers.
4
+
5
+ import React, { useRef, useState } from 'react';
6
+ import { createStyles } from '../styles.js';
7
+ import { useDirection, useTheme } from './theme.js';
8
+ import { XK_DOWN, XK_END, XK_HOME, XK_LEFT, XK_RIGHT, XK_UP } from './keys.js';
9
+
10
+ const h = React.createElement;
11
+
12
+ const DIVIDER = 6;
13
+ const KEY_STEP = 16;
14
+
15
+ const s = createStyles({
16
+ root: { flexGrow: 1, minHeight: 0, minWidth: 0 },
17
+ pane: { minHeight: 0, minWidth: 0 },
18
+ // `flex: 1` is load-bearing, not tidying: the zero basis in it is what
19
+ // makes this pane "whatever is left" rather than "as much as my content
20
+ // wants, shrunk as far as it will go". With a content basis the pane still
21
+ // could not go below the content's own floor, so a second pane holding a
22
+ // wrapping row would lay out at its unwrapped width and overflow. The
23
+ // `min-*: 0` on top says this one may go below even that.
24
+ grow: { flex: 1, minHeight: 0, minWidth: 0 },
25
+ divider: {
26
+ flexShrink: 0,
27
+ alignItems: 'center',
28
+ justifyContent: 'center',
29
+ transition: { backgroundColor: 100 },
30
+ },
31
+ // the grab handle is a hairline; the *target* is the whole divider
32
+ grip: { borderRadius: 1 },
33
+ });
34
+
35
+ /**
36
+ * <SplitPane> — two panes with a divider you can drag.
37
+ *
38
+ * <SplitPane direction="row" defaultSize={220}>
39
+ * <Sidebar />
40
+ * <Editor />
41
+ * </SplitPane>
42
+ *
43
+ * `size` is the first pane's width (or height, when `direction="column"`)
44
+ * in pixels: controlled with `size` + `onResize`, uncontrolled with
45
+ * `defaultSize`. The second pane takes what is left, so only one number is
46
+ * ever stored and a window resize cannot leave a gap.
47
+ *
48
+ * `min` and `minSecond` keep either pane from collapsing; the drag clamps
49
+ * against the container's laid-out size, so it stays honest when the window
50
+ * changes underneath it.
51
+ *
52
+ * The divider is focusable: arrows move it by 16px, Home/End drive it to
53
+ * either limit. Dragging captures the pointer, so it keeps tracking after
54
+ * the pointer leaves the six pixels it started on.
55
+ */
56
+ export function SplitPane({
57
+ direction = 'row',
58
+ size,
59
+ defaultSize = 200,
60
+ min = 40,
61
+ minSecond = 40,
62
+ onResize,
63
+ children,
64
+ style,
65
+ ...boxProps
66
+ }) {
67
+ const theme = useTheme();
68
+ const rtl = useDirection() === 'rtl';
69
+ const [first, second] = React.Children.toArray(children);
70
+ const [uncontrolled, setUncontrolled] = useState(defaultSize);
71
+ const [dragging, setDragging] = useState(false);
72
+ const rootRef = useRef(null);
73
+ const grab = useRef(0);
74
+ const vertical = direction === 'column';
75
+ const current = size ?? uncontrolled;
76
+
77
+ // the live size and drag flag, mirrored in refs: a press and the moves
78
+ // that follow can all land before React re-renders, and a handler closing
79
+ // over the last rendered value would drop everything after the first
80
+ const sizeRef = useRef(current);
81
+ sizeRef.current = current;
82
+ const draggingRef = useRef(false);
83
+
84
+ /** Clamp against the container as laid out right now. */
85
+ const commit = (next) => {
86
+ // logical throughout: `next` becomes a style width/height, and the
87
+ // container rect has to be in the same unit (`abs` is device)
88
+ const box = rootRef.current?.getClientRects?.()[0];
89
+ const total = (vertical ? box?.height : box?.width) ?? 0;
90
+ const room = Math.max(min, total - DIVIDER - minSecond);
91
+ const clamped = Math.min(Math.max(min, next), total > 0 ? room : next);
92
+ if (clamped === sizeRef.current) return;
93
+ sizeRef.current = clamped;
94
+ if (size === undefined) setUncontrolled(clamped);
95
+ onResize?.(clamped);
96
+ };
97
+
98
+ /**
99
+ * How far along the split the pointer is, measured from the first pane's
100
+ * own edge — the top in a column, and in a row the side the panes start
101
+ * at, which is the right-hand one when the layout is mirrored. Everything
102
+ * else here is already a distance rather than a coordinate, so this is the
103
+ * only place the direction gets read.
104
+ */
105
+ const alongSplit = (ev, box) => {
106
+ if (vertical) return ev.y - box.y;
107
+ return rtl ? box.x + box.width - ev.x : ev.x - box.x;
108
+ };
109
+
110
+ const dividerProps = {
111
+ focusable: true,
112
+ onMouseDown: (ev) => {
113
+ const box = rootRef.current?.getClientRects?.()[0];
114
+ if (!box) return;
115
+ // where in the divider it was taken, so it does not jump on press
116
+ grab.current = alongSplit(ev, box) - sizeRef.current;
117
+ ev.capturePointer();
118
+ draggingRef.current = true;
119
+ setDragging(true);
120
+ },
121
+ onMouseMove: (ev) => {
122
+ if (!draggingRef.current) return;
123
+ const box = rootRef.current?.getClientRects?.()[0];
124
+ if (!box) return;
125
+ commit(alongSplit(ev, box) - grab.current);
126
+ },
127
+ onMouseUp: () => {
128
+ draggingRef.current = false;
129
+ setDragging(false);
130
+ },
131
+ onKeyDown: (ev) => {
132
+ // the key that *shrinks* the first pane is the one pointing back at
133
+ // its own edge, which swaps with the layout
134
+ const swap = !vertical && rtl;
135
+ const back = vertical ? XK_UP : swap ? XK_RIGHT : XK_LEFT;
136
+ const forward = vertical ? XK_DOWN : swap ? XK_LEFT : XK_RIGHT;
137
+ switch (ev.keysym) {
138
+ case back:
139
+ commit(sizeRef.current - KEY_STEP);
140
+ return;
141
+ case forward:
142
+ commit(sizeRef.current + KEY_STEP);
143
+ return;
144
+ case XK_HOME:
145
+ commit(min);
146
+ return;
147
+ case XK_END:
148
+ commit(Number.MAX_SAFE_INTEGER);
149
+ return;
150
+ default:
151
+ }
152
+ },
153
+ };
154
+
155
+ return h(
156
+ 'box',
157
+ {
158
+ theme,
159
+ ref: rootRef,
160
+ ...boxProps,
161
+ style: [s.root, { flexDirection: vertical ? 'column' : 'row' }, style],
162
+ },
163
+ h(
164
+ 'box',
165
+ {
166
+ style: [
167
+ s.pane,
168
+ vertical
169
+ ? { height: current, flexShrink: 0 }
170
+ : { width: current, flexShrink: 0 },
171
+ ],
172
+ },
173
+ first ?? null,
174
+ ),
175
+ h(
176
+ 'box',
177
+ {
178
+ // the ARIA window-splitter pattern: a separator whose value is the
179
+ // primary pane's size
180
+ role: 'separator',
181
+ 'aria-orientation': vertical ? 'vertical' : 'horizontal',
182
+ 'aria-valuenow': current,
183
+ 'aria-valuemin': min,
184
+ ...dividerProps,
185
+ style: [
186
+ s.divider,
187
+ vertical
188
+ ? { height: DIVIDER, cursor: 'row-resize' }
189
+ : { width: DIVIDER, cursor: 'col-resize' },
190
+ { backgroundColor: dragging ? theme.surfaceHover : 'transparent' },
191
+ { ':hover': { backgroundColor: theme.surfaceHover } },
192
+ ],
193
+ },
194
+ h('box', {
195
+ style: [
196
+ s.grip,
197
+ vertical
198
+ ? { height: 2, alignSelf: 'stretch' }
199
+ : { width: 2, alignSelf: 'stretch' },
200
+ { backgroundColor: theme.border },
201
+ ],
202
+ }),
203
+ ),
204
+ h('box', { style: s.grow }, second ?? null),
205
+ );
206
+ }
@@ -3,47 +3,125 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React from 'react';
6
+ import { createStyles } from '../styles.js';
7
+ import { changeEvent } from './change.js';
6
8
  import { useControl, useTheme } from './theme.js';
7
9
 
8
10
  const h = React.createElement;
9
11
 
12
+ const TRACK_WIDTH = 36;
13
+ const THUMB = 16;
14
+ const INSET = 2;
15
+
16
+ const TRACK_HEIGHT = 20;
17
+ // WCAG 2.2 SC 2.5.8 wants 24px in both directions and the pill is 20 tall.
18
+ // Growing the pill would be a redesign of a control whose proportions are
19
+ // the point, so the *target* grows instead: `hitSlop` is hit testing only,
20
+ // so nothing about the drawing or the layout moves.
21
+ const TRACK_SLOP = (24 - TRACK_HEIGHT) / 2;
22
+
23
+ const s = createStyles({
24
+ track: {
25
+ width: TRACK_WIDTH,
26
+ height: TRACK_HEIGHT,
27
+ borderRadius: 10,
28
+ justifyContent: 'center',
29
+ hitSlop: TRACK_SLOP,
30
+ transition: { backgroundColor: 120 },
31
+ },
32
+ // absolutely positioned so the thumb slides on `start`: `justifyContent`
33
+ // would flip it between the ends with nothing in between to animate. The
34
+ // *logical* inset, so an off switch has its thumb on the side its label
35
+ // starts at and the slide runs the way the text does.
36
+ thumb: {
37
+ position: 'absolute',
38
+ width: THUMB,
39
+ height: THUMB,
40
+ borderRadius: THUMB / 2,
41
+ transition: { start: 120 },
42
+ },
43
+ });
44
+
45
+ const THUMB_OFF = INSET;
46
+ const THUMB_ON = TRACK_WIDTH - THUMB - INSET;
47
+
10
48
  /**
11
- * <Switch checked onChange disabled/> — Checkbox semantics in a sliding
12
- * pill; the thumb sits at the end matching the state.
49
+ * <Switch checked onChange disabled style/> — Checkbox semantics in a
50
+ * sliding pill; the thumb slides to the end matching the state.
51
+ * `onChange(ev)`, as `Checkbox`.
52
+ *
53
+ * The hover, press and focus tints are `:hover`/`:active`/`:focus` blocks
54
+ * rather than React state: the renderer already knows which node the pointer
55
+ * is over, which one is held and which one has focus, so lighting the track
56
+ * up is a repaint of one node instead of a re-render of this component and
57
+ * everything under it.
58
+ *
59
+ * The thumb only slides once the switch has actually flipped, which is on
60
+ * the release — so the press is the whole of the feedback in between, and a
61
+ * click held for half a second is half a second of a control that has not
62
+ * answered without it. `:active` marks the press chain rather than the one
63
+ * node under the pointer, which is what lets the track answer a press that
64
+ * landed on the thumb.
65
+ *
66
+ * The slide is a `transition` on `start`, so the animation is the renderer's
67
+ * frame clock rather than a requestAnimationFrame loop written here — and
68
+ * the same declaration animates the track colour with it. It runs on the
69
+ * press tint too: 120ms of fade that *starts* on the press frame, which is
70
+ * the part perception is actually measuring.
13
71
  */
14
72
  export function Switch({
15
73
  checked = false,
16
74
  onChange,
75
+ name,
17
76
  disabled = false,
77
+ style,
18
78
  ...boxProps
19
79
  }) {
20
80
  const theme = useTheme();
21
- const { focused, props } = useControl(disabled, () => onChange?.(!checked));
81
+ const control = useControl(
82
+ disabled,
83
+ () => onChange?.(changeEvent('checkbox', name, !checked)),
84
+ { styled: true },
85
+ );
22
86
  return h(
23
87
  'box',
24
88
  {
25
- width: 36,
26
- height: 20,
27
- borderRadius: 10,
28
- padding: 2,
29
- flexDirection: 'row',
30
- alignItems: 'center',
31
- justifyContent: checked ? 'flex-end' : 'flex-start',
32
- backgroundColor: disabled
33
- ? theme.track
34
- : checked
35
- ? theme.accent
36
- : focused
37
- ? theme.dim
38
- : theme.border,
39
- ...props,
89
+ theme,
90
+ role: 'switch',
91
+ 'aria-checked': checked,
92
+ ...control.props,
40
93
  ...boxProps,
94
+ style: [
95
+ control.style,
96
+ s.track,
97
+ {
98
+ backgroundColor: disabled
99
+ ? theme.track
100
+ : checked
101
+ ? theme.accent
102
+ : theme.border,
103
+ },
104
+ !disabled && {
105
+ ':hover': {
106
+ backgroundColor: checked ? theme.accentHover : theme.textMuted,
107
+ },
108
+ ':active': {
109
+ backgroundColor: checked
110
+ ? theme.accentActive
111
+ : theme.textMutedActive,
112
+ },
113
+ },
114
+ style,
115
+ ],
41
116
  },
42
117
  h('box', {
43
- width: 16,
44
- height: 16,
45
- borderRadius: 8,
46
- backgroundColor: theme.background,
118
+ style: [
119
+ s.thumb,
120
+ {
121
+ start: checked ? THUMB_ON : THUMB_OFF,
122
+ backgroundColor: theme.surface,
123
+ },
124
+ ],
47
125
  }),
48
126
  );
49
127
  }