@lumiose/ui 0.0.0-stage → 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 (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/accordion/accordion.css +20 -0
  4. package/dist/accordion/accordion.script.ts +66 -0
  5. package/dist/accordion/accordion.tsx +288 -0
  6. package/dist/animation/animation.ts +34 -0
  7. package/dist/autocomplete/autocomplete.tsx +239 -0
  8. package/dist/avatar/avatar.css +54 -0
  9. package/dist/avatar/avatar.script.ts +96 -0
  10. package/dist/avatar/avatar.tsx +110 -0
  11. package/dist/button/button.script.ts +35 -0
  12. package/dist/button/button.tsx +40 -0
  13. package/dist/checkbox/checkbox.script.ts +225 -0
  14. package/dist/checkbox/checkbox.tsx +156 -0
  15. package/dist/checkbox/context.ts +17 -0
  16. package/dist/checkbox-group/checkbox-group.tsx +111 -0
  17. package/dist/collapsible/collapsible.css +12 -0
  18. package/dist/collapsible/collapsible.script.ts +7 -0
  19. package/dist/collapsible/collapsible.tsx +153 -0
  20. package/dist/collapsible/panel.ts +115 -0
  21. package/dist/combobox/combobox.css +37 -0
  22. package/dist/combobox/combobox.script.ts +974 -0
  23. package/dist/combobox/combobox.tsx +556 -0
  24. package/dist/combobox/context.ts +46 -0
  25. package/dist/context-menu/context-menu.css +16 -0
  26. package/dist/context-menu/context-menu.script.ts +172 -0
  27. package/dist/context-menu/context-menu.tsx +178 -0
  28. package/dist/dialog/dialog.css +35 -0
  29. package/dist/dialog/dialog.script.ts +269 -0
  30. package/dist/dialog/dialog.tsx +214 -0
  31. package/dist/drawer/drawer.css +38 -0
  32. package/dist/drawer/drawer.script.ts +207 -0
  33. package/dist/drawer/drawer.tsx +113 -0
  34. package/dist/field/context.ts +156 -0
  35. package/dist/field/field.css +16 -0
  36. package/dist/field/field.script.ts +422 -0
  37. package/dist/field/field.tsx +465 -0
  38. package/dist/fieldset/fieldset.tsx +41 -0
  39. package/dist/form/form.tsx +50 -0
  40. package/dist/form/native.ts +21 -0
  41. package/dist/form/reset.ts +53 -0
  42. package/dist/index.ts +261 -0
  43. package/dist/input/input.tsx +67 -0
  44. package/dist/layer/layer.ts +191 -0
  45. package/dist/locale/digits.ts +21 -0
  46. package/dist/menu/menu.css +22 -0
  47. package/dist/menu/menu.script.ts +591 -0
  48. package/dist/menu/menu.tsx +520 -0
  49. package/dist/menubar/menubar.css +16 -0
  50. package/dist/menubar/menubar.script.ts +195 -0
  51. package/dist/menubar/menubar.tsx +67 -0
  52. package/dist/meter/meter.tsx +134 -0
  53. package/dist/navigation-menu/navigation-menu.css +14 -0
  54. package/dist/navigation-menu/navigation-menu.script.ts +466 -0
  55. package/dist/navigation-menu/navigation-menu.tsx +266 -0
  56. package/dist/number-field/format.ts +70 -0
  57. package/dist/number-field/number-field.css +38 -0
  58. package/dist/number-field/number-field.script.ts +490 -0
  59. package/dist/number-field/number-field.tsx +403 -0
  60. package/dist/otp-field/otp-field.css +45 -0
  61. package/dist/otp-field/otp-field.script.ts +330 -0
  62. package/dist/otp-field/otp-field.tsx +320 -0
  63. package/dist/placement/place.ts +148 -0
  64. package/dist/placement/placement.css +155 -0
  65. package/dist/placement/placement.ts +60 -0
  66. package/dist/popover/hover.ts +178 -0
  67. package/dist/popover/popover.css +20 -0
  68. package/dist/popover/popover.script.ts +256 -0
  69. package/dist/popover/popover.tsx +317 -0
  70. package/dist/preview-card/preview-card.css +7 -0
  71. package/dist/preview-card/preview-card.script.ts +136 -0
  72. package/dist/preview-card/preview-card.tsx +128 -0
  73. package/dist/progress/progress.tsx +155 -0
  74. package/dist/progress/value.ts +23 -0
  75. package/dist/radio-group/radio-group.tsx +156 -0
  76. package/dist/scroll-area/scroll-area.css +72 -0
  77. package/dist/scroll-area/scroll-area.script.ts +281 -0
  78. package/dist/scroll-area/scroll-area.tsx +118 -0
  79. package/dist/select/select.css +25 -0
  80. package/dist/select/select.tsx +171 -0
  81. package/dist/separator/separator.css +12 -0
  82. package/dist/separator/separator.tsx +38 -0
  83. package/dist/slider/format.ts +33 -0
  84. package/dist/slider/slider.css +93 -0
  85. package/dist/slider/slider.script.ts +355 -0
  86. package/dist/slider/slider.tsx +374 -0
  87. package/dist/styles.css +30 -0
  88. package/dist/switch/switch.tsx +92 -0
  89. package/dist/tabs/tabs.css +54 -0
  90. package/dist/tabs/tabs.script.ts +225 -0
  91. package/dist/tabs/tabs.tsx +272 -0
  92. package/dist/themes/default.css +2111 -0
  93. package/dist/toast/command.ts +3 -0
  94. package/dist/toast/toast.css +68 -0
  95. package/dist/toast/toast.script.ts +516 -0
  96. package/dist/toast/toast.tsx +432 -0
  97. package/dist/toggle/context.ts +16 -0
  98. package/dist/toggle/toggle.tsx +99 -0
  99. package/dist/toggle-group/toggle-group.script.ts +166 -0
  100. package/dist/toggle-group/toggle-group.tsx +117 -0
  101. package/dist/toolbar/context.ts +18 -0
  102. package/dist/toolbar/toolbar.css +12 -0
  103. package/dist/toolbar/toolbar.script.ts +123 -0
  104. package/dist/toolbar/toolbar.tsx +180 -0
  105. package/dist/tooltip/tooltip.css +8 -0
  106. package/dist/tooltip/tooltip.script.ts +222 -0
  107. package/dist/tooltip/tooltip.tsx +149 -0
  108. package/docs/README.md +174 -0
  109. package/docs/accordion.md +208 -0
  110. package/docs/autocomplete.md +161 -0
  111. package/docs/avatar.md +198 -0
  112. package/docs/button.md +98 -0
  113. package/docs/checkbox-group.md +112 -0
  114. package/docs/checkbox.md +133 -0
  115. package/docs/collapsible.md +165 -0
  116. package/docs/combobox.md +369 -0
  117. package/docs/context-menu.md +197 -0
  118. package/docs/dialog.md +256 -0
  119. package/docs/drawer.md +204 -0
  120. package/docs/field.md +295 -0
  121. package/docs/fieldset.md +92 -0
  122. package/docs/form.md +131 -0
  123. package/docs/input.md +87 -0
  124. package/docs/installation.md +65 -0
  125. package/docs/llms-full.txt +6923 -0
  126. package/docs/llms.txt +53 -0
  127. package/docs/menu.md +365 -0
  128. package/docs/menubar.md +186 -0
  129. package/docs/meter.md +103 -0
  130. package/docs/navigation-menu.md +257 -0
  131. package/docs/number-field.md +181 -0
  132. package/docs/otp-field.md +160 -0
  133. package/docs/popover.md +271 -0
  134. package/docs/preview-card.md +130 -0
  135. package/docs/progress.md +115 -0
  136. package/docs/radio-group.md +127 -0
  137. package/docs/scroll-area.md +147 -0
  138. package/docs/select.md +236 -0
  139. package/docs/separator.md +116 -0
  140. package/docs/slider.md +184 -0
  141. package/docs/switch.md +98 -0
  142. package/docs/tabs.md +254 -0
  143. package/docs/theming.md +132 -0
  144. package/docs/toast.md +311 -0
  145. package/docs/toggle-group.md +119 -0
  146. package/docs/toggle.md +94 -0
  147. package/docs/toolbar.md +138 -0
  148. package/docs/tooltip.md +128 -0
  149. package/package.json +102 -3
@@ -0,0 +1,93 @@
1
+ /*
2
+ * The CSS Slider needs to work, and nothing else (decision-62). The Control stacks the Track and
3
+ * the thumbs (native range inputs) in one place; the Indicator spans the selected part of the
4
+ * track, from `--lu-slider-start` to `--lu-slider-end`; over a Track, the thumbs' own tracks are
5
+ * see-through. With two thumbs, only the thumbs take the pointer, so a press on the track reaches
6
+ * the control and moves the nearest thumb. A vertical slider writes its thumbs bottom to top. It
7
+ * sits in the `lu.core` layer, so any app CSS wins.
8
+ *
9
+ * `--lu-slider-thumb-size` (the thumbs' size, from your CSS or the theme; 0px by default) lines the
10
+ * track up with the thumbs' centres: by default (`"edge"`) the thumbs stay inside the control, as
11
+ * native ones do, and the track runs between their centres at the ends, so the Indicator ends at a
12
+ * thumb's centre; with `data-thumb-alignment="center"` the thumbs overhang the control by half
13
+ * their size, and their centres reach its ends.
14
+ */
15
+ @layer lu.core, lu.theme;
16
+
17
+ @layer lu.core {
18
+ lu-slider {
19
+ display: block;
20
+ }
21
+ /* The Control: the track and the thumbs in one grid cell. */
22
+ lu-slider div:has(> input[type="range"]) {
23
+ display: grid;
24
+ position: relative;
25
+ touch-action: pan-y;
26
+ }
27
+ lu-slider[data-orientation="vertical"] div:has(> input[type="range"]) {
28
+ justify-items: center;
29
+ touch-action: pan-x;
30
+ }
31
+ lu-slider div:has(> input[type="range"]) > * {
32
+ grid-area: 1 / 1;
33
+ margin: 0;
34
+ }
35
+ /*
36
+ * The thumbs over the (positioned) track, for the pointer too, spanning the control (in their own
37
+ * writing mode: a vertical thumb's inline size is its height).
38
+ */
39
+ lu-slider div:has(> input[type="range"]) > input[type="range"] {
40
+ position: relative;
41
+ inline-size: 100%;
42
+ }
43
+ lu-slider[data-thumb-alignment="center"] div:has(> input[type="range"]) > input[type="range"] {
44
+ inline-size: calc(100% + var(--lu-slider-thumb-size, 0px));
45
+ margin-inline: calc(var(--lu-slider-thumb-size, 0px) / -2);
46
+ }
47
+ /* The Track and its Indicator. */
48
+ lu-slider div:has(> input[type="range"]) > div {
49
+ position: relative;
50
+ align-self: center;
51
+ margin-inline: calc(var(--lu-slider-thumb-size, 0px) / 2);
52
+ }
53
+ lu-slider div:has(> input[type="range"]) > div > div {
54
+ position: absolute;
55
+ inset-block: 0;
56
+ inset-inline-start: var(--lu-slider-start, 0%);
57
+ inset-inline-end: calc(100% - var(--lu-slider-end, 0%));
58
+ }
59
+ lu-slider[data-orientation="vertical"] div:has(> input[type="range"]) > div {
60
+ align-self: stretch;
61
+ justify-self: center;
62
+ margin-inline: 0;
63
+ margin-block: calc(var(--lu-slider-thumb-size, 0px) / 2);
64
+ }
65
+ lu-slider[data-thumb-alignment="center"] div:has(> input[type="range"]) > div {
66
+ margin: 0;
67
+ }
68
+ lu-slider[data-orientation="vertical"] div:has(> input[type="range"]) > div > div {
69
+ inset-inline: 0;
70
+ inset-block-start: calc(100% - var(--lu-slider-end, 0%));
71
+ inset-block-end: var(--lu-slider-start, 0%);
72
+ }
73
+ /* Over a Track, the thumbs' own tracks give way to it. */
74
+ lu-slider div:has(> div) > input[type="range"] {
75
+ appearance: none;
76
+ background: transparent;
77
+ }
78
+ /* A vertical thumb runs bottom to top. */
79
+ lu-slider input[type="range"][aria-orientation="vertical"] {
80
+ writing-mode: vertical-lr;
81
+ direction: rtl;
82
+ }
83
+ /* Two thumbs: only the thumbs take the pointer; a press on the track moves the nearest one. */
84
+ lu-slider div:has(> input[type="range"] ~ input[type="range"]) > input[type="range"] {
85
+ pointer-events: none;
86
+ }
87
+ lu-slider div:has(> input[type="range"] ~ input[type="range"]) > input::-webkit-slider-thumb {
88
+ pointer-events: auto;
89
+ }
90
+ lu-slider div:has(> input[type="range"] ~ input[type="range"]) > input::-moz-range-thumb {
91
+ pointer-events: auto;
92
+ }
93
+ }
@@ -0,0 +1,355 @@
1
+ // Slider's client script (decision-62, decision-63): it defines `<lu-slider>`, the light-DOM
2
+ // element Slider.Root renders. Each thumb is a native `<input type="range">`: the platform drags
3
+ // it, moves it with the arrow keys, Home and End, follows the direction and submits its value, in
4
+ // server HTML and without JavaScript. The element adds what Base UI's Slider does beyond that
5
+ // (doc-9 section 14):
6
+ //
7
+ // - `--lu-slider-start` and `--lu-slider-end` on the element, the selected part of the track (from
8
+ // the start to the thumb, or between two thumbs), which the Indicator part spans.
9
+ // - Two thumbs or more: the values stay in order, `min-steps-between-values` steps apart, and a
10
+ // thumb pushes its neighbour (`thumb-collision-behavior="push"`, the default) or stops at it
11
+ // (`"none"`). With `"swap"`, a thumb dragged past another trades places with it: the drag goes
12
+ // on with the thumb that holds the dragged value, which takes focus (the keys stop at the other
13
+ // thumb, as with `"none"`). A native drag keeps its own thumb, so that thumb stays put and hands
14
+ // its value to the other one, which takes focus when the press ends (Firefox ends a native drag
15
+ // whose thumb loses focus). Presses on the track move the nearest thumb, and a drag goes on from
16
+ // there.
17
+ // - Page Up and Page Down, and Shift with an arrow key, move by `large-step` (default 10). The
18
+ // arrow keys are the element's too: Up and Right step up (Left right to left), Down and Left
19
+ // down, the same in every browser (WebKit's native range does not mirror them right to left,
20
+ // TASK-418). Home and End go to the ends, or to the neighbouring thumb in a range, which they
21
+ // never push (as Base UI and the APG, TASK-449 L21).
22
+ // - A move that changes no value (a thumb blocked by its neighbour with `"none"`) reports nothing:
23
+ // its `input` and `change` events stop at the element (TASK-449 L21).
24
+ // - The values as text: `aria-valuetext` on the thumbs when the element has a `format`
25
+ // (`Intl.NumberFormat` options, JSON), and the Value part (an `<output for>` the thumbs) unless
26
+ // it renders content of its own (then it has no `for`).
27
+ // - After a form reset, which moves the thumbs back without an `input` event, the track and the
28
+ // text follow them.
29
+ //
30
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
31
+
32
+ import { formatValues } from "./format.ts";
33
+
34
+ const THUMB = "input[type=range]";
35
+
36
+ const same = (a: readonly string[], b: readonly string[]) =>
37
+ a.length === b.length && a.every((value, i) => value === b[i]);
38
+
39
+ const number = (value: string | null, fallback: number) => {
40
+ const parsed = Number.parseFloat(value ?? "");
41
+ return Number.isFinite(parsed) ? parsed : fallback;
42
+ };
43
+
44
+ export class SliderElement extends HTMLElement {
45
+ /** The thumb a press on the track is dragging, until the press ends. */
46
+ #dragging: HTMLInputElement | undefined;
47
+ /** A thumb the pointer drags itself (the native drag), until the press ends. */
48
+ #pressed: HTMLInputElement | undefined;
49
+ /** After a swap, the thumb `#pressed`'s drag moves instead, and the value `#pressed` keeps. */
50
+ #proxy: { target: HTMLInputElement; value: string } | undefined;
51
+ /** The values `input` last reported (or the script set). */
52
+ #reported: string[] = [];
53
+ /** The values when the thumb the pointer drags natively was pressed, for its `change`. */
54
+ #pressedAt: string[] | undefined;
55
+
56
+ /** The element's own thumbs, in order. */
57
+ get thumbs(): HTMLInputElement[] {
58
+ return [...this.querySelectorAll<HTMLInputElement>(THUMB)].filter(
59
+ (thumb) => thumb.closest("lu-slider") === this,
60
+ );
61
+ }
62
+
63
+ /** The value, or the values with two thumbs or more. Setting it moves the thumbs, without events. */
64
+ get value(): number | number[] {
65
+ const values = this.thumbs.map((thumb) => thumb.valueAsNumber);
66
+ return values.length === 1 ? values[0]! : values;
67
+ }
68
+
69
+ set value(next: number | readonly number[] | null | undefined) {
70
+ if (next == null) return;
71
+ const values = typeof next === "number" ? [next] : next;
72
+ this.thumbs.forEach((thumb, i) => {
73
+ if (values[i] !== undefined) thumb.value = String(values[i]);
74
+ });
75
+ this.#sync();
76
+ }
77
+
78
+ connectedCallback(): void {
79
+ // Capturing: the values are in order, and a move that changed nothing stopped, before any
80
+ // listener on the element or a thumb sees the event.
81
+ this.addEventListener("input", this.#onInput, true);
82
+ this.addEventListener("change", this.#onChange, true);
83
+ this.addEventListener("keydown", this.#onKeyDown);
84
+ this.addEventListener("pointerdown", this.#onPress);
85
+ document.addEventListener("reset", this.#onReset, true);
86
+ if (document.readyState === "loading") {
87
+ document.addEventListener("DOMContentLoaded", () => this.#sync(), { once: true });
88
+ } else queueMicrotask(() => this.#sync());
89
+ }
90
+
91
+ disconnectedCallback(): void {
92
+ this.removeEventListener("input", this.#onInput, true);
93
+ this.removeEventListener("change", this.#onChange, true);
94
+ this.removeEventListener("keydown", this.#onKeyDown);
95
+ this.removeEventListener("pointerdown", this.#onPress);
96
+ document.removeEventListener("reset", this.#onReset, true);
97
+ }
98
+
99
+ #range(thumb: HTMLInputElement) {
100
+ const min = number(thumb.min, 0);
101
+ const max = number(thumb.max, 100);
102
+ const step = thumb.step === "any" ? 1 : number(thumb.step, 1);
103
+ return { min, max, step };
104
+ }
105
+
106
+ /** Keeps two thumbs or more in order and apart, moving the others (push) or `moved` (none). */
107
+ #constrain(moved: HTMLInputElement): void {
108
+ const thumbs = this.thumbs;
109
+ if (thumbs.length < 2) return;
110
+ const mode = this.getAttribute("thumb-collision-behavior");
111
+ if (mode === "swap" && (this.#pressed || this.#dragging)) return this.#swap(moved);
112
+ const i = thumbs.indexOf(moved);
113
+ const { step } = this.#range(moved);
114
+ const gap = number(this.getAttribute("min-steps-between-values"), 0) * step;
115
+ const push = mode !== "none" && mode !== "swap";
116
+ // Toward the end: each next thumb at least `gap` above the one before.
117
+ for (let j = i + 1; j < thumbs.length; j++) {
118
+ const floor = thumbs[j - 1]!.valueAsNumber + gap;
119
+ if (thumbs[j]!.valueAsNumber >= floor) break;
120
+ if (push) thumbs[j]!.value = String(floor);
121
+ if (!push || thumbs[j]!.valueAsNumber < floor) {
122
+ // It could not move (or must not): the moved thumb stops below it.
123
+ moved.value = String(thumbs[j]!.valueAsNumber - gap * (j - i));
124
+ break;
125
+ }
126
+ }
127
+ // Toward the start.
128
+ for (let j = i - 1; j >= 0; j--) {
129
+ const ceiling = thumbs[j + 1]!.valueAsNumber - gap;
130
+ if (thumbs[j]!.valueAsNumber <= ceiling) break;
131
+ if (push) thumbs[j]!.value = String(ceiling);
132
+ if (!push || thumbs[j]!.valueAsNumber > ceiling) {
133
+ moved.value = String(thumbs[j]!.valueAsNumber + gap * (i - j));
134
+ break;
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Swap: `moved`, dragged past other thumbs, trades places with them, so the values stay in order
141
+ * and the dragged value moves on with another thumb. The drag and focus follow it.
142
+ */
143
+ #swap(moved: HTMLInputElement): void {
144
+ const thumbs = this.thumbs;
145
+ const value = moved.value;
146
+ const dragged = moved.valueAsNumber;
147
+ let i = thumbs.indexOf(moved);
148
+ while (i < thumbs.length - 1 && dragged > thumbs[i + 1]!.valueAsNumber) {
149
+ thumbs[i]!.value = thumbs[i + 1]!.value;
150
+ thumbs[++i]!.value = value;
151
+ }
152
+ while (i > 0 && dragged < thumbs[i - 1]!.valueAsNumber) {
153
+ thumbs[i]!.value = thumbs[i - 1]!.value;
154
+ thumbs[--i]!.value = value;
155
+ }
156
+ const active = thumbs[i]!;
157
+ if (active === moved) return;
158
+ if (this.#dragging) this.#dragging = active;
159
+ if (this.#pressed) {
160
+ // The native drag goes on in its own thumb: focus follows when the press ends.
161
+ this.#proxy =
162
+ active === this.#pressed ? undefined : { target: active, value: this.#pressed.value };
163
+ return;
164
+ }
165
+ active.focus({ preventScroll: true });
166
+ }
167
+
168
+ #values(): string[] {
169
+ return this.thumbs.map((thumb) => thumb.value);
170
+ }
171
+
172
+ /** Writes the selected part of the track, the values' text and the Value part. */
173
+ #sync(): void {
174
+ const thumbs = this.thumbs;
175
+ this.#reported = this.#values();
176
+ if (thumbs.length === 0) return;
177
+ const { min, max } = this.#range(thumbs[0]!);
178
+ const at = (value: number) => (max > min ? ((value - min) / (max - min)) * 100 : 0);
179
+ const values = thumbs.map((thumb) => thumb.valueAsNumber);
180
+ const start = values.length > 1 ? at(values[0]!) : 0;
181
+ const end = at(values.at(-1)!);
182
+ this.style.setProperty("--lu-slider-start", `${start}%`);
183
+ this.style.setProperty("--lu-slider-end", `${end}%`);
184
+ const format = this.getAttribute("format");
185
+ const texts = formatValues(values, format, this.getAttribute("locale"));
186
+ if (format) {
187
+ thumbs.forEach((thumb, i) => thumb.setAttribute("aria-valuetext", texts[i]!));
188
+ }
189
+ // The Value part showing the values is `for` the thumbs; one with content of its own is not.
190
+ for (const output of this.querySelectorAll("output[for]")) {
191
+ if (output.closest("lu-slider") === this) output.textContent = texts.join(" – ");
192
+ }
193
+ }
194
+
195
+ /** A form reset puts the thumbs back without an `input` event: the track and text follow. */
196
+ #onReset = (event: Event) => {
197
+ if (this.thumbs.some((thumb) => thumb.form === event.target)) setTimeout(() => this.#sync());
198
+ };
199
+
200
+ #onInput = (event: Event) => {
201
+ let thumb = event.target as HTMLInputElement;
202
+ if (!thumb.matches?.(THUMB) || thumb.closest("lu-slider") !== this) return;
203
+ if (this.#proxy && thumb === this.#pressed) {
204
+ // The dragged thumb swapped places: it stays put, and its drag moves the other thumb.
205
+ const value = thumb.value;
206
+ thumb.value = this.#proxy.value;
207
+ thumb = this.#proxy.target;
208
+ thumb.value = value;
209
+ }
210
+ const before = this.#reported;
211
+ this.#constrain(thumb);
212
+ this.#sync();
213
+ // Blocked: nothing moved, so nothing is reported.
214
+ if (same(before, this.#reported)) event.stopPropagation();
215
+ };
216
+
217
+ /** A native drag's `change`: nothing to commit when it ends where it began. */
218
+ #onChange = (event: Event) => {
219
+ const thumb = event.target as HTMLInputElement;
220
+ if (!thumb.matches?.(THUMB) || thumb.closest("lu-slider") !== this) return;
221
+ const start = this.#pressedAt;
222
+ this.#pressedAt = undefined;
223
+ if (start && same(start, this.#values())) event.stopPropagation();
224
+ };
225
+
226
+ /** Sets `thumb` to `value`, as the user would: clamped, on the step, with input and change. */
227
+ #move(thumb: HTMLInputElement, value: number, change: boolean): void {
228
+ const before = this.#values();
229
+ thumb.value = String(value);
230
+ // Blocked by a neighbour (or already there): no move, no events (TASK-449 L21).
231
+ this.#constrain(thumb);
232
+ if (same(before, this.#values())) return;
233
+ this.#pressedAt = undefined;
234
+ thumb.dispatchEvent(new Event("input", { bubbles: true }));
235
+ if (change) thumb.dispatchEvent(new Event("change", { bubbles: true }));
236
+ }
237
+
238
+ #onKeyDown = (event: KeyboardEvent) => {
239
+ const thumb = event.target as HTMLInputElement;
240
+ if (!thumb.matches?.(THUMB) || thumb.matches(":disabled") || event.isComposing) return;
241
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
242
+ const large = number(this.getAttribute("large-step"), 10);
243
+ let delta = 0;
244
+ if (event.key === "Home" || event.key === "End") {
245
+ // To the end of the range, or to the neighbouring thumb, never past or pushing it.
246
+ const thumbs = this.thumbs;
247
+ const i = thumbs.indexOf(thumb);
248
+ const { min, max, step } = this.#range(thumb);
249
+ const gap = number(this.getAttribute("min-steps-between-values"), 0) * step;
250
+ const previous = thumbs[i - 1];
251
+ const next = thumbs[i + 1];
252
+ const to =
253
+ event.key === "Home"
254
+ ? previous
255
+ ? previous.valueAsNumber + gap
256
+ : min
257
+ : next
258
+ ? next.valueAsNumber - gap
259
+ : max;
260
+ event.preventDefault();
261
+ this.#move(thumb, to, true);
262
+ return;
263
+ }
264
+ if (event.key === "PageUp") delta = large;
265
+ else if (event.key === "PageDown") delta = -large;
266
+ else if (event.key.startsWith("Arrow")) {
267
+ // A vertical thumb's writing mode turns its own direction: only a horizontal one mirrors.
268
+ const vertical = thumb.getAttribute("aria-orientation") === "vertical";
269
+ const rtl = !vertical && getComputedStyle(thumb).direction === "rtl";
270
+ const up = event.key === "ArrowUp" || event.key === (rtl ? "ArrowLeft" : "ArrowRight");
271
+ const down = event.key === "ArrowDown" || event.key === (rtl ? "ArrowRight" : "ArrowLeft");
272
+ const size = event.shiftKey ? large : this.#range(thumb).step;
273
+ delta = up ? size : down ? -size : 0;
274
+ }
275
+ if (delta === 0) return;
276
+ event.preventDefault();
277
+ this.#move(thumb, thumb.valueAsNumber + delta, true);
278
+ };
279
+
280
+ /**
281
+ * The value at a pointer position on the control. A native thumb travels inside its input, inset
282
+ * by half its size, and is about as large as the input is thick.
283
+ */
284
+ #valueAt(event: PointerEvent, thumb: HTMLInputElement): number {
285
+ const box = thumb.getBoundingClientRect();
286
+ const { min, max } = this.#range(thumb);
287
+ const vertical = thumb.getAttribute("aria-orientation") === "vertical";
288
+ const rtl = !vertical && getComputedStyle(thumb).direction === "rtl";
289
+ const length = vertical ? box.height : box.width;
290
+ const size = Math.min(vertical ? box.width : box.height, length);
291
+ const offset = vertical ? box.bottom - event.clientY : event.clientX - box.left;
292
+ let ratio = length > size ? (offset - size / 2) / (length - size) : 0;
293
+ if (rtl) ratio = 1 - ratio;
294
+ return min + Math.min(1, Math.max(0, ratio)) * (max - min);
295
+ }
296
+
297
+ #onPress = (event: PointerEvent) => {
298
+ // `:disabled`: a disabled fieldset around the slider disables its thumbs too (TASK-439, M30).
299
+ const thumbs = this.thumbs.filter((thumb) => !thumb.matches(":disabled"));
300
+ // One thumb: the native input takes presses on its whole track.
301
+ if (thumbs.length < 2 || event.button !== 0 || !event.isPrimary) return;
302
+ if ((event.target as Element).matches?.(THUMB)) {
303
+ // The thumb's own drag; a swap hands it to another thumb until the press ends.
304
+ this.#pressed = event.target as HTMLInputElement;
305
+ this.#pressedAt = this.#values();
306
+ const end = (next: PointerEvent) => {
307
+ if (next.pointerId !== event.pointerId) return;
308
+ document.removeEventListener("pointerup", end, true);
309
+ document.removeEventListener("pointercancel", end, true);
310
+ this.#proxy?.target.focus({ preventScroll: true });
311
+ this.#pressed = this.#proxy = undefined;
312
+ };
313
+ document.addEventListener("pointerup", end, true);
314
+ document.addEventListener("pointercancel", end, true);
315
+ return;
316
+ }
317
+ const control = (event.target as Element).closest?.<HTMLElement>(`div:has(> ${THUMB})`);
318
+ if (!control || !this.contains(control)) return;
319
+ const value = this.#valueAt(event, thumbs[0]!);
320
+ // The nearest thumb; between two at the same distance, the one the press is past.
321
+ let nearest = thumbs[0]!;
322
+ for (const thumb of thumbs) {
323
+ if (Math.abs(thumb.valueAsNumber - value) <= Math.abs(nearest.valueAsNumber - value)) {
324
+ if (thumb.valueAsNumber !== nearest.valueAsNumber || value > thumb.valueAsNumber) {
325
+ nearest = thumb;
326
+ }
327
+ }
328
+ }
329
+ event.preventDefault();
330
+ nearest.focus();
331
+ this.#dragging = nearest;
332
+ const start = this.#values();
333
+ this.#move(nearest, value, false);
334
+ control.setPointerCapture(event.pointerId);
335
+ const move = (next: PointerEvent) => {
336
+ if (next.pointerId !== event.pointerId || !this.#dragging) return;
337
+ this.#move(this.#dragging, this.#valueAt(next, this.#dragging), false);
338
+ };
339
+ const up = (next: PointerEvent) => {
340
+ if (next.pointerId !== event.pointerId) return;
341
+ control.removeEventListener("pointermove", move);
342
+ control.removeEventListener("pointerup", up);
343
+ control.removeEventListener("pointercancel", up);
344
+ if (!same(start, this.#values())) {
345
+ this.#dragging?.dispatchEvent(new Event("change", { bubbles: true }));
346
+ }
347
+ this.#dragging = undefined;
348
+ };
349
+ control.addEventListener("pointermove", move);
350
+ control.addEventListener("pointerup", up);
351
+ control.addEventListener("pointercancel", up);
352
+ };
353
+ }
354
+
355
+ if (!customElements.get("lu-slider")) customElements.define("lu-slider", SliderElement);