@jxsuite/ui 0.0.0 → 0.2.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 +268 -34
  4. package/components/jx-action-group.json +131 -0
  5. package/components/jx-button.json +141 -26
  6. package/components/jx-checkbox.json +222 -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 +551 -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 +6 -13
  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 +331 -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 +645 -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 +719 -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
@@ -0,0 +1,465 @@
1
+ /**
2
+ * `jx-color-field`'s behaviour sidecar: the one place a colour is taken apart and put back
3
+ * together, and the one listener the whole composed control speaks through.
4
+ *
5
+ * The element is five controls around one value — a swatch that opens the picker, a text field, a
6
+ * saturation/brightness square, a hue track, an alpha track, and the two doors to the system's own
7
+ * picker — and every one of them is a real control that fires real events. Two things follow, and
8
+ * they are the whole design.
9
+ *
10
+ * ONE VALUE, TWO REPRESENTATIONS, AND A GUARD BETWEEN THEM. What the element HOLDS is a string a
11
+ * stylesheet can read; what its picker MOVES is four numbers. Neither can be derived from the other
12
+ * for free: a grey has no hue, so a round trip through the string would send the hue slider back to
13
+ * red every time the reader dragged the saturation to zero. So `value` is the truth, the channels
14
+ * are a working copy, and {@link syncFromValue} rewrites the copy only when the two genuinely
15
+ * disagree — compared as hex, which is the one spelling both sides can be reduced to. That guard is
16
+ * also what stops the loop: composing writes `value`, the write moves `data-value`, the observer
17
+ * calls the sync, and the sync finds nothing to do.
18
+ *
19
+ * ONE LISTENER, AT THE ROOT. Five controls' worth of `input` and `change` would otherwise leave
20
+ * this element with `e.target` being an inner range, a text input or a native colour well depending
21
+ * on which one the reader touched — a host would have to know the element's insides to read its
22
+ * value. Instead the root listens once, works out which channel fired from the `data-channel` the
23
+ * document writes beside each `part`, stops the original, and re-dispatches the platform's own two
24
+ * names FROM THE HOST. `e.target` is always the `jx-color-field`; `e.target.value` is always the
25
+ * colour. The re-dispatch does not re-enter, because an event whose target is the host is this
26
+ * element's own and returns immediately.
27
+ *
28
+ * THE SYSTEM PICKER IS A DOOR, NEVER THE CONTROL. `<input type="color">` and `EyeDropper` are here
29
+ * to reach what the page cannot draw — the operating system's picker, and the pixels outside the
30
+ * document — and both are optional: an engine without `EyeDropper` simply has no such button, and
31
+ * the element is fully operable by keyboard with neither.
32
+ *
33
+ * @docs extending/ui-kit
34
+ */
35
+
36
+ import {
37
+ formatHex,
38
+ formatOklch,
39
+ hsvToRgb,
40
+ parseColor,
41
+ preferredInk,
42
+ rgbToHsv,
43
+ srgbToOklch,
44
+ } from "../color.ts";
45
+ import type { Rgb } from "../color.ts";
46
+ import { close, openAt } from "./popover.ts";
47
+
48
+ /** The tag this sidecar belongs to. */
49
+ const HOST = "jx-color-field";
50
+
51
+ /** The reactive scope a `jx-color-field` document hands its handlers. */
52
+ export interface ColorFieldState {
53
+ /** The colour, spelled as `format` says. */
54
+ value: string;
55
+ /**
56
+ * The colour behind `value` when `value` is a reference this page cannot resolve; empty when the
57
+ * value stands for itself. Drawn by the chip and opened on by the picker, never written.
58
+ */
59
+ resolved: string;
60
+ /** `hex` or `oklch`: which spelling {@link compose} writes. */
61
+ format: string;
62
+ /** Whether the alpha channel is offered at all. */
63
+ alpha: boolean;
64
+ /** Hue, 0-360. Kept across a colour going grey, which is why it is state and not a derivation. */
65
+ hue: number;
66
+ /** Saturation, 0-100. */
67
+ saturation: number;
68
+ /** Brightness, 0-100. */
69
+ brightness: number;
70
+ /** Opacity, 0-100, so it shares the sliders' units. */
71
+ opacity: number;
72
+ /** What the text field is showing, which is not `value` while the reader is mid-word. */
73
+ text: string;
74
+ /** Whether the text field is holding something that is not a colour. */
75
+ invalid: boolean;
76
+ /** Whether the picker is showing. */
77
+ expanded: boolean;
78
+ /** `black` or `white`: the ink a mark drawn on the current colour can be seen in. */
79
+ ink: string;
80
+ /** The current colour at full opacity, as hex: the alpha track's gradient and the system well. */
81
+ solid: string;
82
+ /** This element's own id stem, minted at mount. */
83
+ uid: string;
84
+ /** Whether the element refuses to move. */
85
+ disabled: boolean;
86
+ [key: string]: unknown;
87
+ }
88
+
89
+ /** The system's screen-colour picker, where the engine has one. */
90
+ interface EyeDropperApi {
91
+ open: () => Promise<{ sRGBHex: string }>;
92
+ }
93
+
94
+ /** How many stems have been minted, so no two instances share one. */
95
+ let minted = 0;
96
+
97
+ /** The element each scope belongs to, learned once at mount. */
98
+ const hosts = new WeakMap<object, HTMLElement>();
99
+
100
+ /**
101
+ * The `jx-color-field` an event is being handled ON, for a listener bound to the ROOT.
102
+ *
103
+ * Deliberately exact rather than a `closest`: the root's own listener must be able to tell its own
104
+ * re-dispatch from a control's event, and `currentTarget === host` is what that test is made of.
105
+ */
106
+ function hostOf(event: Event): HTMLElement | null {
107
+ const target = event.currentTarget;
108
+ return target instanceof HTMLElement && target.localName === HOST ? target : null;
109
+ }
110
+
111
+ /**
112
+ * The `jx-color-field` a listener bound to an INNER control belongs to.
113
+ *
114
+ * The swatch button and the screen-picker button are handlers on nodes inside the element, so their
115
+ * `currentTarget` is that node — and for the screen picker it is a `jx-action-button`, one custom
116
+ * element inside another. Asking the element tree is the only answer that holds for both.
117
+ */
118
+ function ownerOf(event: Event): HTMLElement | null {
119
+ const target = event.currentTarget ?? event.target;
120
+ return target instanceof Element ? target.closest<HTMLElement>(HOST) : null;
121
+ }
122
+
123
+ /** The picker panel. */
124
+ function panelOf(host: HTMLElement): HTMLElement | null {
125
+ return host.querySelector<HTMLElement>('[part="picker"]');
126
+ }
127
+
128
+ /** The colour the channels currently describe. */
129
+ function currentRgb(state: ColorFieldState): Rgb {
130
+ return hsvToRgb({
131
+ a: state.alpha === true ? Number(state.opacity) / 100 : 1,
132
+ h: Number(state.hue),
133
+ s: Number(state.saturation),
134
+ v: Number(state.brightness),
135
+ });
136
+ }
137
+
138
+ /** Say the value moved, from the HOST, in the platform's own names. */
139
+ function announce(host: HTMLElement, ...names: string[]): void {
140
+ for (const name of names) {
141
+ host.dispatchEvent(new Event(name, { bubbles: true }));
142
+ }
143
+ }
144
+
145
+ /**
146
+ * The two things every path needs whatever else it did: the opaque form of the colour, and the ink
147
+ * that can be seen on it.
148
+ *
149
+ * @param state The element's reactive scope.
150
+ * @param rgb The colour now in force.
151
+ */
152
+ function paint(state: ColorFieldState, rgb: Rgb): void {
153
+ const opaque: Rgb = { ...rgb, a: 1 };
154
+ state.solid = formatHex(opaque);
155
+ state.ink = preferredInk(opaque);
156
+ }
157
+
158
+ /**
159
+ * Write the channels out into `value`, the text field and the drawing.
160
+ *
161
+ * @param state The element's reactive scope.
162
+ */
163
+ function compose(state: ColorFieldState): void {
164
+ const rgb = currentRgb(state);
165
+ state.value = state.format === "oklch" ? formatOklch(srgbToOklch(rgb)) : formatHex(rgb);
166
+ state.text = state.value;
167
+ state.invalid = false;
168
+ paint(state, rgb);
169
+ }
170
+
171
+ /**
172
+ * Bring the working channels back into agreement with `value`, and only when they disagree.
173
+ *
174
+ * The hue is kept when the parsed colour has none of its own — a grey, or black — because the hue
175
+ * slider is where the reader left it and a colour with no chroma says nothing about where that
176
+ * was.
177
+ *
178
+ * @param state The element's reactive scope.
179
+ */
180
+ export function syncFromValue(state: ColorFieldState): void {
181
+ /*
182
+ * A VALUE THE ELEMENT CANNOT DECOMPOSE IS NOT A REFUSAL.
183
+ *
184
+ * `invalid` means "what you typed is not a colour", and this path is not the reader typing: it
185
+ * is the value the host holds. `var(--brand-accent)`, a named colour, a `color-mix()` — each is
186
+ * a perfectly good value that only the browser can resolve, and each is what a project's own
187
+ * palette actually puts in a style. The element keeps it verbatim, the text field shows it, and
188
+ * the preview chip still DRAWS it, because the chip's background is that string handed to CSS
189
+ * rather than anything computed here.
190
+ *
191
+ * Unless the host has said what the reference stands for. `resolved` is the colour behind a
192
+ * token the page cannot see — Studio edits a document whose `--color-*` live in ANOTHER page,
193
+ * so `var(--color-accent)` handed to this page's CSS is nothing — and when it parses, the
194
+ * channels are seeded from it: the chip draws it through `preview`, and the picker opens on the
195
+ * token's own colour, so the first drag starts from there rather than from wherever the
196
+ * sliders last were. The text field still shows the reference, because that is the value.
197
+ *
198
+ * With neither, only the picker's channels are stale, and the first thing the reader moves
199
+ * replaces the token with a literal, which is what moving a picker means. Either way the
200
+ * refusal is cleared, because a host write is an answer to whatever the reader last got wrong,
201
+ * and the text field is moved onto the value: it is the truth even when it is a truth this
202
+ * module cannot read.
203
+ */
204
+ const parsed = parseColor(String(state.value ?? "")) ?? parseColor(String(state.resolved ?? ""));
205
+ state.invalid = false;
206
+ state.text = String(state.value ?? "");
207
+ if (!parsed) {
208
+ return;
209
+ }
210
+ /* The drawing is refreshed even when the channels are already right, because this is also the
211
+ first thing that ever runs: a field mounted with a value has no `solid` and no `ink` yet, and
212
+ an early return before this left the alpha track fading from nothing. */
213
+ paint(state, parsed);
214
+ if (formatHex(parsed) === formatHex(currentRgb(state))) {
215
+ return;
216
+ }
217
+ const hsv = rgbToHsv(parsed);
218
+ state.saturation = Number(hsv.s.toFixed(2));
219
+ state.brightness = Number(hsv.v.toFixed(2));
220
+ state.opacity = Number((hsv.a * 100).toFixed(2));
221
+ if (hsv.s > 0 && hsv.v > 0) {
222
+ state.hue = Number(hsv.h.toFixed(2));
223
+ }
224
+ }
225
+
226
+ /**
227
+ * The routing attribute, and it is NOT `data-channel`.
228
+ *
229
+ * `jx-color-slider` writes `data-channel` on its own root from its own `channel` prop, and an
230
+ * element's own root attributes win over the ones an instance is given — so the alpha track the
231
+ * field labelled `opacity` arrived here calling itself `alpha`. A name in the kit's own space
232
+ * cannot be taken by a child element that knows nothing about being composed.
233
+ */
234
+ const CHANNEL = "[data-jx-channel]";
235
+
236
+ /**
237
+ * The node the field is routing for: the nearest thing between the event's target and the field
238
+ * that the DOCUMENT has named a channel.
239
+ *
240
+ * A `data-` attribute rather than a `part`: the text field's event target is the `<input
241
+ * part="input">` INSIDE `jx-textfield`, so the nearest part is not the channel, and asking for the
242
+ * channel by name is what makes the answer the same for a control the element owns, a control one
243
+ * of its children owns, and a palette a consumer slotted in.
244
+ */
245
+ function sourceOf(event: Event): (HTMLElement & Record<string, unknown>) | null {
246
+ const { target } = event;
247
+ return target instanceof Element
248
+ ? target.closest<HTMLElement & Record<string, unknown>>(CHANNEL)
249
+ : null;
250
+ }
251
+
252
+ /**
253
+ * The root's `input` and `change`, for every control inside the element.
254
+ *
255
+ * @param state The element's reactive scope.
256
+ * @param event The event from whichever control the reader touched.
257
+ * @param name Which of the platform's two names this is.
258
+ */
259
+ function route(state: ColorFieldState, event: Event, name: "input" | "change"): void {
260
+ const host = hostOf(event);
261
+ if (!host || event.target === host) {
262
+ /* Our own re-dispatch, arriving at the listener that made it. */
263
+ return;
264
+ }
265
+ const source = sourceOf(event);
266
+ const channel = source?.dataset["jxChannel"];
267
+ if (!source || !channel) {
268
+ /* Something inside the element that the element does not own — anything a consumer slotted in
269
+ that is not a palette. It is left to bubble as itself rather than re-said as a colour: an
270
+ element may re-say what it routes and nothing else. */
271
+ return;
272
+ }
273
+ event.stopPropagation();
274
+ if (channel === "text") {
275
+ const typed = String((event.target as HTMLInputElement).value ?? "");
276
+ state.text = typed;
277
+ const parsed = parseColor(typed);
278
+ if (name === "input") {
279
+ /* Half a hex is not a colour and not an error either: the refusal waits for the commit. */
280
+ state.invalid = false;
281
+ return;
282
+ }
283
+ if (!parsed) {
284
+ state.invalid = typed !== "";
285
+ return;
286
+ }
287
+ applyRgb(state, parsed);
288
+ compose(state);
289
+ announce(host, "input", "change");
290
+ return;
291
+ }
292
+ if (channel === "system" || channel === "palette") {
293
+ /* The two channels that hand over a whole colour rather than one of its numbers: the system's
294
+ own well, and a palette a consumer slotted into the picker. Both are read straight off
295
+ whatever announced them, and both are opaque by construction — a colour well cannot express
296
+ an alpha and a palette entry need not — so the alpha the reader already chose is carried
297
+ across rather than reset to full by a control that has nothing to say about it. */
298
+ const parsed = parseColor(String((event.target as HTMLInputElement).value ?? ""));
299
+ if (!parsed) {
300
+ return;
301
+ }
302
+ applyRgb(state, { ...parsed, a: state.alpha === true ? Number(state.opacity) / 100 : 1 });
303
+ } else if (channel === "area") {
304
+ state.saturation = Number(source["saturation"]);
305
+ state.brightness = Number(source["brightness"]);
306
+ } else if (channel === "hue") {
307
+ state.hue = Number(source["value"]);
308
+ } else if (channel === "opacity") {
309
+ state.opacity = Number(source["value"]);
310
+ } else {
311
+ return;
312
+ }
313
+ compose(state);
314
+ announce(host, name);
315
+ }
316
+
317
+ /**
318
+ * Take a parsed colour into the working channels, keeping the hue a colourless value cannot supply.
319
+ *
320
+ * @param state The element's reactive scope.
321
+ * @param rgb The colour.
322
+ */
323
+ function applyRgb(state: ColorFieldState, rgb: Rgb): void {
324
+ const hsv = rgbToHsv(rgb);
325
+ state.saturation = Number(hsv.s.toFixed(2));
326
+ state.brightness = Number(hsv.v.toFixed(2));
327
+ if (state.alpha === true) {
328
+ state.opacity = Number((hsv.a * 100).toFixed(2));
329
+ }
330
+ if (hsv.s > 0 && hsv.v > 0) {
331
+ state.hue = Number(hsv.h.toFixed(2));
332
+ }
333
+ }
334
+
335
+ /**
336
+ * The root's `input`: a value moving under the reader's hand.
337
+ *
338
+ * @param state The element's reactive scope.
339
+ * @param event The event.
340
+ */
341
+ export function onFieldInput(state: ColorFieldState, event: Event): void {
342
+ route(state, event, "input");
343
+ }
344
+
345
+ /**
346
+ * The root's `change`: a value the reader has committed.
347
+ *
348
+ * @param state The element's reactive scope.
349
+ * @param event The event.
350
+ */
351
+ export function onFieldChange(state: ColorFieldState, event: Event): void {
352
+ route(state, event, "change");
353
+ }
354
+
355
+ /**
356
+ * The swatch button: show the picker under the field, or hide it if it is already showing.
357
+ *
358
+ * The panel is positioned from here rather than by CSS anchor positioning, which is what
359
+ * `jx-popover` asks of whoever opens it: the coordinates are written before the panel is shown, so
360
+ * the panel's own toggle handler clamps a real position into the viewport rather than the origin.
361
+ *
362
+ * @param state The element's reactive scope.
363
+ * @param event The click.
364
+ */
365
+ export function onOpenPicker(state: ColorFieldState, event: Event): void {
366
+ const host = ownerOf(event);
367
+ const panel = host ? panelOf(host) : null;
368
+ if (!host || !panel || state.disabled === true) {
369
+ return;
370
+ }
371
+ if (state.expanded === true) {
372
+ close(panel);
373
+ return;
374
+ }
375
+ const box = host.getBoundingClientRect();
376
+ (panel as HTMLElement & { x: number; y: number }).x = Math.round(box.left);
377
+ (panel as HTMLElement & { x: number; y: number }).y = Math.round(box.bottom);
378
+ openAt(panel, event.currentTarget instanceof Element ? event.currentTarget : host);
379
+ }
380
+
381
+ /**
382
+ * Whether the screen picker is both offered by the author and present in the engine.
383
+ *
384
+ * A capability the DOCUMENT asks about rather than assumes: the button is drawn only where the
385
+ * answer is yes, so an engine without an `EyeDropper` shows no dead control rather than one that
386
+ * does nothing when pressed. It takes the author's own opt-in as its argument rather than reading
387
+ * it off a scope, because a `$src` callable is handed its declared positional arguments and nothing
388
+ * else — and a DECLARED one is what makes it callable at all: a `Function` entry with no
389
+ * `parameters` is an event handler, and calling one from a template throws in the middle of the
390
+ * render, which takes the element's whole child tree with it and says nothing.
391
+ *
392
+ * @param offered The element's own `eyedropper` prop.
393
+ * @returns Whether to draw the button.
394
+ */
395
+ export function eyeDropperAvailable(offered: unknown): boolean {
396
+ return (
397
+ offered === true && typeof (globalThis as { EyeDropper?: unknown }).EyeDropper === "function"
398
+ );
399
+ }
400
+
401
+ /**
402
+ * The system's screen picker, where the engine has one.
403
+ *
404
+ * Nothing happens where it does not, and a reader who dismisses the picker has cancelled rather
405
+ * than failed: the rejection is swallowed and the colour is left where it was.
406
+ *
407
+ * @param state The element's reactive scope.
408
+ * @param event The click.
409
+ * @returns The promise, so a test can wait for it.
410
+ */
411
+ export function onEyeDropper(state: ColorFieldState, event: Event): Promise<void> {
412
+ const host = ownerOf(event);
413
+ const Api = (globalThis as { EyeDropper?: new () => EyeDropperApi }).EyeDropper;
414
+ if (!host || !Api || state.disabled === true) {
415
+ return Promise.resolve();
416
+ }
417
+ return new Api()
418
+ .open()
419
+ .then((result) => {
420
+ const parsed = parseColor(String(result?.sRGBHex ?? ""));
421
+ if (!parsed) {
422
+ return;
423
+ }
424
+ applyRgb(state, parsed);
425
+ compose(state);
426
+ announce(host, "input", "change");
427
+ })
428
+ .catch(() => {
429
+ /* The reader pressed Escape. That is an answer, not a fault. */
430
+ });
431
+ }
432
+
433
+ /**
434
+ * Learn the element, mint its id stem, and keep the working channels in step with `value` for as
435
+ * long as it lives.
436
+ *
437
+ * The observer watches `data-value` and `data-resolved` — the attributes the document binds to
438
+ * `state.value` and `state.resolved` — because a HOST write of `el.value` mutates nothing else the
439
+ * element could hear. It is the same seam `jx-select`'s sidecar uses, and for the same reason: the
440
+ * alternative is a reactive effect, which would make the kit depend on the runtime's reactivity
441
+ * library to learn something the document can simply say out loud.
442
+ *
443
+ * @param state The element's reactive scope.
444
+ * @param event The element's own `jx-ready`.
445
+ */
446
+ export function onFieldMount(state: ColorFieldState, event: Event): void {
447
+ const host = hostOf(event);
448
+ if (!host) {
449
+ return;
450
+ }
451
+ hosts.set(state, host);
452
+ minted += 1;
453
+ state.uid = `jx-color-field-${minted}`;
454
+ syncFromValue(state);
455
+ const observer = new MutationObserver(() => {
456
+ syncFromValue(state);
457
+ });
458
+ observer.observe(host, { attributeFilter: ["data-resolved", "data-value"], attributes: true });
459
+ const panel = panelOf(host);
460
+ if (panel) {
461
+ panel.addEventListener("toggle", (toggle: Event) => {
462
+ state.expanded = (toggle as Event & { newState?: string }).newState === "open";
463
+ });
464
+ }
465
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `jx-color-slider`'s behaviour sidecar: ten steps, and the family's one event contract.
3
+ *
4
+ * The element is ONE native `<input type="range">` drawn over a gradient, so almost nothing is left
5
+ * for this file. The platform owns the thumb, the drag, the along-axis arrows with their clamping
6
+ * and snapping, Home, End, PageUp, PageDown, the focus ring, `:disabled`, and every word of
7
+ * `role="slider"` with its `aria-valuemin`, `aria-valuemax` and `aria-valuenow`. Those are not
8
+ * re-implemented here, and an arrow key with no `Shift` on it returns from {@link onSliderKeydown}
9
+ * untouched so that stays true.
10
+ *
11
+ * TWO THINGS ARE THE KIT'S.
12
+ *
13
+ * `Shift`+Arrow moves ten steps, which is the same coarse gesture `jx-number-field` adds and which
14
+ * the platform has on no range. It is arithmetic through `snapToStep` rather than `stepUp`: a
15
+ * range's value is already on its own grid — the platform sanitizes it there — so the two agree
16
+ * here, and the arithmetic is the one that also runs where `stepUp` is not implemented.
17
+ *
18
+ * The inner control's own `input` and `change` are STOPPED and re-dispatched from the host, which
19
+ * is `jx-color-area`'s contract and is adopted here so the family reads one way. A host listening
20
+ * for a colour reads `e.target.value` off the ELEMENT — a number in the element's own units — and
21
+ * never has to know whether the event came from a hue track, an alpha track or a square. The cost
22
+ * is one dispatch per keystroke; the alternative is a host that must ask which node fired.
23
+ *
24
+ * @docs extending/ui-kit
25
+ */
26
+
27
+ import { snapToStep } from "../color.ts";
28
+
29
+ /** The tag this sidecar belongs to. */
30
+ const HOST = "jx-color-slider";
31
+
32
+ /** The reactive scope a `jx-color-slider` document hands its handlers. */
33
+ export interface ColorSliderState {
34
+ /** Where the thumb is, in the track's own units. */
35
+ value: number;
36
+ /** The bottom of the track's range. */
37
+ min: number;
38
+ /** The top of it, already resolved: the element computes it from `max` and `channel`. */
39
+ maxValue: number;
40
+ /** The grid the value moves on. */
41
+ step: number;
42
+ /** Whether the element refuses to move. */
43
+ disabled: boolean;
44
+ [key: string]: unknown;
45
+ }
46
+
47
+ /** Which way each arrow moves the value. */
48
+ const KEY_SIGN: Readonly<Record<string, number>> = {
49
+ ArrowDown: -1,
50
+ ArrowLeft: -1,
51
+ ArrowRight: 1,
52
+ ArrowUp: 1,
53
+ };
54
+
55
+ /** The `jx-color-slider` an event is being handled on. */
56
+ function hostOf(event: Event): HTMLElement | null {
57
+ const target = event.currentTarget;
58
+ return target instanceof HTMLElement && target.localName === HOST ? target : null;
59
+ }
60
+
61
+ /** The element's own native range. */
62
+ function controlOf(host: HTMLElement): HTMLInputElement | null {
63
+ return host.querySelector<HTMLInputElement>('input[part="input"]');
64
+ }
65
+
66
+ /** Say the value moved, from the HOST, in the platform's own names. */
67
+ function announce(host: HTMLElement, ...names: string[]): void {
68
+ for (const name of names) {
69
+ host.dispatchEvent(new Event(name, { bubbles: true }));
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The bounds and grid as numbers.
75
+ *
76
+ * The top comes from `maxValue`, which the DOCUMENT computes from `max` and `channel` — a hue track
77
+ * runs to 360 and an alpha track to 100, and neither is a number an author should have to remember
78
+ * to write. Resolving it in the document rather than here keeps one definition: the same computed
79
+ * is what the inner range's own `max` attribute is bound to, so the platform's clamp and this one
80
+ * cannot drift apart.
81
+ */
82
+ function boundsOf(state: ColorSliderState): { min: number; max: number; step: number } {
83
+ const min = Number.isFinite(Number(state.min)) ? Number(state.min) : 0;
84
+ const top = Number(state.maxValue);
85
+ const step = Number(state.step);
86
+ return {
87
+ max: Number.isFinite(top) && top > min ? top : 360,
88
+ min,
89
+ step: Number.isFinite(step) && step > 0 ? step : 1,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * The inner range's `input`: mirror it, and re-say it from the host.
95
+ *
96
+ * @param state The element's reactive scope.
97
+ * @param event The range's own input event.
98
+ */
99
+ export function onSliderInput(state: ColorSliderState, event: Event): void {
100
+ const host = hostOf(event);
101
+ if (!host || event.target === host) {
102
+ /* The listener sits on the ROOT so `currentTarget` is the element, which means it also hears
103
+ the element's own re-dispatch. That one has already been accounted for. */
104
+ return;
105
+ }
106
+ event.stopPropagation();
107
+ state.value = Number((event.target as HTMLInputElement).value);
108
+ announce(host, "input");
109
+ }
110
+
111
+ /**
112
+ * The inner range's `change`: the same stop, and the commit re-said from the host.
113
+ *
114
+ * @param state The element's reactive scope.
115
+ * @param event The range's own change event.
116
+ */
117
+ export function onSliderChange(state: ColorSliderState, event: Event): void {
118
+ const host = hostOf(event);
119
+ if (!host || event.target === host) {
120
+ return;
121
+ }
122
+ event.stopPropagation();
123
+ state.value = Number((event.target as HTMLInputElement).value);
124
+ announce(host, "change");
125
+ }
126
+
127
+ /**
128
+ * `Shift`+Arrow: ten steps, cancelled so the platform's own single step does not run on top.
129
+ *
130
+ * @param state The element's reactive scope.
131
+ * @param event The keydown.
132
+ */
133
+ export function onSliderKeydown(state: ColorSliderState, event: KeyboardEvent): void {
134
+ const host = hostOf(event);
135
+ const sign = KEY_SIGN[event.key];
136
+ if (!host || !sign || !event.shiftKey || state.disabled === true) {
137
+ return;
138
+ }
139
+ event.preventDefault();
140
+ const { max, min, step } = boundsOf(state);
141
+ const next = snapToStep(Number(state.value) + sign * step * 10, step, min, max);
142
+ if (next === Number(state.value)) {
143
+ return;
144
+ }
145
+ state.value = next;
146
+ /* The control follows through its own binding, but the binding is what a RENDER does and the
147
+ events go out now, so the control is written here too: a host that reads the range back out of
148
+ `e.target` during the handler must not see the value it had before the key. */
149
+ const control = controlOf(host);
150
+ if (control) {
151
+ control.value = String(next);
152
+ }
153
+ announce(host, "input", "change");
154
+ }