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.
- package/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
package/src/styles.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Style props → yoga setters (layout) and paint metadata. Flat, ink-style
|
|
2
2
|
// props: <box flexDirection="row" padding={8} backgroundColor="#eee">.
|
|
3
3
|
// Numbers are pixels; strings like '50%' / 'auto' pass through to yoga.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import {
|
|
4
|
+
// The engine is ours (`./yoga.js`) — the enum tables below are built at
|
|
5
|
+
// module scope, which is what that module's synchronous half is for.
|
|
6
|
+
import { cssColorStraight } from 'ntk';
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import { parseBoxShadow, parseLinearGradient } from './decorations.js';
|
|
9
|
+
import { Yoga } from './yoga.js';
|
|
9
10
|
|
|
10
11
|
const FLEX_DIRECTION = {
|
|
11
12
|
row: Yoga.FLEX_DIRECTION_ROW,
|
|
@@ -51,12 +52,46 @@ const DISPLAY = {
|
|
|
51
52
|
none: Yoga.DISPLAY_NONE,
|
|
52
53
|
};
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* CSS's `flex` shorthand, as the three properties it sets. A number is
|
|
57
|
+
* `flex: <grow> 1 0` — "take this share of what is left, from a base size of
|
|
58
|
+
* nothing" — which is what `flex: 1` means everywhere it is written; the two
|
|
59
|
+
* keywords are CSS's own, `'auto'` for "grow and shrink from my content" and
|
|
60
|
+
* `'none'` for "do neither".
|
|
61
|
+
*
|
|
62
|
+
* It is here rather than in `LAYOUT_APPLIERS` because a shorthand is not a
|
|
63
|
+
* yoga property: it expands into three of them before the diff runs, so
|
|
64
|
+
* `{ flex: 1, flexBasis: 'auto' }` resolves the way CSS does — the longhand
|
|
65
|
+
* after the shorthand wins — and the applier for each still sees a plain
|
|
66
|
+
* value changing.
|
|
67
|
+
*/
|
|
68
|
+
const FLEX_SHORTHAND = {
|
|
69
|
+
none: { flexGrow: 0, flexShrink: 0, flexBasis: 'auto' },
|
|
70
|
+
auto: { flexGrow: 1, flexShrink: 1, flexBasis: 'auto' },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const isFlexShorthand = (v) =>
|
|
74
|
+
(typeof v === 'number' && Number.isFinite(v) && v >= 0) ||
|
|
75
|
+
(typeof v === 'string' && v in FLEX_SHORTHAND);
|
|
76
|
+
|
|
54
77
|
const OVERFLOW = {
|
|
55
78
|
visible: Yoga.OVERFLOW_VISIBLE,
|
|
56
79
|
hidden: Yoga.OVERFLOW_HIDDEN,
|
|
57
80
|
scroll: Yoga.OVERFLOW_SCROLL,
|
|
58
81
|
};
|
|
59
82
|
|
|
83
|
+
/**
|
|
84
|
+
* CSS's `direction`. `'inherit'` is yoga's own default and the value every
|
|
85
|
+
* node keeps having, so writing it is the same as leaving it out — it is
|
|
86
|
+
* spelled anyway because "take it from the box around me" is a thing a style
|
|
87
|
+
* has to be able to say back after saying `'rtl'`.
|
|
88
|
+
*/
|
|
89
|
+
const DIRECTION = {
|
|
90
|
+
ltr: Yoga.DIRECTION_LTR,
|
|
91
|
+
rtl: Yoga.DIRECTION_RTL,
|
|
92
|
+
inherit: Yoga.DIRECTION_INHERIT,
|
|
93
|
+
};
|
|
94
|
+
|
|
60
95
|
const pick = (map, value, name) => {
|
|
61
96
|
if (value === undefined) return undefined;
|
|
62
97
|
if (!(value in map)) {
|
|
@@ -98,6 +133,12 @@ const LAYOUT_APPLIERS = {
|
|
|
98
133
|
n.setPositionType(
|
|
99
134
|
pick(POSITION, v, 'position') ?? Yoga.POSITION_TYPE_RELATIVE,
|
|
100
135
|
),
|
|
136
|
+
// Which way the boxes under this one run. Everything else in this file is
|
|
137
|
+
// physical; this is the one property that decides what "start" means, and
|
|
138
|
+
// yoga inherits it down its own tree — so a `<box>` that sets it mirrors
|
|
139
|
+
// that subtree and nothing above it.
|
|
140
|
+
direction: (n, v) =>
|
|
141
|
+
n.setDirection(pick(DIRECTION, v, 'direction') ?? Yoga.DIRECTION_INHERIT),
|
|
101
142
|
top: (n, v) => n.setPosition(Yoga.EDGE_TOP, v),
|
|
102
143
|
right: (n, v) => n.setPosition(Yoga.EDGE_RIGHT, v),
|
|
103
144
|
bottom: (n, v) => n.setPosition(Yoga.EDGE_BOTTOM, v),
|
|
@@ -112,6 +153,23 @@ const LAYOUT_APPLIERS = {
|
|
|
112
153
|
paddingRight: (n, v) => n.setPadding(Yoga.EDGE_RIGHT, v),
|
|
113
154
|
paddingBottom: (n, v) => n.setPadding(Yoga.EDGE_BOTTOM, v),
|
|
114
155
|
paddingLeft: (n, v) => n.setPadding(Yoga.EDGE_LEFT, v),
|
|
156
|
+
// The **logical** edges — the side the text starts on and the side it ends
|
|
157
|
+
// on, whichever those turn out to be. A stylesheet written in these is the
|
|
158
|
+
// same stylesheet in both directions, which is the whole reason `direction`
|
|
159
|
+
// is worth having: a physical `paddingLeft` under `direction: 'rtl'` is a
|
|
160
|
+
// gutter on the wrong side of the text it was meant to indent.
|
|
161
|
+
//
|
|
162
|
+
// Yoga's edge precedence is start/end over the physical side over
|
|
163
|
+
// `EDGE_HORIZONTAL` over `EDGE_ALL` — so `paddingStart` beats `paddingLeft`
|
|
164
|
+
// even in LTR where the two name the same edge, the way CSS's
|
|
165
|
+
// `padding-inline-start` beats `padding-left`. Pinned in a test rather than
|
|
166
|
+
// trusted, since it is the opposite of what the vertical shorthands do.
|
|
167
|
+
start: (n, v) => n.setPosition(Yoga.EDGE_START, v),
|
|
168
|
+
end: (n, v) => n.setPosition(Yoga.EDGE_END, v),
|
|
169
|
+
marginStart: (n, v) => n.setMargin(Yoga.EDGE_START, v),
|
|
170
|
+
marginEnd: (n, v) => n.setMargin(Yoga.EDGE_END, v),
|
|
171
|
+
paddingStart: (n, v) => n.setPadding(Yoga.EDGE_START, v),
|
|
172
|
+
paddingEnd: (n, v) => n.setPadding(Yoga.EDGE_END, v),
|
|
115
173
|
gap: (n, v) => n.setGap(Yoga.GUTTER_ALL, v ?? 0),
|
|
116
174
|
rowGap: (n, v) => n.setGap(Yoga.GUTTER_ROW, v ?? 0),
|
|
117
175
|
columnGap: (n, v) => n.setGap(Yoga.GUTTER_COLUMN, v ?? 0),
|
|
@@ -121,14 +179,46 @@ const LAYOUT_APPLIERS = {
|
|
|
121
179
|
overflow: (n, v) =>
|
|
122
180
|
n.setOverflow(pick(OVERFLOW, v, 'overflow') ?? Yoga.OVERFLOW_VISIBLE),
|
|
123
181
|
borderWidth: (n, v) => n.setBorder(Yoga.EDGE_ALL, v ?? 0),
|
|
182
|
+
// per-side widths resolve the way padding does: the side overrides the
|
|
183
|
+
// shorthand, and yoga's own edge precedence (EDGE_TOP over EDGE_ALL) is
|
|
184
|
+
// what implements the override
|
|
185
|
+
borderTopWidth: (n, v) => n.setBorder(Yoga.EDGE_TOP, v),
|
|
186
|
+
borderRightWidth: (n, v) => n.setBorder(Yoga.EDGE_RIGHT, v),
|
|
187
|
+
borderBottomWidth: (n, v) => n.setBorder(Yoga.EDGE_BOTTOM, v),
|
|
188
|
+
borderLeftWidth: (n, v) => n.setBorder(Yoga.EDGE_LEFT, v),
|
|
189
|
+
borderStartWidth: (n, v) => n.setBorder(Yoga.EDGE_START, v),
|
|
190
|
+
borderEndWidth: (n, v) => n.setBorder(Yoga.EDGE_END, v),
|
|
124
191
|
};
|
|
125
192
|
|
|
126
193
|
// Props that only affect painting, not geometry.
|
|
194
|
+
//
|
|
195
|
+
// `outline*` is here rather than beside `border*` in LAYOUT_APPLIERS for the
|
|
196
|
+
// reason CSS grew a second property at all: a focus ring must not move the
|
|
197
|
+
// thing it is drawn around. It is painted outside the border box and takes
|
|
198
|
+
// no part in yoga, so switching it on is a repaint of one node and nothing
|
|
199
|
+
// under it reflows.
|
|
127
200
|
const PAINT_PROPS = new Set([
|
|
128
201
|
'backgroundColor',
|
|
202
|
+
// The two decorations that are not a colour (issue #345, src/decorations.js).
|
|
203
|
+
// Paint props like the rest of this set, which is what makes them legal in
|
|
204
|
+
// a state block — a card that lifts on `:hover` is the case they exist for
|
|
205
|
+
// — and what keeps them out of layout: a gradient is painted in the box the
|
|
206
|
+
// layout already decided on, and a shadow is drawn outside it and moves
|
|
207
|
+
// nothing.
|
|
208
|
+
'backgroundImage',
|
|
209
|
+
'boxShadow',
|
|
129
210
|
'borderColor',
|
|
211
|
+
'borderTopColor',
|
|
212
|
+
'borderRightColor',
|
|
213
|
+
'borderBottomColor',
|
|
214
|
+
'borderLeftColor',
|
|
215
|
+
'borderStartColor',
|
|
216
|
+
'borderEndColor',
|
|
130
217
|
'borderRadius',
|
|
131
218
|
'zIndex',
|
|
219
|
+
'outlineWidth',
|
|
220
|
+
'outlineColor',
|
|
221
|
+
'outlineOffset',
|
|
132
222
|
]);
|
|
133
223
|
|
|
134
224
|
// Text style props. All affect measurement except color.
|
|
@@ -137,14 +227,1285 @@ export const TEXT_LAYOUT_PROPS = new Set([
|
|
|
137
227
|
'fontSize',
|
|
138
228
|
'fontWeight',
|
|
139
229
|
'fontStyle',
|
|
230
|
+
// a variable font's axes, `{ wght: 460 }` — the `wght` axis is already
|
|
231
|
+
// driven by `fontWeight`, so this is for the rest. Compared by value, not
|
|
232
|
+
// identity, since it is written as an object literal in a render
|
|
233
|
+
'fontVariationSettings',
|
|
140
234
|
'textAlign',
|
|
141
235
|
'lineHeight',
|
|
236
|
+
// read by TextNode rather than passed to ntk: it trims the box the layout
|
|
237
|
+
// produced, it does not change the layout
|
|
238
|
+
'textBoxTrim',
|
|
239
|
+
// read by TextNode too, and it *is* the layout: `'nowrap'` measures at
|
|
240
|
+
// unbounded width, so the text is one line and whatever contains it decides
|
|
241
|
+
// what to do about the overflow
|
|
242
|
+
'textWrap',
|
|
243
|
+
// The truncation pair. Both are handed straight to ntk's TextLayout, which
|
|
244
|
+
// does the careful version — the ellipsis in the cut run's own font, the
|
|
245
|
+
// cut on a grapheme boundary with the tail re-shaped, and on the visually
|
|
246
|
+
// last run rather than the logically last one. `textOverflow` changes what
|
|
247
|
+
// fits on a line, so it is a measurement input like the rest of this set.
|
|
248
|
+
'textOverflow',
|
|
249
|
+
'maxLines',
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Text style props that change how the text is **drawn** and provably not
|
|
254
|
+
* where any of it lands. They still invalidate the cached layout — the value
|
|
255
|
+
* rides on the spans inside it — but never the box, so changing one repaints
|
|
256
|
+
* without reflowing.
|
|
257
|
+
*
|
|
258
|
+
* `textRendering` is CSS's, and picks the glyph path: `geometricPrecision`
|
|
259
|
+
* puts glyph origins exactly where shaping asked, `optimizeSpeed` keeps them
|
|
260
|
+
* on ntk's cached-bitmap path, `auto` lets size decide. Only rounding at
|
|
261
|
+
* draw time differs — ntk's layout answers byte-identically for all three,
|
|
262
|
+
* down to per-run offsets — which is what makes it safe to keep out of the
|
|
263
|
+
* measurement set.
|
|
264
|
+
*/
|
|
265
|
+
export const TEXT_PAINT_PROPS = new Set(['textRendering']);
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The text properties that **inherit** — the ones a node hands down to
|
|
269
|
+
* everything drawing text inside it, so `<box style={{ color: theme.textMuted }}>`
|
|
270
|
+
* dims the labels under it the way it would in CSS.
|
|
271
|
+
*
|
|
272
|
+
* This is CSS's inherited set narrowed to what a *descendant* can act on: the
|
|
273
|
+
* face, the size, the ink and the glyph rounding. `textAlign`, `lineHeight`,
|
|
274
|
+
* `textWrap`, `textOverflow`, `maxLines` and `textBoxTrim` stay out even
|
|
275
|
+
* though CSS inherits the first two — here they are read by the node that
|
|
276
|
+
* owns the **box** the text flows in, and a box is not something a descendant
|
|
277
|
+
* has. A `<box>` that wants its children aligned says so in the styles it
|
|
278
|
+
* gives them.
|
|
279
|
+
*/
|
|
280
|
+
export const INHERITED_TEXT_PROPS = new Set([
|
|
281
|
+
'fontFamily',
|
|
282
|
+
'fontSize',
|
|
283
|
+
'fontWeight',
|
|
284
|
+
'fontStyle',
|
|
285
|
+
'fontVariationSettings',
|
|
286
|
+
'textRendering',
|
|
287
|
+
'color',
|
|
142
288
|
]);
|
|
143
289
|
|
|
290
|
+
/**
|
|
291
|
+
* The text props that do **not** inherit — `TEXT_LAYOUT_PROPS` minus
|
|
292
|
+
* `INHERITED_TEXT_PROPS`. They shape the box a node's own text flows in, so
|
|
293
|
+
* no cascade can bring one in from above and the node that owns them is the
|
|
294
|
+
* only one that has to react.
|
|
295
|
+
*/
|
|
296
|
+
export const LOCAL_TEXT_PROPS = new Set(
|
|
297
|
+
[...TEXT_LAYOUT_PROPS].filter((key) => !INHERITED_TEXT_PROPS.has(key)),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
/** Did anything that shapes this node's own text box change? */
|
|
301
|
+
export function localTextStyleChanged(style, before) {
|
|
302
|
+
if (style === before) return false;
|
|
303
|
+
for (const key of LOCAL_TEXT_PROPS) {
|
|
304
|
+
if (style[key] !== before[key]) return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Did anything a descendant inherits change between two style bags? The
|
|
310
|
+
* gate on re-resolving a subtree, so a commit that moved `padding` walks
|
|
311
|
+
* nothing. */
|
|
312
|
+
export function inheritedTextChanged(style, before) {
|
|
313
|
+
if (style === before) return false;
|
|
314
|
+
for (const key of INHERITED_TEXT_PROPS) {
|
|
315
|
+
if (key === 'fontVariationSettings') {
|
|
316
|
+
if (!axesEqual(style[key], before[key])) return true;
|
|
317
|
+
} else if (style[key] !== before[key]) return true;
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** What a change in resolved text style costs the node that draws with it:
|
|
323
|
+
* a glyph may have moved. */
|
|
324
|
+
export const TEXT_REMEASURE = 2;
|
|
325
|
+
/** …or only the ink or the rounding did, so the box cannot have changed. */
|
|
326
|
+
export const TEXT_REPAINT = 1;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Compare two **resolved** text styles (`textStyleFrom`'s shape, which is
|
|
330
|
+
* ntk's) and price the difference.
|
|
331
|
+
*
|
|
332
|
+
* The split is what keeps a colour cascade off the layout path: `color` and
|
|
333
|
+
* `textRendering` ride on the spans inside a cached layout, so the layout
|
|
334
|
+
* still has to go — but neither moves a glyph, so nothing needs measuring
|
|
335
|
+
* again. Conflating the two is why `:hover { color }` used to be able to
|
|
336
|
+
* cost a full layout pass per pointer move.
|
|
337
|
+
*/
|
|
338
|
+
export function resolvedTextDelta(a, b) {
|
|
339
|
+
if (a === b) return 0;
|
|
340
|
+
if (
|
|
341
|
+
a.family !== b.family ||
|
|
342
|
+
a.size !== b.size ||
|
|
343
|
+
a.weight !== b.weight ||
|
|
344
|
+
a.style !== b.style ||
|
|
345
|
+
!axesEqual(a.variations, b.variations)
|
|
346
|
+
) {
|
|
347
|
+
return TEXT_REMEASURE;
|
|
348
|
+
}
|
|
349
|
+
if (a.color !== b.color || a.textRendering !== b.textRendering) {
|
|
350
|
+
return TEXT_REPAINT;
|
|
351
|
+
}
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Every text layout prop is a scalar and compares by value, except the one
|
|
357
|
+
* that is a bag of axis coordinates. `fontVariationSettings` is written as
|
|
358
|
+
* an object literal in a render, so a fresh one arrives on every commit and
|
|
359
|
+
* `!==` would call it a change every time — re-shaping the paragraph and
|
|
360
|
+
* re-rasterizing its glyphs to arrive at the same pixels. Small and flat, so
|
|
361
|
+
* comparing it is cheaper than believing it.
|
|
362
|
+
*/
|
|
363
|
+
export function axesEqual(a, b) {
|
|
364
|
+
if (a === b) return true;
|
|
365
|
+
if (!a || !b) return false;
|
|
366
|
+
const keys = Object.keys(a);
|
|
367
|
+
if (keys.length !== Object.keys(b).length) return false;
|
|
368
|
+
for (const key of keys) {
|
|
369
|
+
if (a[key] !== b[key]) return false;
|
|
370
|
+
}
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
|
|
144
374
|
export const isLayoutProp = (name) =>
|
|
145
375
|
Object.prototype.hasOwnProperty.call(LAYOUT_APPLIERS, name);
|
|
146
376
|
export const isPaintProp = (name) => PAINT_PROPS.has(name);
|
|
147
|
-
|
|
377
|
+
/**
|
|
378
|
+
* A handler prop. `unstable_`-prefixed ones count: the prefix marks an API as
|
|
379
|
+
* provisional (React's own idiom), it does not stop the prop being a handler.
|
|
380
|
+
* Both callers care — a handler must not reach `CreateWindow` as a window
|
|
381
|
+
* attribute, and `paintChanged` must not claim a repaint every time a render
|
|
382
|
+
* passes a fresh inline arrow.
|
|
383
|
+
*/
|
|
384
|
+
export const isEventProp = (name) => /^(?:unstable_)?on[A-Z]/.test(name);
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* State blocks, lowest precedence first. These are *node* states, not
|
|
388
|
+
* selectors: each one is something the node itself knows about, so
|
|
389
|
+
* resolving them needs no specificity and no matching. Anything relational
|
|
390
|
+
* — `:hover > child`, a sibling selector, `:nth-child` — stays in React,
|
|
391
|
+
* where composition already answers it.
|
|
392
|
+
*
|
|
393
|
+
* The two that read as relational are not. `:hover` and `:active` mark the
|
|
394
|
+
* whole ancestor chain because the node the pointer actually landed on is
|
|
395
|
+
* whatever the control happens to be built out of, and `:focus-within` is
|
|
396
|
+
* the same fact about the focus path — each of them is still "something
|
|
397
|
+
* true of this node", diffed over a path the event manager has already
|
|
398
|
+
* computed. What a child does about an ancestor's state is inheritance
|
|
399
|
+
* rather than a selector: a `:hover` block that sets `color` reaches the
|
|
400
|
+
* labels inside, the way it does in CSS.
|
|
401
|
+
*/
|
|
402
|
+
export const STATE_KEYS = [
|
|
403
|
+
// The pointer is heading here but has not arrived (ntk#37). Lowest
|
|
404
|
+
// precedence of the lot, because it is the only *prediction* in the list
|
|
405
|
+
// and every other state is a fact: once the pointer actually lands,
|
|
406
|
+
// `:hover` is the truth and has to win.
|
|
407
|
+
':attention',
|
|
408
|
+
':hover',
|
|
409
|
+
// Focus is on this node or inside it — CSS's `:focus-within`. Below
|
|
410
|
+
// `:focus` on purpose: it is the broader fact, so a node that is itself
|
|
411
|
+
// focused should be able to say something narrower and win.
|
|
412
|
+
':focus-within',
|
|
413
|
+
':focus',
|
|
414
|
+
// Focus that came from the keyboard rather than from a press — CSS's
|
|
415
|
+
// `:focus-visible`, and for the same reason: a ring on every click is
|
|
416
|
+
// noise, a ring on Tab is the only way a keyboard user can tell where
|
|
417
|
+
// they are. `focus()` decides which it was; see EventManager.
|
|
418
|
+
':focus-visible',
|
|
419
|
+
':active',
|
|
420
|
+
':disabled',
|
|
421
|
+
':drag-over',
|
|
422
|
+
':dragging',
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
// What a state block may change. Deliberately paint-only: a state block that
|
|
426
|
+
// could set `padding` or `fontSize` would reflow the tree on pointer move,
|
|
427
|
+
// which is both a jitter bug and the end of the "hover is a repaint, not a
|
|
428
|
+
// React render" property that makes this worth having at all.
|
|
429
|
+
const STATE_PROPS = new Set([...PAINT_PROPS, 'color']);
|
|
430
|
+
|
|
431
|
+
const STYLE_PROPS = new Set([
|
|
432
|
+
...Object.keys(LAYOUT_APPLIERS),
|
|
433
|
+
// the one layout property that is not a yoga property: a shorthand for
|
|
434
|
+
// three of them, expanded by `resolveComputedStyle`
|
|
435
|
+
'flex',
|
|
436
|
+
...PAINT_PROPS,
|
|
437
|
+
...TEXT_LAYOUT_PROPS,
|
|
438
|
+
...TEXT_PAINT_PROPS,
|
|
439
|
+
'color',
|
|
440
|
+
'borderStyle',
|
|
441
|
+
'transition',
|
|
442
|
+
// a loop rather than a change with an end — see `animationsOf`
|
|
443
|
+
'animation',
|
|
444
|
+
// CSS concepts even though they read as behaviour; React Native has been
|
|
445
|
+
// moving pointerEvents into style for the same reason
|
|
446
|
+
'cursor',
|
|
447
|
+
'pointerEvents',
|
|
448
|
+
// How far outside the box the pointer still counts as hitting it. Neither
|
|
449
|
+
// layout nor paint — the one thing it must never do is grow the visuals,
|
|
450
|
+
// since the whole point is a 24px target under a 16px control.
|
|
451
|
+
'hitSlop',
|
|
452
|
+
]);
|
|
453
|
+
|
|
454
|
+
export const isStyleProp = (name) => STYLE_PROPS.has(name);
|
|
455
|
+
|
|
456
|
+
/** Every style property, by name. DevTools' style editor takes this list as
|
|
457
|
+
* `nativeStyleEditorValidAttributes` — what it offers to add to an element
|
|
458
|
+
* — so it is the same set `isStyleProp` answers for rather than a second
|
|
459
|
+
* list that could drift from it. */
|
|
460
|
+
export const STYLE_PROP_NAMES = Object.freeze([...STYLE_PROPS].sort());
|
|
461
|
+
|
|
462
|
+
const isState = (key) => key.charCodeAt(0) === 58; /* ':' */
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Window size queries: `'@width >= 600'`. The X11 analogue of `@media` —
|
|
466
|
+
* what a style can usefully ask about here is the window it is being laid
|
|
467
|
+
* out in, not the screen.
|
|
468
|
+
*
|
|
469
|
+
* Unlike a state block, a size query *may* set layout properties. That is
|
|
470
|
+
* not an inconsistency: pointer state changes must never reflow the tree,
|
|
471
|
+
* but a size query is only ever re-evaluated during a layout pass that a
|
|
472
|
+
* resize has already triggered, so it costs nothing extra.
|
|
473
|
+
*/
|
|
474
|
+
const SIZE_QUERY = /^@(width|height)\s*(>=|<=|>|<)\s*(\d+(?:\.\d+)?)$/;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Capability queries: `'@supports transparency'`. Where a size query asks
|
|
478
|
+
* about the window, this asks about the *server* — what will actually be
|
|
479
|
+
* shown if the style asks for it.
|
|
480
|
+
*
|
|
481
|
+
* `transparency` is true only when the window really has an alpha channel
|
|
482
|
+
* (it was created on a 32-bit visual) *and* a compositor is running to
|
|
483
|
+
* blend it. Either half missing and a transparent corner is a black corner,
|
|
484
|
+
* so the honest answer is no. It is per window, not per display: a plain
|
|
485
|
+
* `<window>` never composites anything, so the same component nested in one
|
|
486
|
+
* gets the opaque design without being told twice.
|
|
487
|
+
*/
|
|
488
|
+
const SUPPORTS_QUERY = /^@supports\s+(transparency)$/;
|
|
489
|
+
const SUPPORTS_FEATURES = ['transparency'];
|
|
490
|
+
const isQuery = (key) => key.charCodeAt(0) === 64; /* '@' */
|
|
491
|
+
|
|
492
|
+
const parsedQueries = new Map();
|
|
493
|
+
function parseQuery(key) {
|
|
494
|
+
let q = parsedQueries.get(key);
|
|
495
|
+
if (q === undefined) {
|
|
496
|
+
const size = SIZE_QUERY.exec(key);
|
|
497
|
+
const supports = size ? null : SUPPORTS_QUERY.exec(key);
|
|
498
|
+
q = size
|
|
499
|
+
? { kind: 'size', axis: size[1], op: size[2], value: Number(size[3]) }
|
|
500
|
+
: supports
|
|
501
|
+
? { kind: 'supports', feature: supports[1] }
|
|
502
|
+
: null;
|
|
503
|
+
parsedQueries.set(key, q);
|
|
504
|
+
}
|
|
505
|
+
return q;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function sizeMatches(q, size) {
|
|
509
|
+
const v = size?.[q.axis];
|
|
510
|
+
if (v == null) return false;
|
|
511
|
+
return q.op === '>='
|
|
512
|
+
? v >= q.value
|
|
513
|
+
: q.op === '<='
|
|
514
|
+
? v <= q.value
|
|
515
|
+
: q.op === '>'
|
|
516
|
+
? v > q.value
|
|
517
|
+
: v < q.value;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const hasQueryOfKind = (style, kind) => {
|
|
521
|
+
for (const key of Object.keys(style)) {
|
|
522
|
+
if (isQuery(key) && parseQuery(key)?.kind === kind) return true;
|
|
523
|
+
}
|
|
524
|
+
return false;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/** Re-resolved when the window is laid out at a new size. */
|
|
528
|
+
export const styleHasSizeQueries = (style) => hasQueryOfKind(style, 'size');
|
|
529
|
+
|
|
530
|
+
/** Re-resolved when the server's answer changes — a compositor starting or
|
|
531
|
+
* stopping — rather than on every layout. The two registries are kept
|
|
532
|
+
* apart because the triggers are: a resize must not walk every node that
|
|
533
|
+
* only ever asked about transparency. */
|
|
534
|
+
export const styleHasSupportsQueries = (style) =>
|
|
535
|
+
hasQueryOfKind(style, 'supports');
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Merge the query blocks that match, in declaration order, over the base.
|
|
539
|
+
* Size and capability blocks resolve in one pass so that ordering between
|
|
540
|
+
* them is the order they were written in. Returns the style itself when
|
|
541
|
+
* nothing matches, so the identity fast path survives the common case.
|
|
542
|
+
*
|
|
543
|
+
* `supports` is the map of capability answers, or null while they are still
|
|
544
|
+
* unknown — in which case a capability block does not apply, which is the
|
|
545
|
+
* safe way round: the fallback design is the one that works everywhere.
|
|
546
|
+
*/
|
|
547
|
+
/**
|
|
548
|
+
* `resolveQueries` with only the size half, the shape this had before
|
|
549
|
+
* capability blocks existed. Kept because `react-x11/style` is a public
|
|
550
|
+
* entry and a registered element outside the package may be calling it.
|
|
551
|
+
*/
|
|
552
|
+
export function resolveSizeQueries(style, size) {
|
|
553
|
+
return size ? resolveQueries(style, { size }) : style;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function resolveQueries(style, { size = null, supports = null } = {}) {
|
|
557
|
+
let out = style;
|
|
558
|
+
for (const key of Object.keys(style)) {
|
|
559
|
+
if (!isQuery(key)) continue;
|
|
560
|
+
const q = parseQuery(key);
|
|
561
|
+
if (!q) continue;
|
|
562
|
+
const hit =
|
|
563
|
+
q.kind === 'size'
|
|
564
|
+
? size && sizeMatches(q, size)
|
|
565
|
+
: Boolean(supports?.[q.feature]);
|
|
566
|
+
if (!hit) continue;
|
|
567
|
+
if (out === style) out = { ...style };
|
|
568
|
+
Object.assign(out, style[key]);
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The two style values that are a small language rather than a number, and
|
|
575
|
+
* therefore the two that can be *wrong* rather than merely absent. Parsed in
|
|
576
|
+
* development wherever they are written — including inside a state block,
|
|
577
|
+
* which is the half of the surface a `continue` used to skip — so the error
|
|
578
|
+
* naming the property and the expected spelling arrives at the call site
|
|
579
|
+
* instead of as a blank panel three commits later.
|
|
580
|
+
*
|
|
581
|
+
* Tokens are still unresolved here (`$accent` is a colour as far as the
|
|
582
|
+
* grammar is concerned), so this checks the shape and never the colours.
|
|
583
|
+
*/
|
|
584
|
+
function validateValue(key, value, where) {
|
|
585
|
+
if (key !== 'backgroundImage' && key !== 'boxShadow') return;
|
|
586
|
+
try {
|
|
587
|
+
if (key === 'backgroundImage') parseLinearGradient(value);
|
|
588
|
+
else parseBoxShadow(value);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
// the parser names the property and the grammar; only the call site is
|
|
591
|
+
// missing, and it is what turns the message into a place to look
|
|
592
|
+
err.message += `\n in ${where}`;
|
|
593
|
+
throw err;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function validateStyle(style, where) {
|
|
598
|
+
for (const key of Object.keys(style)) {
|
|
599
|
+
if (isQuery(key)) {
|
|
600
|
+
if (!parseQuery(key)) {
|
|
601
|
+
throw new Error(
|
|
602
|
+
`react-x11: bad query "${key}" in ${where} (expected a size query ` +
|
|
603
|
+
'like "@width >= 600", or a capability query like ' +
|
|
604
|
+
`"@supports ${SUPPORTS_FEATURES.join('" / "@supports ')}")`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
validateStyle(style[key] ?? {}, `${where} ${key}`);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (isState(key)) {
|
|
611
|
+
if (!STATE_KEYS.includes(key)) {
|
|
612
|
+
throw new Error(
|
|
613
|
+
`react-x11: unknown style state "${key}" in ${where} ` +
|
|
614
|
+
`(expected one of ${STATE_KEYS.join(', ')})`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
for (const inner of Object.keys(style[key] ?? {})) {
|
|
618
|
+
if (STATE_PROPS.has(inner)) {
|
|
619
|
+
validateValue(inner, style[key][inner], `${where} ${key}`);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
throw new Error(
|
|
623
|
+
`react-x11: "${inner}" is not allowed inside "${key}" in ${where}. ` +
|
|
624
|
+
'State blocks may only change paint properties ' +
|
|
625
|
+
`(${[...STATE_PROPS].join(', ')}) — anything that reflows the ` +
|
|
626
|
+
'tree on hover belongs in React state.',
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (!STYLE_PROPS.has(key)) {
|
|
632
|
+
throw new Error(`react-x11: unknown style property "${key}" in ${where}`);
|
|
633
|
+
}
|
|
634
|
+
// The one style value that describes motion rather than a state, so the
|
|
635
|
+
// one whose mistakes are invisible: a loop with a bad `to` does not draw
|
|
636
|
+
// anything wrong, it simply never moves.
|
|
637
|
+
if (key === 'animation') animationsOf(style, where);
|
|
638
|
+
validateValue(key, style[key], where);
|
|
639
|
+
if (key === 'flex' && !isFlexShorthand(style[key])) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`react-x11: invalid flex ${JSON.stringify(style[key])} in ${where} ` +
|
|
642
|
+
'(expected a number — flex: 1 is flexGrow: 1, flexShrink: 1, ' +
|
|
643
|
+
"flexBasis: 0 — or 'auto' / 'none')",
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Flatten a style prop — an object, or a nested array of them with falsy
|
|
651
|
+
* entries skipped, later entries winning:
|
|
652
|
+
*
|
|
653
|
+
* style={[styles.card, isWide && styles.wide, { padding: 4 }]}
|
|
654
|
+
*
|
|
655
|
+
* This is what replaces the cascade: precedence is written at the call
|
|
656
|
+
* site instead of being resolved by specificity.
|
|
657
|
+
*/
|
|
658
|
+
export function flattenStyle(style, into) {
|
|
659
|
+
if (!style) return into ?? EMPTY_STYLE;
|
|
660
|
+
if (Array.isArray(style)) {
|
|
661
|
+
const acc = into ?? {};
|
|
662
|
+
for (const entry of style) flattenStyle(entry, acc);
|
|
663
|
+
return acc;
|
|
664
|
+
}
|
|
665
|
+
// a lone object is returned as-is: no copy, and `===` still identifies a
|
|
666
|
+
// hoisted style across renders
|
|
667
|
+
if (!into) return style;
|
|
668
|
+
for (const key of Object.keys(style)) {
|
|
669
|
+
// a state block merges with one already collected rather than replacing
|
|
670
|
+
// it, so [{':hover': {color}}, {':hover': {backgroundColor}}] keeps both
|
|
671
|
+
into[key] =
|
|
672
|
+
isState(key) && into[key] ? { ...into[key], ...style[key] } : style[key];
|
|
673
|
+
}
|
|
674
|
+
return into;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export const EMPTY_STYLE = Object.freeze({});
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Declare styles once, outside render. Identity is the point: a hoisted
|
|
681
|
+
* style object lets the renderer skip an update with a `===` check, the
|
|
682
|
+
* same reason RN's StyleSheet.create exists now that its id registry is
|
|
683
|
+
* gone. It also validates keys, which a bare object literal cannot.
|
|
684
|
+
*/
|
|
685
|
+
export function createStyles(sheet) {
|
|
686
|
+
for (const name of Object.keys(sheet)) {
|
|
687
|
+
validateStyle(sheet[name], `styles.${name}`);
|
|
688
|
+
Object.freeze(sheet[name]);
|
|
689
|
+
}
|
|
690
|
+
return Object.freeze(sheet);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Overlay the active state blocks on a flattened style, lowest precedence
|
|
695
|
+
* first (hover < focus < active < disabled — a disabled control must never
|
|
696
|
+
* look hovered). Returns the base object itself when no state is active,
|
|
697
|
+
* so the common case allocates nothing and stays `===`-comparable.
|
|
698
|
+
*/
|
|
699
|
+
export function resolveStyleStates(style, states) {
|
|
700
|
+
let resolved = style;
|
|
701
|
+
for (const key of STATE_KEYS) {
|
|
702
|
+
const block = style[key];
|
|
703
|
+
if (!block || !states[key]) continue;
|
|
704
|
+
if (resolved === style) resolved = { ...style };
|
|
705
|
+
Object.assign(resolved, block);
|
|
706
|
+
}
|
|
707
|
+
return resolved;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Does this style react to node state at all? */
|
|
711
|
+
export function hasStateStyles(style) {
|
|
712
|
+
for (const key of STATE_KEYS) if (style[key]) return true;
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Style properties a transition can animate: numbers and colours. Enums
|
|
718
|
+
* (`flexDirection`), `zIndex` (restacking every frame is not an animation)
|
|
719
|
+
* and `transition` itself are excluded — a change to those snaps.
|
|
720
|
+
*/
|
|
721
|
+
const NOT_ANIMATABLE = new Set([
|
|
722
|
+
'transition',
|
|
723
|
+
// the loop declaration itself: it is an object, and what it describes is
|
|
724
|
+
// already a value changing over time
|
|
725
|
+
'animation',
|
|
726
|
+
// Both are strings that describe several numbers and a colour at once, and
|
|
727
|
+
// `interpolate` works on one value. They snap, which for a state change is
|
|
728
|
+
// what the shorter durations look like anyway; a card that wants to *rise*
|
|
729
|
+
// on hover transitions its `borderColor` or its background beside them.
|
|
730
|
+
'backgroundImage',
|
|
731
|
+
'boxShadow',
|
|
732
|
+
'zIndex',
|
|
733
|
+
'direction',
|
|
734
|
+
'flexDirection',
|
|
735
|
+
'justifyContent',
|
|
736
|
+
'alignItems',
|
|
737
|
+
'alignSelf',
|
|
738
|
+
'alignContent',
|
|
739
|
+
'flexWrap',
|
|
740
|
+
'position',
|
|
741
|
+
'display',
|
|
742
|
+
'overflow',
|
|
743
|
+
'cursor',
|
|
744
|
+
'pointerEvents',
|
|
745
|
+
'borderStyle',
|
|
746
|
+
'fontFamily',
|
|
747
|
+
'fontWeight',
|
|
748
|
+
'fontStyle',
|
|
749
|
+
'textAlign',
|
|
750
|
+
// nothing is drawn from it, so there is no frame in which a halfway value
|
|
751
|
+
// would be visible — and it may be an object, which does not lerp
|
|
752
|
+
'hitSlop',
|
|
753
|
+
]);
|
|
754
|
+
|
|
755
|
+
export const isAnimatableProp = (name) =>
|
|
756
|
+
STYLE_PROPS.has(name) && !NOT_ANIMATABLE.has(name);
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* `transition: 150` — every animatable property that changes, over 150ms.
|
|
760
|
+
* `transition: { backgroundColor: 150 }` — only these, with their own
|
|
761
|
+
* durations. Returns a lookup of prop -> ms, or null.
|
|
762
|
+
*/
|
|
763
|
+
export function transitionFor(style, prop) {
|
|
764
|
+
const t = style.transition;
|
|
765
|
+
if (t == null || !isAnimatableProp(prop)) return 0;
|
|
766
|
+
if (typeof t === 'number') return t;
|
|
767
|
+
return t[prop] ?? 0;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* The easing curves an `animation` can name. `transition` has one fixed
|
|
772
|
+
* ease-out and keeps it — a change that ends looks right slowing into its
|
|
773
|
+
* new value — but a loop is a different shape: ease-out on a cycle that
|
|
774
|
+
* restarts reads as a stutter at the wrap, so the default here is `linear`
|
|
775
|
+
* and the curve is the author's to pick.
|
|
776
|
+
*/
|
|
777
|
+
const EASINGS = {
|
|
778
|
+
linear: (t) => t,
|
|
779
|
+
'ease-in': (t) => t ** 3,
|
|
780
|
+
'ease-out': (t) => 1 - (1 - t) ** 3,
|
|
781
|
+
'ease-in-out': (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2),
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
export const EASING_NAMES = Object.freeze(Object.keys(EASINGS));
|
|
785
|
+
|
|
786
|
+
/** A value that is still a `$token` reference, or mentions one. */
|
|
787
|
+
const unresolvedValue = (v) => isToken(v) || mentionsToken(v);
|
|
788
|
+
|
|
789
|
+
// Parsed `animation` objects, keyed by the object itself: a hoisted style
|
|
790
|
+
// parses once for the process, an inline one once per render. What is cached
|
|
791
|
+
// is the *declaration* — `from` may still be defaulting to the property's
|
|
792
|
+
// declared value, which belongs to the style rather than to this object.
|
|
793
|
+
const parsedAnimations = new WeakMap();
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* `animation` — a property that travels between two values and keeps doing
|
|
797
|
+
* it, as against `transition`, which is how long it takes to arrive
|
|
798
|
+
* somewhere and is over when it gets there.
|
|
799
|
+
*
|
|
800
|
+
* ```js
|
|
801
|
+
* animation: { left: { from: '-40%', to: '100%', duration: 1100 } }
|
|
802
|
+
* ```
|
|
803
|
+
*
|
|
804
|
+
* Per property rather than one config for the object, because the two things
|
|
805
|
+
* a loop needs — where it goes from and where it goes to — are per property
|
|
806
|
+
* already, and a shared `duration` would only be a shorthand for writing it
|
|
807
|
+
* twice.
|
|
808
|
+
*
|
|
809
|
+
* `from` defaults to what the style declares for that property, so a pulse
|
|
810
|
+
* reads as the resting value plus where it goes:
|
|
811
|
+
*
|
|
812
|
+
* ```js
|
|
813
|
+
* { backgroundColor: theme.track,
|
|
814
|
+
* animation: { backgroundColor: { to: theme.accent, duration: 900,
|
|
815
|
+
* alternate: true } } }
|
|
816
|
+
* ```
|
|
817
|
+
*
|
|
818
|
+
* Everything wrong with a declaration throws here rather than showing up as
|
|
819
|
+
* a thing that does not move: a loop nobody wrote a stop for is exactly the
|
|
820
|
+
* feature where silence is unreadable.
|
|
821
|
+
*/
|
|
822
|
+
function parseAnimation(spec, where) {
|
|
823
|
+
if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) {
|
|
824
|
+
throw new Error(
|
|
825
|
+
`react-x11: invalid animation ${JSON.stringify(spec)} in ${where} ` +
|
|
826
|
+
"(expected { property: { to, duration } }, e.g. { left: { from: '0%', " +
|
|
827
|
+
"to: '100%', duration: 900 } })",
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
const entries = [];
|
|
831
|
+
for (const prop of Object.keys(spec)) {
|
|
832
|
+
const at = `"${prop}" in ${where}`;
|
|
833
|
+
if (!isAnimatableProp(prop)) {
|
|
834
|
+
throw new Error(
|
|
835
|
+
`react-x11: ${at} cannot be animated — a loop interpolates, and ` +
|
|
836
|
+
'this property has no midpoint between two values.',
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
const entry = spec[prop];
|
|
840
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
841
|
+
throw new Error(
|
|
842
|
+
`react-x11: invalid animation for ${at} — expected ` +
|
|
843
|
+
'{ from?, to, duration, easing?, alternate? }, got ' +
|
|
844
|
+
JSON.stringify(entry),
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
for (const key of Object.keys(entry)) {
|
|
848
|
+
if (!['from', 'to', 'duration', 'easing', 'alternate'].includes(key)) {
|
|
849
|
+
throw new Error(
|
|
850
|
+
`react-x11: unknown animation option "${key}" for ${at} ` +
|
|
851
|
+
'(expected from, to, duration, easing, alternate)',
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (entry.to === undefined) {
|
|
856
|
+
throw new Error(`react-x11: animation for ${at} has no "to" value`);
|
|
857
|
+
}
|
|
858
|
+
if (
|
|
859
|
+
typeof entry.duration !== 'number' ||
|
|
860
|
+
!Number.isFinite(entry.duration) ||
|
|
861
|
+
entry.duration <= 0
|
|
862
|
+
) {
|
|
863
|
+
throw new Error(
|
|
864
|
+
`react-x11: animation for ${at} needs a positive "duration" in ms, ` +
|
|
865
|
+
`got ${JSON.stringify(entry.duration)}`,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
const easing = entry.easing ?? 'linear';
|
|
869
|
+
if (!Object.hasOwn(EASINGS, easing)) {
|
|
870
|
+
throw new Error(
|
|
871
|
+
`react-x11: unknown animation easing ${JSON.stringify(easing)} for ` +
|
|
872
|
+
`${at} (expected one of ${EASING_NAMES.join(', ')})`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
entries.push({
|
|
876
|
+
prop,
|
|
877
|
+
from: entry.from,
|
|
878
|
+
to: entry.to,
|
|
879
|
+
duration: entry.duration,
|
|
880
|
+
easing,
|
|
881
|
+
ease: EASINGS[easing],
|
|
882
|
+
alternate: Boolean(entry.alternate),
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
return entries;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* The loops a style declares, with `from` resolved against the style itself
|
|
890
|
+
* and both ends checked for a midpoint. Null when there are none, so the
|
|
891
|
+
* common case allocates nothing.
|
|
892
|
+
*/
|
|
893
|
+
export function animationsOf(style, where = 'a style', scale = 1) {
|
|
894
|
+
const spec = style.animation;
|
|
895
|
+
if (spec == null) return null;
|
|
896
|
+
let parsed = parsedAnimations.get(spec);
|
|
897
|
+
if (!parsed) {
|
|
898
|
+
parsed = parseAnimation(spec, where);
|
|
899
|
+
parsedAnimations.set(spec, parsed);
|
|
900
|
+
}
|
|
901
|
+
if (parsed.length === 0) return null;
|
|
902
|
+
return parsed.map((entry) => {
|
|
903
|
+
// The display scale, only where the caller says the style is already in
|
|
904
|
+
// device pixels (`_syncLoops` passes the node's). The declared ends are
|
|
905
|
+
// logical like everything an app writes; a `from` *defaulted* from the
|
|
906
|
+
// style is a device value already and is left alone — which is why the
|
|
907
|
+
// scaling happens here, where the two can still be told apart.
|
|
908
|
+
if (scale !== 1 && SCALED_LENGTHS.has(entry.prop)) {
|
|
909
|
+
entry = {
|
|
910
|
+
...entry,
|
|
911
|
+
...(typeof entry.from === 'number' && { from: entry.from * scale }),
|
|
912
|
+
...(typeof entry.to === 'number' && { to: entry.to * scale }),
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
const from = entry.from === undefined ? style[entry.prop] : entry.from;
|
|
916
|
+
if (from === undefined) {
|
|
917
|
+
throw new Error(
|
|
918
|
+
`react-x11: animation for "${entry.prop}" in ${where} has no "from" ` +
|
|
919
|
+
'value and the style does not declare one to start from',
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
// A `$token` is not a colour yet — `validateStyle` runs where the style
|
|
923
|
+
// is written, which is before the node has an ancestry to resolve
|
|
924
|
+
// against. The check is not skipped, only deferred: `_syncStyle`
|
|
925
|
+
// resolves the tokens and comes back through here with real values.
|
|
926
|
+
const resolved = from === entry.from ? entry : { ...entry, from };
|
|
927
|
+
if (unresolvedValue(from) || unresolvedValue(entry.to)) return resolved;
|
|
928
|
+
if (interpolate(from, entry.to, 0.5) === null) {
|
|
929
|
+
throw new Error(
|
|
930
|
+
`react-x11: animation for "${entry.prop}" in ${where} has no ` +
|
|
931
|
+
`midpoint between ${JSON.stringify(from)} and ` +
|
|
932
|
+
`${JSON.stringify(entry.to)} — a loop interpolates, so both ends ` +
|
|
933
|
+
'have to be numbers, colours, or percentages of the same unit.',
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
return resolved;
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Whether two resolved loop declarations describe the same motion — what
|
|
941
|
+
* decides between letting a running loop keep its phase and starting it
|
|
942
|
+
* again from the top. A re-render that produces an equal declaration must
|
|
943
|
+
* not make the animation jump. */
|
|
944
|
+
export function sameAnimation(a, b) {
|
|
945
|
+
return (
|
|
946
|
+
a.from === b.from &&
|
|
947
|
+
a.to === b.to &&
|
|
948
|
+
a.duration === b.duration &&
|
|
949
|
+
a.easing === b.easing &&
|
|
950
|
+
a.alternate === b.alternate
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Where a loop is at `elapsed` ms after it started. Pure, and the whole of
|
|
956
|
+
* the looping: the phase comes from a modulo of the elapsed time rather than
|
|
957
|
+
* from a per-cycle restart, so a bar that has been spinning for an hour is
|
|
958
|
+
* exactly where the clock says and no rounding has accumulated.
|
|
959
|
+
*/
|
|
960
|
+
export function animationValueAt(spec, elapsed) {
|
|
961
|
+
const cycles = Math.max(0, elapsed) / spec.duration;
|
|
962
|
+
let t = cycles % 1;
|
|
963
|
+
if (spec.alternate && Math.floor(cycles) % 2 === 1) t = 1 - t;
|
|
964
|
+
return interpolate(spec.from, spec.to, spec.ease(t)) ?? spec.from;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const PERCENT = /^\s*(-?\d+(?:\.\d+)?)%\s*$/;
|
|
968
|
+
|
|
969
|
+
const rgba = (c) =>
|
|
970
|
+
c &&
|
|
971
|
+
`rgba(${Math.round(c[0] * 255)}, ${Math.round(c[1] * 255)}, ${Math.round(c[2] * 255)}, ${c[3]})`;
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Interpolate one style value. Numbers lerp; two percentages of the same
|
|
975
|
+
* unit lerp as numbers and come back as a percentage, so a value expressed
|
|
976
|
+
* against its container animates without anyone having to measure the
|
|
977
|
+
* container; colours lerp per channel through ntk's own CSS colour parser,
|
|
978
|
+
* so anything the paint path accepts animates. Anything else — `auto`, an
|
|
979
|
+
* enum, a percentage against a pixel value — has no meaningful midpoint and
|
|
980
|
+
* returns null, which the caller treats as a snap.
|
|
981
|
+
*
|
|
982
|
+
* The colours are parsed **straight**, not premultiplied. The result is
|
|
983
|
+
* formatted back into an `rgba()` string, and that round trip only closes on
|
|
984
|
+
* unassociated components: premultiplied ones get scaled by alpha a second
|
|
985
|
+
* time when the paint path parses the string again, so a midpoint of a
|
|
986
|
+
* translucent colour would come out darker than either end.
|
|
987
|
+
*/
|
|
988
|
+
export function interpolate(from, to, t) {
|
|
989
|
+
if (typeof from === 'number' && typeof to === 'number') {
|
|
990
|
+
return from + (to - from) * t;
|
|
991
|
+
}
|
|
992
|
+
if (typeof from === 'string' && typeof to === 'string') {
|
|
993
|
+
// Percentages first, and only percentage against percentage: `'-40%'` to
|
|
994
|
+
// `'100%'` is a number moving in the container's units, which is the one
|
|
995
|
+
// way a loop can travel the width of something it never measured. Mixing
|
|
996
|
+
// one with a pixel value is still a snap — the midpoint of `10` and
|
|
997
|
+
// `'50%'` depends on a layout that has not run yet.
|
|
998
|
+
const pa = PERCENT.exec(from);
|
|
999
|
+
if (pa) {
|
|
1000
|
+
const pb = PERCENT.exec(to);
|
|
1001
|
+
if (!pb) return null;
|
|
1002
|
+
const value = Number(pa[1]) + (Number(pb[1]) - Number(pa[1])) * t;
|
|
1003
|
+
return `${Math.round(value * 1000) / 1000}%`;
|
|
1004
|
+
}
|
|
1005
|
+
if (PERCENT.test(to)) return null;
|
|
1006
|
+
const a = cssColorStraight(from);
|
|
1007
|
+
const b = cssColorStraight(to);
|
|
1008
|
+
if (!a || !b) return null;
|
|
1009
|
+
// Interpolate *premultiplied*, then divide the alpha back out, which is
|
|
1010
|
+
// what CSS does and for the same reason. `transparent` is black at zero
|
|
1011
|
+
// alpha, so lerping straight channels drags the colour towards black on
|
|
1012
|
+
// the way: half way from `transparent` to a near-white hover fill lands on
|
|
1013
|
+
// mid grey — 0.736 against 0.973 — and the curve is not even monotonic,
|
|
1014
|
+
// it darkens and then lightens again. That is the rectangle that flashes
|
|
1015
|
+
// when hover crosses two adjacent tabs, one fading out as the other fades
|
|
1016
|
+
// in, both passing through grey together.
|
|
1017
|
+
const alpha = a[3] + (b[3] - a[3]) * t;
|
|
1018
|
+
if (alpha <= 0) return 'rgba(0, 0, 0, 0)';
|
|
1019
|
+
const channel = (i) => {
|
|
1020
|
+
const from0 = a[i] * a[3];
|
|
1021
|
+
return (from0 + (b[i] * b[3] - from0) * t) / alpha;
|
|
1022
|
+
};
|
|
1023
|
+
return rgba([channel(0), channel(1), channel(2), alpha]);
|
|
1024
|
+
}
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* One more step in the direction `from` → `to`, clamped to the gamut.
|
|
1030
|
+
*
|
|
1031
|
+
* This is how a palette that named a hover and stopped there still gets a
|
|
1032
|
+
* pressed colour (`theme.js`): the press is the hover step taken twice. It
|
|
1033
|
+
* reads the *direction* rather than assuming one, so it darkens a light
|
|
1034
|
+
* theme and lightens a dark one without being told which it is — which is
|
|
1035
|
+
* the whole reason it is not a `darken(colour, 0.1)`.
|
|
1036
|
+
*
|
|
1037
|
+
* Straight components, like `interpolate`, and for the same round-trip
|
|
1038
|
+
* reason. Clamping is what extrapolation needs and interpolation does not:
|
|
1039
|
+
* two steps out of a near-white hover leaves the cube.
|
|
1040
|
+
*/
|
|
1041
|
+
export function stepBeyond(from, to) {
|
|
1042
|
+
const a = cssColorStraight(from);
|
|
1043
|
+
const b = cssColorStraight(to);
|
|
1044
|
+
if (!a || !b) return to;
|
|
1045
|
+
const step = (i) => Math.min(1, Math.max(0, b[i] + (b[i] - a[i])));
|
|
1046
|
+
return rgba([step(0), step(1), step(2), step(3)]);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* WCAG relative luminance — the perceptual lightness contrast is measured
|
|
1051
|
+
* in, which is not the mean of the channels: green carries most of it and
|
|
1052
|
+
* blue almost none, so `#0000ff` and `#00ff00` are worlds apart here and
|
|
1053
|
+
* three pixels apart in a channel average.
|
|
1054
|
+
*/
|
|
1055
|
+
function luminance(c) {
|
|
1056
|
+
const linear = (v) =>
|
|
1057
|
+
v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
1058
|
+
return 0.2126 * linear(c[0]) + 0.7152 * linear(c[1]) + 0.0722 * linear(c[2]);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Which of `inks` can be read on `fill` — the one with the most contrast,
|
|
1063
|
+
* WCAG's ratio.
|
|
1064
|
+
*
|
|
1065
|
+
* This is what keeps a palette from having to name the ink on every fill it
|
|
1066
|
+
* names. The two candidates a palette always has are its own `text` and its
|
|
1067
|
+
* own `background`, and one of them is readable on any fill by construction:
|
|
1068
|
+
* a fill light enough to swallow the light one is dark enough to show the
|
|
1069
|
+
* dark one. So `resolveTheme` derives `accentText` and the status inks from
|
|
1070
|
+
* the family colour, and a theme that names a yellow warning gets dark
|
|
1071
|
+
* letters on it without having thought about it.
|
|
1072
|
+
*
|
|
1073
|
+
* Ratio rather than a lightness threshold because a threshold is exactly the
|
|
1074
|
+
* thing that fails on the mid-tones: an accent at L*55 is on whichever side
|
|
1075
|
+
* of 50% the theme's own ink is not, and only a comparison knows which.
|
|
1076
|
+
*
|
|
1077
|
+
* Returns the first ink where a colour will not parse, which is the same
|
|
1078
|
+
* "keep going with what you were given" the rest of this file does.
|
|
1079
|
+
*/
|
|
1080
|
+
export function readableInk(fill, inks) {
|
|
1081
|
+
const bg = cssColorStraight(fill);
|
|
1082
|
+
if (!bg) return inks[0];
|
|
1083
|
+
const lb = luminance(bg);
|
|
1084
|
+
let best = inks[0];
|
|
1085
|
+
let bestRatio = -1;
|
|
1086
|
+
for (const ink of inks) {
|
|
1087
|
+
const c = cssColorStraight(ink);
|
|
1088
|
+
if (!c) continue;
|
|
1089
|
+
const li = luminance(c);
|
|
1090
|
+
const ratio = (Math.max(lb, li) + 0.05) / (Math.min(lb, li) + 0.05);
|
|
1091
|
+
if (ratio > bestRatio) {
|
|
1092
|
+
bestRatio = ratio;
|
|
1093
|
+
best = ink;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return best;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* A colour at a given opacity — `tint('#2980b9', 0.3)`.
|
|
1101
|
+
*
|
|
1102
|
+
* For fills that are drawn *under* text whose colour they do not control: a
|
|
1103
|
+
* selection highlight is the case, and an opaque one has to be chosen to
|
|
1104
|
+
* contrast with the ink on top of it, which cannot be done once for both a
|
|
1105
|
+
* light and a dark palette. A translucent one is chosen against the surface
|
|
1106
|
+
* instead, and the ink keeps whatever contrast it already had.
|
|
1107
|
+
*/
|
|
1108
|
+
export function tint(color, alpha) {
|
|
1109
|
+
const c = cssColorStraight(color);
|
|
1110
|
+
if (!c) return color;
|
|
1111
|
+
return rgba([c[0], c[1], c[2], c[3] * alpha]);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// ease-out cubic: fast to start, settles gently — the shape almost every UI
|
|
1115
|
+
// toolkit defaults to for state changes
|
|
1116
|
+
export const ease = (t) => 1 - (1 - t) ** 3;
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Theme tokens. A style value of `'$name'` resolves against the nearest
|
|
1120
|
+
* `theme` prop above the node, so a style can be hoisted — declared once,
|
|
1121
|
+
* outside render, with no access to React context — and still follow the
|
|
1122
|
+
* theme. The sigil is what keeps it unambiguous: `'red'` is a CSS colour,
|
|
1123
|
+
* `'$red'` is a token.
|
|
1124
|
+
*
|
|
1125
|
+
* A token also resolves **inside** a value that is a small language of its
|
|
1126
|
+
* own — `linear-gradient($accent, $accentActive)`, `boxShadow: '0 2px 8px
|
|
1127
|
+
* $shadow'`. Those two are the whole reason: the point of the palette is
|
|
1128
|
+
* that a colour is named once, and a decoration that could not name one
|
|
1129
|
+
* would push every gradient in an app back into `useTheme()` and out of a
|
|
1130
|
+
* hoisted style. It is the same substitution either way, so the value that
|
|
1131
|
+
* *is* a token keeps its fast path and the value that *mentions* one goes
|
|
1132
|
+
* through the regexp.
|
|
1133
|
+
*/
|
|
1134
|
+
const isToken = (v) => typeof v === 'string' && v.charCodeAt(0) === 36; /* $ */
|
|
1135
|
+
/** `$name`, anywhere in a string. Deliberately the same grammar as a whole
|
|
1136
|
+
* token, so `'$accent'` and `'linear-gradient($accent, #000)'` cannot
|
|
1137
|
+
* disagree about what a name is. */
|
|
1138
|
+
const TOKEN_IN_VALUE = /\$[A-Za-z_][A-Za-z0-9_-]*/g;
|
|
1139
|
+
const mentionsToken = (v) =>
|
|
1140
|
+
typeof v === 'string' && v.charCodeAt(0) !== 36 && v.includes('$');
|
|
1141
|
+
|
|
1142
|
+
/** The nested blocks a token can hide in, one level down: a state block,
|
|
1143
|
+
* whose values are style values, and `animation`, whose values are the
|
|
1144
|
+
* per-property declarations *whose* values are style values. Both are
|
|
1145
|
+
* walked by everything that looks for a `$name`, so a themed loop resolves
|
|
1146
|
+
* by the same rules as a themed hover. */
|
|
1147
|
+
const animationBlocks = (style) =>
|
|
1148
|
+
style.animation && typeof style.animation === 'object'
|
|
1149
|
+
? Object.values(style.animation).filter((e) => e && typeof e === 'object')
|
|
1150
|
+
: [];
|
|
1151
|
+
|
|
1152
|
+
export function styleUsesTokens(style) {
|
|
1153
|
+
for (const key of Object.keys(style)) {
|
|
1154
|
+
const v = style[key];
|
|
1155
|
+
if (isToken(v) || mentionsToken(v)) return true;
|
|
1156
|
+
if (key.charCodeAt(0) === 58 && v && styleUsesTokens(v)) return true;
|
|
1157
|
+
}
|
|
1158
|
+
return animationBlocks(style).some(styleUsesTokens);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Every `$name` a style mentions. Not used internally any more — a token
|
|
1162
|
+
* that does not resolve now throws, naming itself — but it is part of the
|
|
1163
|
+
* style surface `src/style.js` publishes for tooling. */
|
|
1164
|
+
export function tokenNames(style, out = new Set()) {
|
|
1165
|
+
for (const key of Object.keys(style)) {
|
|
1166
|
+
const v = style[key];
|
|
1167
|
+
if (isToken(v)) out.add(v);
|
|
1168
|
+
else if (mentionsToken(v))
|
|
1169
|
+
for (const m of v.match(TOKEN_IN_VALUE) ?? []) out.add(m);
|
|
1170
|
+
else if (key.charCodeAt(0) === 58 && v) tokenNames(v, out);
|
|
1171
|
+
}
|
|
1172
|
+
for (const block of animationBlocks(style)) tokenNames(block, out);
|
|
1173
|
+
return out;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// (style object, theme object) -> resolved style. Two hoisted styles under
|
|
1177
|
+
// one theme therefore keep their identity across renders, which is what the
|
|
1178
|
+
// `===` fast path in applyProps relies on.
|
|
1179
|
+
const resolvedCache = new WeakMap();
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Before a node is attached it has no ancestors and so no theme yet — one
|
|
1183
|
+
* commit tick, but long enough for yoga to be handed a `'$gutter'`. Drop
|
|
1184
|
+
* the token-valued properties until the real value is known; the node
|
|
1185
|
+
* restyles on attach.
|
|
1186
|
+
*/
|
|
1187
|
+
export function stripTokens(style) {
|
|
1188
|
+
const out = {};
|
|
1189
|
+
for (const key of Object.keys(style)) {
|
|
1190
|
+
const v = style[key];
|
|
1191
|
+
// a value that *mentions* a token goes too, and whole: half a gradient
|
|
1192
|
+
// is not a gradient, and the node restyles on attach either way
|
|
1193
|
+
if (isToken(v) || mentionsToken(v)) continue;
|
|
1194
|
+
// …and so does a loop with a token at either end, whole: a declaration
|
|
1195
|
+
// missing one of its two values is not a shorter animation, it is one
|
|
1196
|
+
// that would throw for having no midpoint.
|
|
1197
|
+
if (key === 'animation' && animationBlocks(style).some(styleUsesTokens)) {
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
out[key] = key.charCodeAt(0) === 58 && v ? stripTokens(v) : v;
|
|
1201
|
+
}
|
|
1202
|
+
return out;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* `strict` says the node's ancestry is complete, so a token that does not
|
|
1207
|
+
* resolve is a mistake. While a subtree is still being built its nodes can
|
|
1208
|
+
* see only part of their ancestry — the theme two levels up does not exist
|
|
1209
|
+
* for them yet — so resolution there is provisional: unknown tokens are
|
|
1210
|
+
* dropped and the node restyles when it attaches.
|
|
1211
|
+
*/
|
|
1212
|
+
export function resolveTokens(style, theme, where = 'style', strict = true) {
|
|
1213
|
+
if (!theme) return stripTokens(style);
|
|
1214
|
+
let byTheme = strict ? resolvedCache.get(style) : null;
|
|
1215
|
+
if (strict && !byTheme) resolvedCache.set(style, (byTheme = new WeakMap()));
|
|
1216
|
+
const hit = byTheme?.get(theme);
|
|
1217
|
+
if (hit) return hit;
|
|
1218
|
+
|
|
1219
|
+
const out = {};
|
|
1220
|
+
for (const key of Object.keys(style)) {
|
|
1221
|
+
const v = style[key];
|
|
1222
|
+
if (isToken(v)) {
|
|
1223
|
+
const name = v.slice(1);
|
|
1224
|
+
if (name in theme) {
|
|
1225
|
+
out[key] = theme[name];
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
if (!strict) continue;
|
|
1229
|
+
throw new Error(
|
|
1230
|
+
`react-x11: unknown theme token "${v}" in ${where} ` +
|
|
1231
|
+
`(theme has ${Object.keys(theme).join(', ') || 'nothing'})`,
|
|
1232
|
+
);
|
|
1233
|
+
} else if (mentionsToken(v)) {
|
|
1234
|
+
let unknown = null;
|
|
1235
|
+
const substituted = v.replace(TOKEN_IN_VALUE, (token) => {
|
|
1236
|
+
const name = token.slice(1);
|
|
1237
|
+
if (name in theme) return theme[name];
|
|
1238
|
+
unknown ??= token;
|
|
1239
|
+
return token;
|
|
1240
|
+
});
|
|
1241
|
+
// Same rule as a whole-value token, one level down: unknown is a
|
|
1242
|
+
// mistake once the ancestry is complete, and provisional before that —
|
|
1243
|
+
// and a half-substituted gradient is dropped rather than painted,
|
|
1244
|
+
// since `$accent` is not a colour and the parse would fail at the
|
|
1245
|
+
// frame instead of at the style.
|
|
1246
|
+
if (!unknown) out[key] = substituted;
|
|
1247
|
+
else if (strict) {
|
|
1248
|
+
throw new Error(
|
|
1249
|
+
`react-x11: unknown theme token "${unknown}" in ${where} ${key} ` +
|
|
1250
|
+
`(theme has ${Object.keys(theme).join(', ') || 'nothing'})`,
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
} else if (key.charCodeAt(0) === 58 && v) {
|
|
1254
|
+
out[key] = resolveTokens(v, theme, `${where} ${key}`, strict);
|
|
1255
|
+
} else if (key === 'animation' && v && typeof v === 'object') {
|
|
1256
|
+
const loops = {};
|
|
1257
|
+
let incomplete = false;
|
|
1258
|
+
for (const prop of Object.keys(v)) {
|
|
1259
|
+
const entry = v[prop];
|
|
1260
|
+
if (!entry || typeof entry !== 'object') {
|
|
1261
|
+
loops[prop] = entry;
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
const resolved = resolveTokens(
|
|
1265
|
+
entry,
|
|
1266
|
+
theme,
|
|
1267
|
+
`${where} animation ${prop}`,
|
|
1268
|
+
strict,
|
|
1269
|
+
);
|
|
1270
|
+
// A provisional resolution drops what it cannot resolve, which for
|
|
1271
|
+
// an ordinary property means "not styled yet". A loop with one end
|
|
1272
|
+
// missing is not a shorter loop, so the whole declaration waits for
|
|
1273
|
+
// the ancestry to complete rather than throwing at a half of one.
|
|
1274
|
+
if (Object.keys(resolved).length !== Object.keys(entry).length) {
|
|
1275
|
+
incomplete = true;
|
|
1276
|
+
}
|
|
1277
|
+
loops[prop] = resolved;
|
|
1278
|
+
}
|
|
1279
|
+
if (!incomplete) out[key] = loops;
|
|
1280
|
+
} else {
|
|
1281
|
+
out[key] = v;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
byTheme?.set(theme, out);
|
|
1285
|
+
return out;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
export { validateStyle };
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* The style the layout is actually run from: the `flex` shorthand expanded,
|
|
1292
|
+
* and the defaults `overflow: 'scroll'` implies folded in. Both are things a
|
|
1293
|
+
* style *means* rather than things it says, and doing them here — once, on
|
|
1294
|
+
* the resolved object — is what lets everything downstream stay a flat diff
|
|
1295
|
+
* of yoga properties.
|
|
1296
|
+
*
|
|
1297
|
+
* Returns `style` itself when there is nothing to add, so identity comparisons
|
|
1298
|
+
* upstream keep meaning "the style did not change".
|
|
1299
|
+
*
|
|
1300
|
+
* The scroll-container half, property by property:
|
|
1301
|
+
*
|
|
1302
|
+
* - **`min-width/height: 0`** is the spec's own rule, not an invention: CSS
|
|
1303
|
+
* computes `min-*: auto` to `0` on a flex item whose overflow is not
|
|
1304
|
+
* `visible`. It is the one place the automatic minimum size (`Node`'s
|
|
1305
|
+
* content floors) does not apply, and the reason a viewport can be smaller
|
|
1306
|
+
* than what is inside it — which is what scrolling is.
|
|
1307
|
+
* - **`flexBasis: 0`** — what CSS's `flex: 1` means — only when the author
|
|
1308
|
+
* asked it to grow and gave it no size of its own. A flex item's base size
|
|
1309
|
+
* is its content, and a window whose scrolling pane holds more rows than
|
|
1310
|
+
* fit grew *past* the window, pushing the footer out of view however small
|
|
1311
|
+
* the window got. Zeroing the basis fixes the whole ancestor chain at once,
|
|
1312
|
+
* since the content stops counting towards any of their heights.
|
|
1313
|
+
*/
|
|
1314
|
+
export function resolveComputedStyle(style) {
|
|
1315
|
+
const scrolls = style.overflow === 'scroll';
|
|
1316
|
+
const loops = style.animation == null ? null : animationsOf(style);
|
|
1317
|
+
if (style.flex === undefined && !scrolls && !loops) return style;
|
|
1318
|
+
const out = {};
|
|
1319
|
+
if (style.flex !== undefined) {
|
|
1320
|
+
const expansion =
|
|
1321
|
+
typeof style.flex === 'number'
|
|
1322
|
+
? { flexGrow: style.flex, flexShrink: 1, flexBasis: 0 }
|
|
1323
|
+
: FLEX_SHORTHAND[style.flex];
|
|
1324
|
+
if (!expansion) {
|
|
1325
|
+
throw new Error(
|
|
1326
|
+
`react-x11: invalid flex ${JSON.stringify(style.flex)} ` +
|
|
1327
|
+
"(expected a number, 'auto' or 'none')",
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
Object.assign(out, expansion);
|
|
1331
|
+
}
|
|
1332
|
+
// after the expansion, so a longhand written beside the shorthand wins
|
|
1333
|
+
for (const key of Object.keys(style)) {
|
|
1334
|
+
if (key !== 'flex') out[key] = style[key];
|
|
1335
|
+
}
|
|
1336
|
+
if (scrolls) {
|
|
1337
|
+
if (out.minWidth === undefined) out.minWidth = 0;
|
|
1338
|
+
if (out.minHeight === undefined) out.minHeight = 0;
|
|
1339
|
+
if (
|
|
1340
|
+
out.flexBasis === undefined &&
|
|
1341
|
+
out.width === undefined &&
|
|
1342
|
+
out.height === undefined &&
|
|
1343
|
+
(out.flexGrow ?? 0) > 0
|
|
1344
|
+
) {
|
|
1345
|
+
out.flexBasis = 0;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
// A loop owns the property it animates, so `from` is what the property
|
|
1349
|
+
// *is* when nothing is running it — before the first frame, while the
|
|
1350
|
+
// window is off screen, and on a desktop that asked for less motion. Doing
|
|
1351
|
+
// it here rather than at the first tick is what makes the resting frame
|
|
1352
|
+
// and the animated one the same layout: yoga is set up from this style.
|
|
1353
|
+
if (loops) {
|
|
1354
|
+
for (const loop of loops) out[loop.prop] = loop.from;
|
|
1355
|
+
}
|
|
1356
|
+
return out;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* The style lengths that are *distances on the screen*, and therefore the
|
|
1361
|
+
* complete set the display scale multiplies (src/scale.js). Everything a
|
|
1362
|
+
* style can say that is not here is deliberately not here: `lineHeight` is
|
|
1363
|
+
* a multiplier over the font's own height, `aspectRatio` and the flex
|
|
1364
|
+
* factors are ratios, `opacity` and `zIndex` are not lengths, and
|
|
1365
|
+
* `boxShadow` is a string whose lengths are scaled where it is parsed
|
|
1366
|
+
* (src/decorations.js), because its parse is memoized on the raw string.
|
|
1367
|
+
*/
|
|
1368
|
+
export const SCALED_LENGTH_PROPS = [
|
|
1369
|
+
'width',
|
|
1370
|
+
'height',
|
|
1371
|
+
'minWidth',
|
|
1372
|
+
'minHeight',
|
|
1373
|
+
'maxWidth',
|
|
1374
|
+
'maxHeight',
|
|
1375
|
+
'flexBasis',
|
|
1376
|
+
'top',
|
|
1377
|
+
'right',
|
|
1378
|
+
'bottom',
|
|
1379
|
+
'left',
|
|
1380
|
+
'start',
|
|
1381
|
+
'end',
|
|
1382
|
+
'margin',
|
|
1383
|
+
'marginTop',
|
|
1384
|
+
'marginRight',
|
|
1385
|
+
'marginBottom',
|
|
1386
|
+
'marginLeft',
|
|
1387
|
+
'marginStart',
|
|
1388
|
+
'marginEnd',
|
|
1389
|
+
'padding',
|
|
1390
|
+
'paddingTop',
|
|
1391
|
+
'paddingRight',
|
|
1392
|
+
'paddingBottom',
|
|
1393
|
+
'paddingLeft',
|
|
1394
|
+
'paddingStart',
|
|
1395
|
+
'paddingEnd',
|
|
1396
|
+
'gap',
|
|
1397
|
+
'rowGap',
|
|
1398
|
+
'columnGap',
|
|
1399
|
+
'borderWidth',
|
|
1400
|
+
'borderTopWidth',
|
|
1401
|
+
'borderRightWidth',
|
|
1402
|
+
'borderBottomWidth',
|
|
1403
|
+
'borderLeftWidth',
|
|
1404
|
+
'borderStartWidth',
|
|
1405
|
+
'borderEndWidth',
|
|
1406
|
+
'borderRadius',
|
|
1407
|
+
'outlineWidth',
|
|
1408
|
+
'outlineOffset',
|
|
1409
|
+
'fontSize',
|
|
1410
|
+
];
|
|
1411
|
+
|
|
1412
|
+
const SCALED_LENGTHS = new Set(SCALED_LENGTH_PROPS);
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* A resolved style in logical pixels → the same style in device pixels.
|
|
1416
|
+
*
|
|
1417
|
+
* This is the whole mechanism by which the display scale reaches layout,
|
|
1418
|
+
* paint and text: it runs once, at the end of the style funnel
|
|
1419
|
+
* (`_syncStyle` → `resolveComputedStyle` → here → `_retarget`), so yoga,
|
|
1420
|
+
* every `this.style.borderRadius ?? 0` at a paint site, and the font size
|
|
1421
|
+
* the text stack shapes at are all *already* device pixels and none of them
|
|
1422
|
+
* ever multiplies again. Numbers scale; `'50%'`, `'auto'` and every other
|
|
1423
|
+
* string mean the same thing at any density and pass through; `hitSlop`
|
|
1424
|
+
* scales inside its number-or-per-side shape.
|
|
1425
|
+
*
|
|
1426
|
+
* Never mutates: `flattenStyle` hands back the app's own hoisted object
|
|
1427
|
+
* when it can, and scaling it in place would corrupt the next render.
|
|
1428
|
+
* Identity is preserved at scale 1 — the everyday case costs one compare.
|
|
1429
|
+
*/
|
|
1430
|
+
export function scaleResolvedStyle(style, scale) {
|
|
1431
|
+
if (!style || !scale || scale === 1) return style;
|
|
1432
|
+
let out = style;
|
|
1433
|
+
for (const key of SCALED_LENGTH_PROPS) {
|
|
1434
|
+
const value = style[key];
|
|
1435
|
+
if (typeof value !== 'number' || value === 0) continue;
|
|
1436
|
+
if (out === style) out = { ...style };
|
|
1437
|
+
out[key] = value * scale;
|
|
1438
|
+
}
|
|
1439
|
+
const slop = style.hitSlop;
|
|
1440
|
+
if (typeof slop === 'number') {
|
|
1441
|
+
if (out === style) out = { ...style };
|
|
1442
|
+
out.hitSlop = slop * scale;
|
|
1443
|
+
} else if (slop && typeof slop === 'object') {
|
|
1444
|
+
if (out === style) out = { ...style };
|
|
1445
|
+
out.hitSlop = {
|
|
1446
|
+
...slop,
|
|
1447
|
+
...(typeof slop.top === 'number' && { top: slop.top * scale }),
|
|
1448
|
+
...(typeof slop.right === 'number' && { right: slop.right * scale }),
|
|
1449
|
+
...(typeof slop.bottom === 'number' && { bottom: slop.bottom * scale }),
|
|
1450
|
+
...(typeof slop.left === 'number' && { left: slop.left * scale }),
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
return out;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* The renderer's own yoga config — one for the process, shared by every node
|
|
1458
|
+
* — which exists so that a **measurement** can be taken off the pixel grid.
|
|
1459
|
+
*
|
|
1460
|
+
* Yoga rounds a finished layout to whole pixels, and does it on absolute
|
|
1461
|
+
* positions, so a box 36.4 tall reads back as 36 or 37 depending on where it
|
|
1462
|
+
* landed. Summing those with exact paddings is how a min-content measurement
|
|
1463
|
+
* of 36.4 comes out as 37 — and a floor of 37 does not hold a box at the size
|
|
1464
|
+
* it already was, it *grows* it, one pixel per nesting level, all the way up
|
|
1465
|
+
* the tree. Measuring with the grid switched off is what keeps a floor a
|
|
1466
|
+
* promise not to shrink rather than an instruction to grow.
|
|
1467
|
+
*
|
|
1468
|
+
* The final layout — the one that decides where anything is actually drawn —
|
|
1469
|
+
* always runs rounded, which is what keeps a border on a whole pixel.
|
|
1470
|
+
*/
|
|
1471
|
+
let config = null;
|
|
1472
|
+
const layoutConfig = () => (config ??= Yoga.Config.create());
|
|
1473
|
+
|
|
1474
|
+
/** A yoga node in the renderer's config. */
|
|
1475
|
+
export const createLayoutNode = () => Yoga.Node.create(layoutConfig());
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Run `measure` with the pixel grid switched off, and put it back however
|
|
1479
|
+
* that goes: a throw here would otherwise leave every later layout in the
|
|
1480
|
+
* process unrounded, which is a class of blurry-by-a-half-pixel bug nothing
|
|
1481
|
+
* would connect back to this.
|
|
1482
|
+
*/
|
|
1483
|
+
export function measuringExactly(measure) {
|
|
1484
|
+
const cfg = layoutConfig();
|
|
1485
|
+
cfg.setPointScaleFactor(0);
|
|
1486
|
+
try {
|
|
1487
|
+
return measure();
|
|
1488
|
+
} finally {
|
|
1489
|
+
cfg.setPointScaleFactor(1);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/**
|
|
1494
|
+
* The yoga defaults that are not CSS's, written once per node.
|
|
1495
|
+
*
|
|
1496
|
+
* `applyLayoutStyle` only calls a setter for a property that **changed**, so
|
|
1497
|
+
* a style that never mentions `flexShrink` never reaches the `?? 1` in its
|
|
1498
|
+
* applier and yoga's own `0` would stand. A default that only exists in the
|
|
1499
|
+
* reset path is not a default; this is where it actually happens.
|
|
1500
|
+
*
|
|
1501
|
+
* The pair to it is the automatic minimum size — a flex item that may shrink
|
|
1502
|
+
* still cannot shrink below its content unless it says so. Yoga has no such
|
|
1503
|
+
* rule, so the renderer measures the floors itself; see `Node`'s content
|
|
1504
|
+
* floors in nodes.js.
|
|
1505
|
+
*/
|
|
1506
|
+
export function applyLayoutDefaults(yogaNode) {
|
|
1507
|
+
yogaNode.setFlexShrink(1);
|
|
1508
|
+
}
|
|
148
1509
|
|
|
149
1510
|
/**
|
|
150
1511
|
* Apply changed layout props to a yoga node.
|
|
@@ -166,7 +1527,12 @@ export function paintPropsChanged(props, oldProps = {}) {
|
|
|
166
1527
|
for (const key of PAINT_PROPS) {
|
|
167
1528
|
if (props[key] !== oldProps[key]) return true;
|
|
168
1529
|
}
|
|
169
|
-
|
|
1530
|
+
// `color` and `borderStyle` paint but are deliberately not in PAINT_PROPS:
|
|
1531
|
+
// that set also decides what a state block is allowed to set, and widening
|
|
1532
|
+
// it would change validation rather than just this comparison
|
|
1533
|
+
return (
|
|
1534
|
+
props.color !== oldProps.color || props.borderStyle !== oldProps.borderStyle
|
|
1535
|
+
);
|
|
170
1536
|
}
|
|
171
1537
|
|
|
172
1538
|
/** Resolved text style (TextLayout base style) from props + inherited. */
|
|
@@ -176,14 +1542,124 @@ export function textStyleFrom(props, inherited) {
|
|
|
176
1542
|
size: props.fontSize ?? inherited.size,
|
|
177
1543
|
weight: props.fontWeight ?? inherited.weight,
|
|
178
1544
|
style: props.fontStyle ?? inherited.style,
|
|
1545
|
+
// ntk's name for it is `variations`; the prop is spelled after the CSS
|
|
1546
|
+
// property, like every other name in this vocabulary
|
|
1547
|
+
variations: props.fontVariationSettings ?? inherited.variations,
|
|
1548
|
+
textRendering: props.textRendering ?? inherited.textRendering,
|
|
179
1549
|
color: props.color ?? inherited.color,
|
|
180
1550
|
};
|
|
181
1551
|
}
|
|
182
1552
|
|
|
1553
|
+
/**
|
|
1554
|
+
* The floor under `Node.inheritedTextStyle`, which is what text actually
|
|
1555
|
+
* reads: the ink, the face and the size all come from the palette in force,
|
|
1556
|
+
* and these are only what is left when there is no palette at all — a node
|
|
1557
|
+
* that has not been attached yet, or a bare `theme` prop naming neither.
|
|
1558
|
+
*
|
|
1559
|
+
* `family`, `size` and `color` therefore mirror `DefaultTheme.fontFamily`,
|
|
1560
|
+
* `.fontSize` and `.text`; the rest have no token because no theme has ever
|
|
1561
|
+
* wanted to set the weight of every label in an app at once.
|
|
1562
|
+
*/
|
|
183
1563
|
export const DEFAULT_TEXT_STYLE = {
|
|
184
1564
|
family: 'sans-serif',
|
|
185
1565
|
size: 14,
|
|
186
1566
|
weight: 'normal',
|
|
187
1567
|
style: 'normal',
|
|
1568
|
+
variations: undefined,
|
|
1569
|
+
textRendering: undefined,
|
|
188
1570
|
color: 'black',
|
|
189
1571
|
};
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* The focus ring a focusable node draws when nothing asked it to.
|
|
1575
|
+
*
|
|
1576
|
+
* WCAG 2.4.7 is not something an application should have to opt into, and
|
|
1577
|
+
* the vocabulary alone would not have delivered it: `outlineWidth` in a
|
|
1578
|
+
* `:focus-visible` block is a thing every widget author would then have to
|
|
1579
|
+
* remember, on every focusable, forever. So this is the default and
|
|
1580
|
+
* `outlineWidth: 0` is the opt-out. A theme overrides the three values with
|
|
1581
|
+
* `focusRing`, `focusRingWidth` and `focusRingOffset`.
|
|
1582
|
+
*
|
|
1583
|
+
* The offset is what keeps it legible against a control whose own border is
|
|
1584
|
+
* already coloured — the ring is outside the box with a gap, not a second
|
|
1585
|
+
* border on it.
|
|
1586
|
+
*/
|
|
1587
|
+
export const DEFAULT_FOCUS_RING = {
|
|
1588
|
+
color: '#2980b9',
|
|
1589
|
+
width: 2,
|
|
1590
|
+
offset: 1,
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Per-side hit slop from `hitSlop: 4` or `hitSlop: { top: 4, bottom: 4 }`,
|
|
1595
|
+
* or null when there is none. Sides left out are 0, so the object form only
|
|
1596
|
+
* has to name what it grows.
|
|
1597
|
+
*/
|
|
1598
|
+
/**
|
|
1599
|
+
* Which physical side each logical edge lands on. The one function that knows
|
|
1600
|
+
* what `start` means, so a widget or a paint path never has to spell the
|
|
1601
|
+
* conditional out again.
|
|
1602
|
+
*/
|
|
1603
|
+
export const physicalSides = (direction) =>
|
|
1604
|
+
direction === 'rtl'
|
|
1605
|
+
? { start: 'right', end: 'left' }
|
|
1606
|
+
: { start: 'left', end: 'right' };
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Per-side border widths, resolved the way padding resolves: the side
|
|
1610
|
+
* property overrides the `borderWidth` shorthand, and a **logical** side
|
|
1611
|
+
* overrides the physical one — `borderStartWidth` beats `borderLeftWidth` in
|
|
1612
|
+
* LTR, the way `border-inline-start-width` beats `border-left-width` in CSS.
|
|
1613
|
+
* This is the paint-side reading of the rule yoga applies on the layout side
|
|
1614
|
+
* (EDGE_START over EDGE_LEFT over EDGE_ALL), kept in one place so the two
|
|
1615
|
+
* cannot disagree — a border that lays out one width and paints another is a
|
|
1616
|
+
* gap along the edge of the box.
|
|
1617
|
+
*
|
|
1618
|
+
* `direction` is the resolved direction of the node being painted, so this is
|
|
1619
|
+
* also where a `borderStartWidth` crosses to the other side of the box.
|
|
1620
|
+
*/
|
|
1621
|
+
export function resolveBorderWidths(style, direction) {
|
|
1622
|
+
const all = style.borderWidth ?? 0;
|
|
1623
|
+
const { start, end } = physicalSides(direction);
|
|
1624
|
+
const sides = {
|
|
1625
|
+
top: style.borderTopWidth ?? all,
|
|
1626
|
+
right: style.borderRightWidth ?? all,
|
|
1627
|
+
bottom: style.borderBottomWidth ?? all,
|
|
1628
|
+
left: style.borderLeftWidth ?? all,
|
|
1629
|
+
};
|
|
1630
|
+
if (style.borderStartWidth !== undefined)
|
|
1631
|
+
sides[start] = style.borderStartWidth;
|
|
1632
|
+
if (style.borderEndWidth !== undefined) sides[end] = style.borderEndWidth;
|
|
1633
|
+
return sides;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/** …and the same rule for the colours those widths are stroked in. */
|
|
1637
|
+
export function resolveBorderColors(style, direction) {
|
|
1638
|
+
const all = style.borderColor;
|
|
1639
|
+
const { start, end } = physicalSides(direction);
|
|
1640
|
+
const sides = {
|
|
1641
|
+
top: style.borderTopColor ?? all,
|
|
1642
|
+
right: style.borderRightColor ?? all,
|
|
1643
|
+
bottom: style.borderBottomColor ?? all,
|
|
1644
|
+
left: style.borderLeftColor ?? all,
|
|
1645
|
+
};
|
|
1646
|
+
if (style.borderStartColor !== undefined)
|
|
1647
|
+
sides[start] = style.borderStartColor;
|
|
1648
|
+
if (style.borderEndColor !== undefined) sides[end] = style.borderEndColor;
|
|
1649
|
+
return sides;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
export function resolveHitSlop(value) {
|
|
1653
|
+
if (value == null) return null;
|
|
1654
|
+
if (typeof value === 'number') {
|
|
1655
|
+
if (!(value > 0)) return null;
|
|
1656
|
+
return { top: value, right: value, bottom: value, left: value };
|
|
1657
|
+
}
|
|
1658
|
+
const slop = {
|
|
1659
|
+
top: value.top ?? 0,
|
|
1660
|
+
right: value.right ?? 0,
|
|
1661
|
+
bottom: value.bottom ?? 0,
|
|
1662
|
+
left: value.left ?? 0,
|
|
1663
|
+
};
|
|
1664
|
+
return slop.top || slop.right || slop.bottom || slop.left ? slop : null;
|
|
1665
|
+
}
|