@jxsuite/ui 0.0.0 → 0.1.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 (105) hide show
  1. package/components/jx-accordion-item.json +281 -0
  2. package/components/jx-accordion.json +33 -0
  3. package/components/jx-action-button.json +261 -33
  4. package/components/jx-action-group.json +123 -0
  5. package/components/jx-button.json +133 -25
  6. package/components/jx-checkbox.json +215 -0
  7. package/components/jx-color-area.json +263 -0
  8. package/components/jx-color-field.json +565 -0
  9. package/components/jx-color-slider.json +216 -0
  10. package/components/jx-combobox.json +544 -0
  11. package/components/jx-dialog.json +416 -0
  12. package/components/jx-divider.json +52 -0
  13. package/components/jx-dot.json +38 -0
  14. package/components/jx-field.json +162 -0
  15. package/components/jx-kbd.json +24 -0
  16. package/components/jx-listbox.json +76 -0
  17. package/components/jx-menu-item.json +5 -12
  18. package/components/jx-menu.json +45 -6
  19. package/components/jx-number-field.json +441 -0
  20. package/components/jx-option.json +350 -0
  21. package/components/jx-popover.json +227 -0
  22. package/components/jx-select.json +637 -0
  23. package/components/jx-spinner.json +129 -0
  24. package/components/jx-split.json +221 -0
  25. package/components/jx-swatch-group.json +107 -0
  26. package/components/jx-swatch.json +262 -0
  27. package/components/jx-switch.json +253 -0
  28. package/components/jx-tab-panel.json +58 -0
  29. package/components/jx-tab.json +296 -0
  30. package/components/jx-tabs.json +124 -0
  31. package/components/jx-textfield.json +639 -0
  32. package/components/jx-toast-host.json +121 -0
  33. package/components/jx-toast.json +255 -0
  34. package/components/jx-toolbar.json +81 -0
  35. package/components/jx-tooltip.json +207 -0
  36. package/components/jx-tree-item.json +325 -0
  37. package/components/jx-tree.json +202 -0
  38. package/icons/list.json +4 -0
  39. package/icons/manifest.json +12 -0
  40. package/package.json +27 -5
  41. package/project.json +164 -153
  42. package/src/behaviors/action-group.ts +255 -0
  43. package/src/behaviors/color-area.ts +303 -0
  44. package/src/behaviors/color-field.ts +465 -0
  45. package/src/behaviors/color-slider.ts +154 -0
  46. package/src/behaviors/combobox.ts +474 -0
  47. package/src/behaviors/dialog.ts +137 -0
  48. package/src/behaviors/listbox.ts +118 -0
  49. package/src/behaviors/menu.ts +80 -48
  50. package/src/behaviors/number-field.ts +161 -0
  51. package/src/behaviors/popover.ts +399 -0
  52. package/src/behaviors/select.ts +154 -0
  53. package/src/behaviors/split.ts +478 -0
  54. package/src/behaviors/swatch-group.ts +297 -0
  55. package/src/behaviors/tabs.ts +319 -0
  56. package/src/behaviors/textfield.ts +164 -0
  57. package/src/behaviors/toast-host.ts +241 -0
  58. package/src/behaviors/toast.ts +261 -0
  59. package/src/behaviors/toolbar.ts +413 -0
  60. package/src/behaviors/tooltip.ts +715 -0
  61. package/src/behaviors/tree.ts +830 -0
  62. package/src/color.ts +433 -0
  63. package/src/documents.ts +91 -1
  64. package/src/index.ts +41 -1
  65. package/src/loaders.ts +47 -0
  66. package/src/testing/anchor-support.ts +28 -0
  67. package/src/testing/popover-shim.ts +39 -6
  68. package/src/theme.ts +30 -6
  69. package/stylebook/jx-accordion-item.json +195 -0
  70. package/stylebook/jx-accordion.json +220 -0
  71. package/stylebook/jx-action-button.json +191 -23
  72. package/stylebook/jx-action-group.json +335 -0
  73. package/stylebook/jx-button.json +129 -26
  74. package/stylebook/jx-checkbox.json +245 -0
  75. package/stylebook/jx-color-area.json +272 -0
  76. package/stylebook/jx-color-field.json +261 -0
  77. package/stylebook/jx-color-slider.json +220 -0
  78. package/stylebook/jx-combobox.json +332 -0
  79. package/stylebook/jx-dialog.json +186 -0
  80. package/stylebook/jx-divider.json +125 -0
  81. package/stylebook/jx-dot.json +92 -0
  82. package/stylebook/jx-field.json +313 -0
  83. package/stylebook/jx-icon.json +4 -0
  84. package/stylebook/jx-kbd.json +90 -0
  85. package/stylebook/jx-listbox.json +337 -0
  86. package/stylebook/jx-menu.json +0 -1
  87. package/stylebook/jx-number-field.json +203 -0
  88. package/stylebook/jx-option.json +285 -0
  89. package/stylebook/jx-popover.json +123 -0
  90. package/stylebook/jx-select.json +407 -0
  91. package/stylebook/jx-spinner.json +111 -0
  92. package/stylebook/jx-split.json +170 -0
  93. package/stylebook/jx-swatch-group.json +175 -0
  94. package/stylebook/jx-swatch.json +249 -0
  95. package/stylebook/jx-switch.json +240 -0
  96. package/stylebook/jx-tab-panel.json +147 -0
  97. package/stylebook/jx-tab.json +201 -0
  98. package/stylebook/jx-tabs.json +278 -0
  99. package/stylebook/jx-textfield.json +309 -0
  100. package/stylebook/jx-toast-host.json +157 -0
  101. package/stylebook/jx-toast.json +266 -0
  102. package/stylebook/jx-toolbar.json +435 -0
  103. package/stylebook/jx-tooltip.json +162 -0
  104. package/stylebook/jx-tree-item.json +258 -0
  105. package/stylebook/jx-tree.json +429 -0
package/src/color.ts ADDED
@@ -0,0 +1,433 @@
1
+ /**
2
+ * The colour maths the kit's colour elements are made of: sRGB, HSV, OKLCH, and the two WCAG
3
+ * numbers that decide whether a mark drawn on a colour can be seen.
4
+ *
5
+ * It is a MODULE rather than a document because none of it is expressible in the closed operator
6
+ * set: a cube root, an `atan2`, a piecewise transfer function and a regular expression are all
7
+ * arithmetic a `$expression` cannot say, and the elements that need them need them per keystroke.
8
+ * Nothing here touches the DOM, reads an element or holds state — it is the pure half of the colour
9
+ * family, and the three behaviour sidecars beside it are the half that may focus, measure and
10
+ * dispatch.
11
+ *
12
+ * THE MODEL EACH SPACE IS FOR.
13
+ *
14
+ * `Hsv` is the geometry of the 2D area and nothing else: `jx-color-area` is a saturation/brightness
15
+ * square under one hue, which is what that square's two axes ARE, so the element's props are its
16
+ * coordinates and no conversion happens while a pointer drags. `Rgb` is what a value is stored and
17
+ * transported as, because `#rrggbbaa` is what a colour input, a stylesheet and a project file all
18
+ * read. `Oklch` is what a colour is JUDGED in — a perceptual space, so a readout says something
19
+ * true about lightness — and it is the format `jx-color-field` writes when a host asks for one.
20
+ *
21
+ * ALPHA IS ALWAYS 0-1 and always present. A colour with no alpha is opaque, never `undefined`: an
22
+ * optional channel would make every caller write `?? 1` and one of them would forget, and the
23
+ * failure mode of forgetting is an invisible swatch.
24
+ *
25
+ * @docs extending/ui-kit
26
+ */
27
+
28
+ /** An sRGB colour. `r`, `g` and `b` are 0-255 and need not be integers; `a` is 0-1. */
29
+ export interface Rgb {
30
+ r: number;
31
+ g: number;
32
+ b: number;
33
+ a: number;
34
+ }
35
+
36
+ /** A colour as the 2D area's own coordinates: hue 0-360, saturation and value 0-100, alpha 0-1. */
37
+ export interface Hsv {
38
+ h: number;
39
+ s: number;
40
+ v: number;
41
+ a: number;
42
+ }
43
+
44
+ /** A colour in OKLCH: lightness 0-1, chroma 0 upward, hue 0-360 degrees, alpha 0-1. */
45
+ export interface Oklch {
46
+ l: number;
47
+ c: number;
48
+ h: number;
49
+ a: number;
50
+ }
51
+
52
+ /**
53
+ * `value` held inside `[min, max]`. `NaN` resolves to `min`, because every caller is converting a
54
+ * reader's text or a measured coordinate and both can be nothing at all.
55
+ *
56
+ * @param value The number to hold.
57
+ * @param min The lower bound.
58
+ * @param max The upper bound.
59
+ * @returns The bounded number.
60
+ */
61
+ export function clamp(value: number, min: number, max: number): number {
62
+ if (!Number.isFinite(value)) {
63
+ return min;
64
+ }
65
+ return value < min ? min : value > max ? max : value;
66
+ }
67
+
68
+ /**
69
+ * `value` snapped to the grid `step` describes, measured from `min` and held inside `[min, max]`.
70
+ *
71
+ * The area's pointer path is why this exists: a drag produces a continuous coordinate and the two
72
+ * ranges behind it hold a stepped one, so without the snap the thumb and the announced value
73
+ * disagree by up to a step for the whole of a drag.
74
+ *
75
+ * @param value The number to snap.
76
+ * @param step The grid, measured from `min`; a non-positive step snaps nothing.
77
+ * @param min The lower bound and the grid's origin.
78
+ * @param max The upper bound.
79
+ * @returns The snapped, bounded number.
80
+ */
81
+ export function snapToStep(value: number, step: number, min: number, max: number): number {
82
+ const held = clamp(value, min, max);
83
+ if (!Number.isFinite(step) || step <= 0) {
84
+ return held;
85
+ }
86
+ let snapped = min + Math.round((held - min) / step) * step;
87
+ /* HTML's own step algorithm, and the reason this is not a plain round: when the nearest rung is
88
+ ABOVE `max` the value falls back to the rung below it rather than to `max` itself, because
89
+ `max` need not be on the grid. 0 to 100 in fortieths rounds 100 up to 120, and a native range
90
+ holding the same three attributes reports 80. Without this the element and the two `<input
91
+ type="range">`s inside it would disagree about the value at the top of every uneven track. */
92
+ if (snapped > max) {
93
+ snapped -= step;
94
+ }
95
+ return clamp(Number(snapped.toFixed(6)), min, max);
96
+ }
97
+
98
+ /** Two hex digits for one 0-255 channel. */
99
+ function hexPair(channel: number): string {
100
+ return Math.round(clamp(channel, 0, 255))
101
+ .toString(16)
102
+ .padStart(2, "0");
103
+ }
104
+
105
+ /**
106
+ * A `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` string as a colour, or `null` when it is none of
107
+ * those.
108
+ *
109
+ * The leading `#` is optional, because a reader retyping a value into a text field drops it about
110
+ * as often as they keep it, and refusing `3b82f6` would be pedantry rather than validation.
111
+ *
112
+ * @param text The text to read.
113
+ * @returns The colour, or null.
114
+ */
115
+ export function parseHex(text: string): Rgb | null {
116
+ const digits = text.trim().replace(/^#/, "");
117
+ if (!/^[0-9a-f]+$/i.test(digits)) {
118
+ return null;
119
+ }
120
+ const wide = digits.length === 6 || digits.length === 8;
121
+ const short = digits.length === 3 || digits.length === 4;
122
+ if (!wide && !short) {
123
+ return null;
124
+ }
125
+ const size = wide ? 2 : 1;
126
+ const at = (index: number): number => {
127
+ const part = digits.slice(index * size, index * size + size);
128
+ return Number.parseInt(size === 1 ? part + part : part, 16);
129
+ };
130
+ const hasAlpha = digits.length === 4 || digits.length === 8;
131
+ return { a: hasAlpha ? at(3) / 255 : 1, b: at(2), g: at(1), r: at(0) };
132
+ }
133
+
134
+ /**
135
+ * One component of a CSS colour function, as a number. A percentage is scaled by `full`.
136
+ *
137
+ * @param token The token as written.
138
+ * @param full What 100% means for this component.
139
+ * @returns The number, or NaN when the token is not one.
140
+ */
141
+ function component(token: string, full: number): number {
142
+ /* `Number` rather than `parseFloat`, and the percent sign taken off by hand: `parseFloat` reads
143
+ as far as it understands and stops, so it turns "12abc" into 12 and a reader's typo into a
144
+ colour. `Number` refuses the whole token, which is the answer a parser owes its caller. */
145
+ if (token.endsWith("%")) {
146
+ return (Number(token.slice(0, -1)) / 100) * full;
147
+ }
148
+ return Number(token);
149
+ }
150
+
151
+ /** The arguments of `name(...)`, split on commas, whitespace and the alpha solidus. */
152
+ function argsOf(text: string, name: string): string[] | null {
153
+ const match = new RegExp(`^${name}a?\\(([^)]*)\\)$`, "i").exec(text.trim());
154
+ if (!match) {
155
+ return null;
156
+ }
157
+ return match[1]!
158
+ .trim()
159
+ .split(/[\s,/]+/)
160
+ .filter(Boolean);
161
+ }
162
+
163
+ /**
164
+ * A colour written as hex, `rgb()`/`rgba()` or `oklch()`, or `null` when the text says none of
165
+ * those.
166
+ *
167
+ * These three and no more, deliberately. Hex is what the element stores, `rgb()` is what
168
+ * `getComputedStyle` hands back for anything a host already had, and `oklch()` is the format the
169
+ * field itself writes — so the set is "what this element can produce", which is exactly the set a
170
+ * reader can paste back into it and get the same colour. A named colour or a `color()` is a
171
+ * question for the platform, and the honest answer to "did the platform understand it" is a
172
+ * computed style, not a table copied into a kit.
173
+ *
174
+ * @param text The text to read.
175
+ * @returns The colour, or null.
176
+ */
177
+ export function parseColor(text: string): Rgb | null {
178
+ const trimmed = text.trim();
179
+ const rgb = argsOf(trimmed, "rgb");
180
+ if (rgb && rgb.length >= 3) {
181
+ const parts = [component(rgb[0]!, 255), component(rgb[1]!, 255), component(rgb[2]!, 255)];
182
+ const alpha = rgb.length > 3 ? component(rgb[3]!, 1) : 1;
183
+ if (parts.some((n) => Number.isNaN(n)) || Number.isNaN(alpha)) {
184
+ return null;
185
+ }
186
+ return {
187
+ a: clamp(alpha, 0, 1),
188
+ b: clamp(parts[2]!, 0, 255),
189
+ g: clamp(parts[1]!, 0, 255),
190
+ r: clamp(parts[0]!, 0, 255),
191
+ };
192
+ }
193
+ const lch = argsOf(trimmed, "oklch");
194
+ if (lch && lch.length >= 3) {
195
+ const l = component(lch[0]!, 1);
196
+ const c = component(lch[1]!, 0.4);
197
+ const h = Number(lch[2]!.replace(/deg$/i, ""));
198
+ const alpha = lch.length > 3 ? component(lch[3]!, 1) : 1;
199
+ if ([l, c, h, alpha].some((n) => Number.isNaN(n))) {
200
+ return null;
201
+ }
202
+ return oklchToSrgb({ a: clamp(alpha, 0, 1), c, h, l });
203
+ }
204
+ return parseHex(trimmed);
205
+ }
206
+
207
+ /**
208
+ * The `#rrggbb` or `#rrggbbaa` spelling of a colour. The alpha pair is written only when the colour
209
+ * is not opaque, so an opaque value round-trips through a native `<input type="color">` — which
210
+ * accepts the six-digit form and nothing else — without growing two digits it cannot read.
211
+ *
212
+ * @param rgb The colour.
213
+ * @returns The hex string, lowercase, with its leading `#`.
214
+ */
215
+ export function formatHex(rgb: Rgb): string {
216
+ const base = `#${hexPair(rgb.r)}${hexPair(rgb.g)}${hexPair(rgb.b)}`;
217
+ return rgb.a >= 1 ? base : `${base}${hexPair(clamp(rgb.a, 0, 1) * 255)}`;
218
+ }
219
+
220
+ /**
221
+ * A colour as the area's own coordinates.
222
+ *
223
+ * A grey has no hue to report, and this returns 0 for it rather than something arbitrary — but the
224
+ * element never asks: `jx-color-field` keeps the hue it was last given while the reader drags the
225
+ * saturation to zero, because a hue slider that snapped back to red every time a colour went grey
226
+ * would be unusable.
227
+ *
228
+ * @param rgb The colour.
229
+ * @returns Its hue, saturation, value and alpha.
230
+ */
231
+ export function rgbToHsv(rgb: Rgb): Hsv {
232
+ const r = clamp(rgb.r, 0, 255) / 255;
233
+ const g = clamp(rgb.g, 0, 255) / 255;
234
+ const b = clamp(rgb.b, 0, 255) / 255;
235
+ const max = Math.max(r, g, b);
236
+ const min = Math.min(r, g, b);
237
+ const span = max - min;
238
+ let h = 0;
239
+ if (span !== 0) {
240
+ if (max === r) {
241
+ h = 60 * (((g - b) / span) % 6);
242
+ } else if (max === g) {
243
+ h = 60 * ((b - r) / span + 2);
244
+ } else {
245
+ h = 60 * ((r - g) / span + 4);
246
+ }
247
+ }
248
+ return {
249
+ a: clamp(rgb.a, 0, 1),
250
+ h: (h + 360) % 360,
251
+ s: max === 0 ? 0 : (span / max) * 100,
252
+ v: max * 100,
253
+ };
254
+ }
255
+
256
+ /**
257
+ * The colour at a point of the area, under a hue.
258
+ *
259
+ * @param hsv The coordinates.
260
+ * @returns The sRGB colour.
261
+ */
262
+ export function hsvToRgb(hsv: Hsv): Rgb {
263
+ const h = ((hsv.h % 360) + 360) % 360;
264
+ const s = clamp(hsv.s, 0, 100) / 100;
265
+ const v = clamp(hsv.v, 0, 100) / 100;
266
+ const chroma = v * s;
267
+ const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));
268
+ const m = v - chroma;
269
+ const sector = Math.floor(h / 60) % 6;
270
+ const table: [number, number, number][] = [
271
+ [chroma, x, 0],
272
+ [x, chroma, 0],
273
+ [0, chroma, x],
274
+ [0, x, chroma],
275
+ [x, 0, chroma],
276
+ [chroma, 0, x],
277
+ ];
278
+ const [r, g, b] = table[sector]!;
279
+ return {
280
+ a: clamp(hsv.a, 0, 1),
281
+ b: (b + m) * 255,
282
+ g: (g + m) * 255,
283
+ r: (r + m) * 255,
284
+ };
285
+ }
286
+
287
+ /** One sRGB channel, 0-1, off its transfer function and onto light. */
288
+ function linearize(channel: number): number {
289
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
290
+ }
291
+
292
+ /** One linear-light channel back onto sRGB's transfer function. */
293
+ function delinearize(channel: number): number {
294
+ return channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055;
295
+ }
296
+
297
+ /**
298
+ * A colour in OKLCH: what the readout says and what a host asking for a perceptual value gets.
299
+ *
300
+ * The matrices are Björn Ottosson's, which CSS Color 4 §10 adopts verbatim, so this and a browser's
301
+ * own `oklch()` agree to within float noise.
302
+ *
303
+ * @param rgb The colour.
304
+ * @returns Its lightness, chroma, hue and alpha.
305
+ */
306
+ export function srgbToOklch(rgb: Rgb): Oklch {
307
+ const r = linearize(clamp(rgb.r, 0, 255) / 255);
308
+ const g = linearize(clamp(rgb.g, 0, 255) / 255);
309
+ const b = linearize(clamp(rgb.b, 0, 255) / 255);
310
+ const lCone = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
311
+ const mCone = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
312
+ const sCone = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
313
+ const l = 0.2104542553 * lCone + 0.793617785 * mCone - 0.0040720468 * sCone;
314
+ const a = 1.9779984951 * lCone - 2.428592205 * mCone + 0.4505937099 * sCone;
315
+ const bAxis = 0.0259040371 * lCone + 0.7827717662 * mCone - 0.808675766 * sCone;
316
+ const chroma = Math.hypot(a, bAxis);
317
+ return {
318
+ a: clamp(rgb.a, 0, 1),
319
+ c: chroma,
320
+ h: chroma < 1e-6 ? 0 : ((Math.atan2(bAxis, a) * 180) / Math.PI + 360) % 360,
321
+ l,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * An OKLCH colour as sRGB, CLAMPED into the gamut per channel.
327
+ *
328
+ * Per-channel clamping is not gamut mapping and is not pretending to be: a chroma sRGB cannot hold
329
+ * comes back as the nearest thing sRGB can draw, with its hue shifted. That is the honest answer
330
+ * for this kit — the only OKLCH values it converts are ones it computed FROM an sRGB colour a
331
+ * moment earlier, so the round trip is exact and the clamp is unreachable on every path the
332
+ * elements take. It exists for `parseColor`, where a reader may type any three numbers at all.
333
+ *
334
+ * @param color The OKLCH colour.
335
+ * @returns The sRGB colour.
336
+ */
337
+ export function oklchToSrgb(color: Oklch): Rgb {
338
+ const hue = (((color.h % 360) + 360) % 360) * (Math.PI / 180);
339
+ const a = color.c * Math.cos(hue);
340
+ const bAxis = color.c * Math.sin(hue);
341
+ const lCone = (color.l + 0.3963377774 * a + 0.2158037573 * bAxis) ** 3;
342
+ const mCone = (color.l - 0.1055613458 * a - 0.0638541728 * bAxis) ** 3;
343
+ const sCone = (color.l - 0.0894841775 * a - 1.291485548 * bAxis) ** 3;
344
+ const r = 4.0767416621 * lCone - 3.3077115913 * mCone + 0.2309699292 * sCone;
345
+ const g = -1.2684380046 * lCone + 2.6097574011 * mCone - 0.3413193965 * sCone;
346
+ const b = -0.0041960863 * lCone - 0.7034186147 * mCone + 1.707614701 * sCone;
347
+ return {
348
+ a: clamp(color.a, 0, 1),
349
+ b: clamp(delinearize(b) * 255, 0, 255),
350
+ g: clamp(delinearize(g) * 255, 0, 255),
351
+ r: clamp(delinearize(r) * 255, 0, 255),
352
+ };
353
+ }
354
+
355
+ /**
356
+ * The `oklch()` spelling of a colour, rounded to what a reader can act on: lightness as a
357
+ * percentage to one decimal, chroma to three, hue to one.
358
+ *
359
+ * @param color The OKLCH colour.
360
+ * @returns The CSS function, with its alpha only when the colour is not opaque.
361
+ */
362
+ export function formatOklch(color: Oklch): string {
363
+ const body = `${(color.l * 100).toFixed(1)}% ${color.c.toFixed(3)} ${color.h.toFixed(1)}`;
364
+ return color.a >= 1 ? `oklch(${body})` : `oklch(${body} / ${Number(color.a.toFixed(3))})`;
365
+ }
366
+
367
+ /**
368
+ * WCAG 2.2's relative luminance, which is the sRGB definition and not OKLCH's lightness. The two
369
+ * disagree, and the contrast criteria are written against this one.
370
+ *
371
+ * @param rgb The colour.
372
+ * @returns Its relative luminance, 0 to 1.
373
+ */
374
+ export function relativeLuminance(rgb: Rgb): number {
375
+ const r = linearize(clamp(rgb.r, 0, 255) / 255);
376
+ const g = linearize(clamp(rgb.g, 0, 255) / 255);
377
+ const b = linearize(clamp(rgb.b, 0, 255) / 255);
378
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
379
+ }
380
+
381
+ /**
382
+ * The contrast ratio between two colours, 1 to 21, as SC 1.4.3 and 1.4.11 define it.
383
+ *
384
+ * Alpha is not composited: a ratio between two translucent colours is not defined without knowing
385
+ * what is behind them, and the kit's one caller passes opaque colours.
386
+ *
387
+ * @param one A colour.
388
+ * @param two The other colour.
389
+ * @returns The ratio, with the lighter colour on top whichever order they arrived in.
390
+ */
391
+ export function contrastRatio(one: Rgb, two: Rgb): number {
392
+ const a = relativeLuminance(one);
393
+ const b = relativeLuminance(two);
394
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
395
+ }
396
+
397
+ /** Pure black, for the contrast comparison below. */
398
+ const BLACK: Rgb = { a: 1, b: 0, g: 0, r: 0 };
399
+ /** Pure white, likewise. */
400
+ const WHITE: Rgb = { a: 1, b: 255, g: 255, r: 255 };
401
+
402
+ /**
403
+ * Which ink a mark drawn ON this colour should use, decided by measuring both rather than by a
404
+ * luminance threshold somebody once wrote down.
405
+ *
406
+ * This is SC 1.4.11 for the one mark the kit draws over a colour it does not choose: the tick on a
407
+ * selected swatch. A fixed ink fails against half the gamut, and a threshold is a guess at the
408
+ * answer this computes.
409
+ *
410
+ * @param rgb The colour the mark is drawn on.
411
+ * @returns `"black"` or `"white"`, whichever has the greater ratio against it.
412
+ */
413
+ export function preferredInk(rgb: Rgb): "black" | "white" {
414
+ return contrastRatio(rgb, BLACK) >= contrastRatio(rgb, WHITE) ? "black" : "white";
415
+ }
416
+
417
+ /**
418
+ * The ink a mark or a boundary drawn ON a colour should use, as a word a document can put straight
419
+ * into a custom property, or `""` when the colour is not one this module can read.
420
+ *
421
+ * This is the `$src` door onto {@link preferredInk}: `jx-swatch` reaches it from its own document,
422
+ * so a swatch works out its own tick and its own ring from its own `color` with no host, no group
423
+ * and no sidecar writing into it. The empty answer is deliberate and is not a failure — a swatch
424
+ * coloured `var(--jx-accent)` or `rebeccapurple` is a colour only the browser can resolve, and the
425
+ * element falls back to the kit's border token rather than guessing at black.
426
+ *
427
+ * @param color The colour, as it was written.
428
+ * @returns `"black"`, `"white"`, or `""`.
429
+ */
430
+ export function inkOn(color: unknown): string {
431
+ const parsed = typeof color === "string" ? parseColor(color) : null;
432
+ return parsed ? preferredInk({ ...parsed, a: 1 }) : "";
433
+ }
package/src/documents.ts CHANGED
@@ -3,21 +3,111 @@
3
3
  * every element is one of these files, interpreted by the runtime, and the canvas edits the same
4
4
  * bytes the shell runs.
5
5
  */
6
- import type { JxDocument } from "@jxsuite/schema/types";
6
+ import { overlayScopeFor } from "@jxsuite/schema/overlays";
7
+ import type { JxDocument, JxElement } from "@jxsuite/schema/types";
8
+ import jxAccordion from "../components/jx-accordion.json";
9
+ import jxAccordionItem from "../components/jx-accordion-item.json";
7
10
  import jxActionButton from "../components/jx-action-button.json";
11
+ import jxActionGroup from "../components/jx-action-group.json";
8
12
  import jxButton from "../components/jx-button.json";
13
+ import jxCheckbox from "../components/jx-checkbox.json";
14
+ import jxColorArea from "../components/jx-color-area.json";
15
+ import jxColorField from "../components/jx-color-field.json";
16
+ import jxColorSlider from "../components/jx-color-slider.json";
17
+ import jxDialog from "../components/jx-dialog.json";
18
+ import jxDivider from "../components/jx-divider.json";
19
+ import jxDot from "../components/jx-dot.json";
20
+ import jxField from "../components/jx-field.json";
21
+ import jxKbd from "../components/jx-kbd.json";
9
22
  import jxIcon from "../components/jx-icon.json";
10
23
  import jxMenu from "../components/jx-menu.json";
11
24
  import jxMenuItem from "../components/jx-menu-item.json";
25
+ import jxPopover from "../components/jx-popover.json";
26
+ import jxCombobox from "../components/jx-combobox.json";
27
+ import jxListbox from "../components/jx-listbox.json";
28
+ import jxOption from "../components/jx-option.json";
29
+ import jxSelect from "../components/jx-select.json";
30
+ import jxSpinner from "../components/jx-spinner.json";
31
+ import jxSplit from "../components/jx-split.json";
32
+ import jxTooltip from "../components/jx-tooltip.json";
33
+ import jxNumberField from "../components/jx-number-field.json";
34
+ import jxSwatch from "../components/jx-swatch.json";
35
+ import jxSwatchGroup from "../components/jx-swatch-group.json";
36
+ import jxSwitch from "../components/jx-switch.json";
37
+ import jxTab from "../components/jx-tab.json";
38
+ import jxTabPanel from "../components/jx-tab-panel.json";
39
+ import jxTabs from "../components/jx-tabs.json";
40
+ import jxTextfield from "../components/jx-textfield.json";
41
+ import jxToast from "../components/jx-toast.json";
42
+ import jxTree from "../components/jx-tree.json";
43
+ import jxTreeItem from "../components/jx-tree-item.json";
44
+ import jxToastHost from "../components/jx-toast-host.json";
45
+ import jxToolbar from "../components/jx-toolbar.json";
12
46
 
13
47
  /** Tag name → document, in registration order (a document's `$elements` come before it). */
14
48
  export const documents: Readonly<Record<string, JxDocument>> = {
15
49
  "jx-icon": jxIcon as unknown as JxDocument,
50
+ "jx-spinner": jxSpinner as unknown as JxDocument,
51
+ // Before the buttons: each renders a `jx-tooltip` for its `hint`.
52
+ "jx-tooltip": jxTooltip as unknown as JxDocument,
16
53
  "jx-button": jxButton as unknown as JxDocument,
17
54
  "jx-action-button": jxActionButton as unknown as JxDocument,
18
55
  "jx-menu-item": jxMenuItem as unknown as JxDocument,
19
56
  "jx-menu": jxMenu as unknown as JxDocument,
57
+ "jx-popover": jxPopover as unknown as JxDocument,
58
+ "jx-textfield": jxTextfield as unknown as JxDocument,
59
+ "jx-checkbox": jxCheckbox as unknown as JxDocument,
60
+ "jx-switch": jxSwitch as unknown as JxDocument,
61
+ "jx-number-field": jxNumberField as unknown as JxDocument,
62
+ "jx-select": jxSelect as unknown as JxDocument,
63
+ "jx-option": jxOption as unknown as JxDocument,
64
+ "jx-listbox": jxListbox as unknown as JxDocument,
65
+ "jx-combobox": jxCombobox as unknown as JxDocument,
66
+ "jx-action-group": jxActionGroup as unknown as JxDocument,
67
+ "jx-toolbar": jxToolbar as unknown as JxDocument,
68
+ "jx-tab": jxTab as unknown as JxDocument,
69
+ "jx-tab-panel": jxTabPanel as unknown as JxDocument,
70
+ "jx-tabs": jxTabs as unknown as JxDocument,
71
+ "jx-tree-item": jxTreeItem as unknown as JxDocument,
72
+ "jx-tree": jxTree as unknown as JxDocument,
73
+ "jx-accordion-item": jxAccordionItem as unknown as JxDocument,
74
+ "jx-accordion": jxAccordion as unknown as JxDocument,
75
+ "jx-divider": jxDivider as unknown as JxDocument,
76
+ "jx-split": jxSplit as unknown as JxDocument,
77
+ "jx-kbd": jxKbd as unknown as JxDocument,
78
+ "jx-dot": jxDot as unknown as JxDocument,
79
+ "jx-field": jxField as unknown as JxDocument,
80
+ "jx-dialog": jxDialog as unknown as JxDocument,
81
+ "jx-toast": jxToast as unknown as JxDocument,
82
+ "jx-toast-host": jxToastHost as unknown as JxDocument,
83
+ "jx-color-area": jxColorArea as unknown as JxDocument,
84
+ "jx-color-slider": jxColorSlider as unknown as JxDocument,
85
+ "jx-swatch": jxSwatch as unknown as JxDocument,
86
+ "jx-swatch-group": jxSwatchGroup as unknown as JxDocument,
87
+ "jx-color-field": jxColorField as unknown as JxDocument,
20
88
  };
21
89
 
22
90
  /** Every tag the kit defines. */
23
91
  export const KIT_TAGS: readonly string[] = Object.keys(documents);
92
+
93
+ /**
94
+ * The tags whose DEFINITION declares `popover` on its root, and the tags that forward the invoker
95
+ * attributes to a native `<button>` inside themselves.
96
+ *
97
+ * A consumer writes `<jx-menu id="m1">` and the `popover` attribute is nowhere in their document,
98
+ * so every structural rule in `@jxsuite/schema/overlays` read it as an ordinary element: a command
99
+ * aimed at it was a target mismatch, and a host asking "does this document have a popover?" was
100
+ * told no. The mirror image is `popovertarget`, which HTML gives only to `<button>` and `<input>`,
101
+ * so the lints refused it on a kit button that observes it and passes it to its own control.
102
+ *
103
+ * DERIVED from the documents by the schema's own `overlayScopeFor`, not listed here: the compiler
104
+ * asks the same question of a project's `*.class.json` definitions, and two derivations of "what
105
+ * makes a tag a popover" is two chances to disagree about one project.
106
+ */
107
+ const KIT_SCOPE = overlayScopeFor(Object.values(documents) as JxElement[]);
108
+
109
+ /** Tags whose definition declares `popover`. */
110
+ export const POPOVER_TAGS: ReadonlySet<string> = KIT_SCOPE.popoverTags ?? new Set();
111
+
112
+ /** Tags that forward `popovertarget` and `commandfor` to a native button inside themselves. */
113
+ export const INVOKER_TAGS: ReadonlySet<string> = KIT_SCOPE.invokerTags ?? new Set();
package/src/index.ts CHANGED
@@ -10,12 +10,32 @@
10
10
  * @docs extending/ui-kit
11
11
  */
12
12
  import { defineElement, preloadDocument, preloadModule } from "@jxsuite/runtime";
13
+ import * as color from "./color.ts";
14
+ import * as colorArea from "./behaviors/color-area.ts";
15
+ import * as colorField from "./behaviors/color-field.ts";
16
+ import * as colorSlider from "./behaviors/color-slider.ts";
17
+ import * as combobox from "./behaviors/combobox.ts";
18
+ import * as listbox from "./behaviors/listbox.ts";
19
+ import * as dialog from "./behaviors/dialog.ts";
13
20
  import * as menu from "./behaviors/menu.ts";
21
+ import * as numberField from "./behaviors/number-field.ts";
22
+ import * as actionGroup from "./behaviors/action-group.ts";
23
+ import * as popover from "./behaviors/popover.ts";
24
+ import * as select from "./behaviors/select.ts";
25
+ import * as split from "./behaviors/split.ts";
26
+ import * as swatchGroup from "./behaviors/swatch-group.ts";
27
+ import * as tabs from "./behaviors/tabs.ts";
28
+ import * as toolbar from "./behaviors/toolbar.ts";
29
+ import * as tree from "./behaviors/tree.ts";
30
+ import * as tooltip from "./behaviors/tooltip.ts";
31
+ import * as textfield from "./behaviors/textfield.ts";
32
+ import * as toast from "./behaviors/toast.ts";
33
+ import * as toastHost from "./behaviors/toast-host.ts";
14
34
  import { documents } from "./documents.ts";
15
35
  import * as icons from "./icons.ts";
16
36
  import { installTheme } from "./theme.ts";
17
37
 
18
- export { documents, KIT_TAGS } from "./documents.ts";
38
+ export { documents, INVOKER_TAGS, KIT_TAGS, POPOVER_TAGS } from "./documents.ts";
19
39
  export { hasIcon, ICON_NAMES, ICON_WEIGHTS, iconPath, iconViewBox } from "./icons.ts";
20
40
  export type { IconWeight } from "./icons.ts";
21
41
  export {
@@ -32,7 +52,27 @@ export const KIT_BASE = "jx-ui:/components/";
32
52
 
33
53
  /** Behaviour modules, under the `$src` specifiers the documents use. */
34
54
  export const KIT_MODULES: Readonly<Record<string, Record<string, unknown>>> = {
55
+ "jx-ui:/behaviors/color-area.ts": colorArea,
56
+ "jx-ui:/behaviors/color-field.ts": colorField,
57
+ "jx-ui:/behaviors/color-slider.ts": colorSlider,
58
+ "jx-ui:/behaviors/combobox.ts": combobox,
59
+ "jx-ui:/behaviors/dialog.ts": dialog,
60
+ "jx-ui:/behaviors/listbox.ts": listbox,
35
61
  "jx-ui:/behaviors/menu.ts": menu,
62
+ "jx-ui:/behaviors/number-field.ts": numberField,
63
+ "jx-ui:/behaviors/action-group.ts": actionGroup,
64
+ "jx-ui:/behaviors/popover.ts": popover,
65
+ "jx-ui:/behaviors/select.ts": select,
66
+ "jx-ui:/behaviors/split.ts": split,
67
+ "jx-ui:/behaviors/swatch-group.ts": swatchGroup,
68
+ "jx-ui:/behaviors/tabs.ts": tabs,
69
+ "jx-ui:/behaviors/toolbar.ts": toolbar,
70
+ "jx-ui:/behaviors/tree.ts": tree,
71
+ "jx-ui:/behaviors/tooltip.ts": tooltip,
72
+ "jx-ui:/behaviors/textfield.ts": textfield,
73
+ "jx-ui:/behaviors/toast.ts": toast,
74
+ "jx-ui:/behaviors/toast-host.ts": toastHost,
75
+ "jx-ui:/color.ts": color,
36
76
  "jx-ui:/icons.ts": icons,
37
77
  };
38
78
 
package/src/loaders.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The kit's `$src` modules as LOADERS: the same table as `KIT_MODULES`, under the same specifiers,
3
+ * with every entry a dynamic import rather than a namespace.
4
+ *
5
+ * A host that registers the kit's elements needs the behaviours at once, because the elements are
6
+ * about to render — that is `registerUi()` and `KIT_MODULES`. A host that only MIGHT render a kit
7
+ * element, from documents it does not know in advance, should not pay for twenty sidecars on the
8
+ * chance: Studio's canvas frame is that host. It renders whatever project is open, and a project
9
+ * that uses a kit element names the element's document by URL and the document names its sidecar by
10
+ * `jx-ui:` specifier; this table is what lets the frame answer that specifier when it comes, and
11
+ * not before. Each entry is its own chunk under a bundler that splits on `import()`, so the frame's
12
+ * entry grows by a table of twenty thunks and nothing else (embedding.md §6).
13
+ *
14
+ * `loaders.test.ts` holds the two tables to one key set, so a behaviour added to one cannot be
15
+ * forgotten in the other.
16
+ *
17
+ * @docs extending/ui-kit
18
+ */
19
+
20
+ /** A module the runtime imports on first use, keyed as the kit's documents spell their `$src`. */
21
+ export type KitModuleLoader = () => Promise<Record<string, unknown>>;
22
+
23
+ /** Behaviour modules as loaders, under the `$src` specifiers the documents use. */
24
+ export const KIT_LOADERS: Readonly<Record<string, KitModuleLoader>> = {
25
+ "jx-ui:/behaviors/color-area.ts": () => import("./behaviors/color-area.ts"),
26
+ "jx-ui:/behaviors/color-field.ts": () => import("./behaviors/color-field.ts"),
27
+ "jx-ui:/behaviors/color-slider.ts": () => import("./behaviors/color-slider.ts"),
28
+ "jx-ui:/behaviors/combobox.ts": () => import("./behaviors/combobox.ts"),
29
+ "jx-ui:/behaviors/dialog.ts": () => import("./behaviors/dialog.ts"),
30
+ "jx-ui:/behaviors/listbox.ts": () => import("./behaviors/listbox.ts"),
31
+ "jx-ui:/behaviors/menu.ts": () => import("./behaviors/menu.ts"),
32
+ "jx-ui:/behaviors/number-field.ts": () => import("./behaviors/number-field.ts"),
33
+ "jx-ui:/behaviors/action-group.ts": () => import("./behaviors/action-group.ts"),
34
+ "jx-ui:/behaviors/popover.ts": () => import("./behaviors/popover.ts"),
35
+ "jx-ui:/behaviors/select.ts": () => import("./behaviors/select.ts"),
36
+ "jx-ui:/behaviors/split.ts": () => import("./behaviors/split.ts"),
37
+ "jx-ui:/behaviors/swatch-group.ts": () => import("./behaviors/swatch-group.ts"),
38
+ "jx-ui:/behaviors/tabs.ts": () => import("./behaviors/tabs.ts"),
39
+ "jx-ui:/behaviors/toolbar.ts": () => import("./behaviors/toolbar.ts"),
40
+ "jx-ui:/behaviors/tree.ts": () => import("./behaviors/tree.ts"),
41
+ "jx-ui:/behaviors/tooltip.ts": () => import("./behaviors/tooltip.ts"),
42
+ "jx-ui:/behaviors/textfield.ts": () => import("./behaviors/textfield.ts"),
43
+ "jx-ui:/behaviors/toast.ts": () => import("./behaviors/toast.ts"),
44
+ "jx-ui:/behaviors/toast-host.ts": () => import("./behaviors/toast-host.ts"),
45
+ "jx-ui:/color.ts": () => import("./color.ts"),
46
+ "jx-ui:/icons.ts": () => import("./icons.ts"),
47
+ };