@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.
- package/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/accordion/accordion.css +20 -0
- package/dist/accordion/accordion.script.ts +66 -0
- package/dist/accordion/accordion.tsx +288 -0
- package/dist/animation/animation.ts +34 -0
- package/dist/autocomplete/autocomplete.tsx +239 -0
- package/dist/avatar/avatar.css +54 -0
- package/dist/avatar/avatar.script.ts +96 -0
- package/dist/avatar/avatar.tsx +110 -0
- package/dist/button/button.script.ts +35 -0
- package/dist/button/button.tsx +40 -0
- package/dist/checkbox/checkbox.script.ts +225 -0
- package/dist/checkbox/checkbox.tsx +156 -0
- package/dist/checkbox/context.ts +17 -0
- package/dist/checkbox-group/checkbox-group.tsx +111 -0
- package/dist/collapsible/collapsible.css +12 -0
- package/dist/collapsible/collapsible.script.ts +7 -0
- package/dist/collapsible/collapsible.tsx +153 -0
- package/dist/collapsible/panel.ts +115 -0
- package/dist/combobox/combobox.css +37 -0
- package/dist/combobox/combobox.script.ts +974 -0
- package/dist/combobox/combobox.tsx +556 -0
- package/dist/combobox/context.ts +46 -0
- package/dist/context-menu/context-menu.css +16 -0
- package/dist/context-menu/context-menu.script.ts +172 -0
- package/dist/context-menu/context-menu.tsx +178 -0
- package/dist/dialog/dialog.css +35 -0
- package/dist/dialog/dialog.script.ts +269 -0
- package/dist/dialog/dialog.tsx +214 -0
- package/dist/drawer/drawer.css +38 -0
- package/dist/drawer/drawer.script.ts +207 -0
- package/dist/drawer/drawer.tsx +113 -0
- package/dist/field/context.ts +156 -0
- package/dist/field/field.css +16 -0
- package/dist/field/field.script.ts +422 -0
- package/dist/field/field.tsx +465 -0
- package/dist/fieldset/fieldset.tsx +41 -0
- package/dist/form/form.tsx +50 -0
- package/dist/form/native.ts +21 -0
- package/dist/form/reset.ts +53 -0
- package/dist/index.ts +261 -0
- package/dist/input/input.tsx +67 -0
- package/dist/layer/layer.ts +191 -0
- package/dist/locale/digits.ts +21 -0
- package/dist/menu/menu.css +22 -0
- package/dist/menu/menu.script.ts +591 -0
- package/dist/menu/menu.tsx +520 -0
- package/dist/menubar/menubar.css +16 -0
- package/dist/menubar/menubar.script.ts +195 -0
- package/dist/menubar/menubar.tsx +67 -0
- package/dist/meter/meter.tsx +134 -0
- package/dist/navigation-menu/navigation-menu.css +14 -0
- package/dist/navigation-menu/navigation-menu.script.ts +466 -0
- package/dist/navigation-menu/navigation-menu.tsx +266 -0
- package/dist/number-field/format.ts +70 -0
- package/dist/number-field/number-field.css +38 -0
- package/dist/number-field/number-field.script.ts +490 -0
- package/dist/number-field/number-field.tsx +403 -0
- package/dist/otp-field/otp-field.css +45 -0
- package/dist/otp-field/otp-field.script.ts +330 -0
- package/dist/otp-field/otp-field.tsx +320 -0
- package/dist/placement/place.ts +148 -0
- package/dist/placement/placement.css +155 -0
- package/dist/placement/placement.ts +60 -0
- package/dist/popover/hover.ts +178 -0
- package/dist/popover/popover.css +20 -0
- package/dist/popover/popover.script.ts +256 -0
- package/dist/popover/popover.tsx +317 -0
- package/dist/preview-card/preview-card.css +7 -0
- package/dist/preview-card/preview-card.script.ts +136 -0
- package/dist/preview-card/preview-card.tsx +128 -0
- package/dist/progress/progress.tsx +155 -0
- package/dist/progress/value.ts +23 -0
- package/dist/radio-group/radio-group.tsx +156 -0
- package/dist/scroll-area/scroll-area.css +72 -0
- package/dist/scroll-area/scroll-area.script.ts +281 -0
- package/dist/scroll-area/scroll-area.tsx +118 -0
- package/dist/select/select.css +25 -0
- package/dist/select/select.tsx +171 -0
- package/dist/separator/separator.css +12 -0
- package/dist/separator/separator.tsx +38 -0
- package/dist/slider/format.ts +33 -0
- package/dist/slider/slider.css +93 -0
- package/dist/slider/slider.script.ts +355 -0
- package/dist/slider/slider.tsx +374 -0
- package/dist/styles.css +30 -0
- package/dist/switch/switch.tsx +92 -0
- package/dist/tabs/tabs.css +54 -0
- package/dist/tabs/tabs.script.ts +225 -0
- package/dist/tabs/tabs.tsx +272 -0
- package/dist/themes/default.css +2111 -0
- package/dist/toast/command.ts +3 -0
- package/dist/toast/toast.css +68 -0
- package/dist/toast/toast.script.ts +516 -0
- package/dist/toast/toast.tsx +432 -0
- package/dist/toggle/context.ts +16 -0
- package/dist/toggle/toggle.tsx +99 -0
- package/dist/toggle-group/toggle-group.script.ts +166 -0
- package/dist/toggle-group/toggle-group.tsx +117 -0
- package/dist/toolbar/context.ts +18 -0
- package/dist/toolbar/toolbar.css +12 -0
- package/dist/toolbar/toolbar.script.ts +123 -0
- package/dist/toolbar/toolbar.tsx +180 -0
- package/dist/tooltip/tooltip.css +8 -0
- package/dist/tooltip/tooltip.script.ts +222 -0
- package/dist/tooltip/tooltip.tsx +149 -0
- package/docs/README.md +174 -0
- package/docs/accordion.md +208 -0
- package/docs/autocomplete.md +161 -0
- package/docs/avatar.md +198 -0
- package/docs/button.md +98 -0
- package/docs/checkbox-group.md +112 -0
- package/docs/checkbox.md +133 -0
- package/docs/collapsible.md +165 -0
- package/docs/combobox.md +369 -0
- package/docs/context-menu.md +197 -0
- package/docs/dialog.md +256 -0
- package/docs/drawer.md +204 -0
- package/docs/field.md +295 -0
- package/docs/fieldset.md +92 -0
- package/docs/form.md +131 -0
- package/docs/input.md +87 -0
- package/docs/installation.md +65 -0
- package/docs/llms-full.txt +6923 -0
- package/docs/llms.txt +53 -0
- package/docs/menu.md +365 -0
- package/docs/menubar.md +186 -0
- package/docs/meter.md +103 -0
- package/docs/navigation-menu.md +257 -0
- package/docs/number-field.md +181 -0
- package/docs/otp-field.md +160 -0
- package/docs/popover.md +271 -0
- package/docs/preview-card.md +130 -0
- package/docs/progress.md +115 -0
- package/docs/radio-group.md +127 -0
- package/docs/scroll-area.md +147 -0
- package/docs/select.md +236 -0
- package/docs/separator.md +116 -0
- package/docs/slider.md +184 -0
- package/docs/switch.md +98 -0
- package/docs/tabs.md +254 -0
- package/docs/theming.md +132 -0
- package/docs/toast.md +311 -0
- package/docs/toggle-group.md +119 -0
- package/docs/toggle.md +94 -0
- package/docs/toolbar.md +138 -0
- package/docs/tooltip.md +128 -0
- package/package.json +102 -3
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Scroll Area's client script (decision-62, decision-63): it defines `<lu-scroll-area>`, the
|
|
2
|
+
// light-DOM element ScrollArea.Root renders, after Base UI's ScrollArea. Scrolling stays native:
|
|
3
|
+
// the `[data-viewport]` scrolls with the wheel, touch, the keyboard (it is focusable while it
|
|
4
|
+
// overflows) and find-in-page, and nothing traps focus. CSS alone can only recolour and thin the
|
|
5
|
+
// native scrollbar (`scrollbar-color`, `scrollbar-width`, `scrollbar-gutter`), not shape it, lay it
|
|
6
|
+
// over the content or show it on hover; so the element hides the native scrollbar once it runs
|
|
7
|
+
// (scroll-area.css) and draws its own, which are pointer conveniences (`aria-hidden`). Without
|
|
8
|
+
// JavaScript the native scrollbars show.
|
|
9
|
+
//
|
|
10
|
+
// - On every scroll and resize: `data-has-overflow-x` and `-y`, `data-overflow-{x,y}-{start,end}`
|
|
11
|
+
// (more than `overflow-edge-threshold` px of content past that edge) on the element and the
|
|
12
|
+
// viewport; the distances as `--lu-scroll-area-overflow-{x,y}-{start,end}` on the viewport; each
|
|
13
|
+
// `[data-scrollbar]` hidden while its axis does not overflow (unless `data-keep-mounted`), its
|
|
14
|
+
// `[data-thumb]` sized (`--lu-scroll-area-thumb-height` or `-width`) and moved with `transform`;
|
|
15
|
+
// the `[data-corner]` shown when both overflow, its size as `--lu-scroll-area-corner-width` and
|
|
16
|
+
// `-height` on the element.
|
|
17
|
+
// - `data-scrolling` while scrolling (and 500 ms after), `data-hovering` on the scrollbars while
|
|
18
|
+
// the pointer is over the area.
|
|
19
|
+
// - Dragging a thumb scrolls with it; a press on a scrollbar's track scrolls there. The wheel over
|
|
20
|
+
// a scrollbar scrolls the viewport (which the scrollbar is not inside), and the page only once
|
|
21
|
+
// the viewport cannot scroll that way (TASK-437 M18); a pinch zoom (Ctrl) is the page's.
|
|
22
|
+
// - Right to left, a horizontal thumb runs from the right.
|
|
23
|
+
//
|
|
24
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
25
|
+
|
|
26
|
+
/** The smallest thumb, in px, so it stays easy to grab. */
|
|
27
|
+
const MIN_THUMB = 16;
|
|
28
|
+
/** How long `data-scrolling` stays after the last scroll. */
|
|
29
|
+
const SCROLLING = 500;
|
|
30
|
+
|
|
31
|
+
type Axis = "x" | "y";
|
|
32
|
+
|
|
33
|
+
export class ScrollAreaElement extends HTMLElement {
|
|
34
|
+
#resize: ResizeObserver | undefined;
|
|
35
|
+
#observed: Element[] = [];
|
|
36
|
+
#scrolling: ReturnType<typeof setTimeout> | undefined;
|
|
37
|
+
#frame = 0;
|
|
38
|
+
/** The scrollbars that listen to the wheel. */
|
|
39
|
+
#wheeled = new WeakSet<HTMLElement>();
|
|
40
|
+
|
|
41
|
+
/** The scrolling element. */
|
|
42
|
+
get viewport(): HTMLElement | null {
|
|
43
|
+
return this.#own<HTMLElement>("[data-viewport]")[0] ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#own<T extends Element>(selector: string): T[] {
|
|
47
|
+
return [...this.querySelectorAll<T>(selector)].filter(
|
|
48
|
+
(node) => node.closest("lu-scroll-area") === this,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#scrollbar(axis: Axis): HTMLElement | undefined {
|
|
53
|
+
const orientation = axis === "y" ? "vertical" : "horizontal";
|
|
54
|
+
return this.#own<HTMLElement>(`[data-scrollbar][data-orientation="${orientation}"]`)[0];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
connectedCallback(): void {
|
|
58
|
+
this.addEventListener("scroll", this.#onScroll, true);
|
|
59
|
+
this.addEventListener("pointerdown", this.#onPress);
|
|
60
|
+
this.addEventListener("pointerenter", this.#onHover);
|
|
61
|
+
this.addEventListener("pointerleave", this.#onHover);
|
|
62
|
+
this.#resize ??= new ResizeObserver(() => this.#schedule());
|
|
63
|
+
const settle = () => {
|
|
64
|
+
this.#observe();
|
|
65
|
+
this.#update();
|
|
66
|
+
};
|
|
67
|
+
if (document.readyState === "loading") {
|
|
68
|
+
document.addEventListener("DOMContentLoaded", settle, { once: true });
|
|
69
|
+
} else queueMicrotask(settle);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
disconnectedCallback(): void {
|
|
73
|
+
this.removeEventListener("scroll", this.#onScroll, true);
|
|
74
|
+
this.removeEventListener("pointerdown", this.#onPress);
|
|
75
|
+
this.removeEventListener("pointerenter", this.#onHover);
|
|
76
|
+
this.removeEventListener("pointerleave", this.#onHover);
|
|
77
|
+
this.#resize?.disconnect();
|
|
78
|
+
this.#observed = [];
|
|
79
|
+
cancelAnimationFrame(this.#frame);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Watches the viewport and its content for size changes. */
|
|
83
|
+
#observe(): void {
|
|
84
|
+
const viewport = this.viewport;
|
|
85
|
+
if (!viewport) return;
|
|
86
|
+
const targets = [viewport, ...viewport.children];
|
|
87
|
+
if (
|
|
88
|
+
targets.length === this.#observed.length &&
|
|
89
|
+
targets.every((t, i) => t === this.#observed[i])
|
|
90
|
+
) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.#resize?.disconnect();
|
|
94
|
+
for (const target of targets) this.#resize?.observe(target);
|
|
95
|
+
this.#observed = targets;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#schedule(): void {
|
|
99
|
+
cancelAnimationFrame(this.#frame);
|
|
100
|
+
this.#frame = requestAnimationFrame(() => {
|
|
101
|
+
this.#observe();
|
|
102
|
+
this.#update();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether a horizontal scroll position counts from the right (right to left). */
|
|
107
|
+
#rtl(viewport: HTMLElement): boolean {
|
|
108
|
+
return getComputedStyle(viewport).direction === "rtl";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Writes the overflow state, the thumbs and the corner from the viewport's scroll position. */
|
|
112
|
+
#update(): void {
|
|
113
|
+
const viewport = this.viewport;
|
|
114
|
+
if (!viewport) return;
|
|
115
|
+
const threshold = Number(this.getAttribute("overflow-edge-threshold")) || 0;
|
|
116
|
+
const { scrollWidth, scrollHeight, clientWidth, clientHeight } = viewport;
|
|
117
|
+
const left = Math.abs(viewport.scrollLeft);
|
|
118
|
+
const top = viewport.scrollTop;
|
|
119
|
+
const distances = {
|
|
120
|
+
"x-start": left,
|
|
121
|
+
"x-end": Math.max(0, scrollWidth - clientWidth - left),
|
|
122
|
+
"y-start": top,
|
|
123
|
+
"y-end": Math.max(0, scrollHeight - clientHeight - top),
|
|
124
|
+
};
|
|
125
|
+
const has = { x: scrollWidth > clientWidth + 1, y: scrollHeight > clientHeight + 1 };
|
|
126
|
+
for (const target of [this, viewport]) {
|
|
127
|
+
target.toggleAttribute("data-has-overflow-x", has.x);
|
|
128
|
+
target.toggleAttribute("data-has-overflow-y", has.y);
|
|
129
|
+
for (const [edge, distance] of Object.entries(distances)) {
|
|
130
|
+
target.toggleAttribute(`data-overflow-${edge}`, distance > threshold);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const [edge, distance] of Object.entries(distances)) {
|
|
134
|
+
viewport.style.setProperty(`--lu-scroll-area-overflow-${edge}`, `${Math.round(distance)}px`);
|
|
135
|
+
}
|
|
136
|
+
// Focusable while there is something to scroll with the keyboard.
|
|
137
|
+
const scrollable = has.x || has.y;
|
|
138
|
+
if (viewport.tabIndex !== (scrollable ? 0 : -1)) viewport.tabIndex = scrollable ? 0 : -1;
|
|
139
|
+
for (const axis of ["x", "y"] as const) this.#thumb(axis, viewport, has[axis]);
|
|
140
|
+
const vertical = this.#scrollbar("y");
|
|
141
|
+
const horizontal = this.#scrollbar("x");
|
|
142
|
+
for (const corner of this.#own<HTMLElement>("[data-corner]")) corner.hidden = !(has.x && has.y);
|
|
143
|
+
this.style.setProperty(
|
|
144
|
+
"--lu-scroll-area-corner-width",
|
|
145
|
+
`${has.x && has.y && vertical ? vertical.offsetWidth : 0}px`,
|
|
146
|
+
);
|
|
147
|
+
this.style.setProperty(
|
|
148
|
+
"--lu-scroll-area-corner-height",
|
|
149
|
+
`${has.x && has.y && horizontal ? horizontal.offsetHeight : 0}px`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Sizes and moves `axis`'s thumb, and shows its scrollbar while that axis overflows. */
|
|
154
|
+
#thumb(axis: Axis, viewport: HTMLElement, overflows: boolean): void {
|
|
155
|
+
const scrollbar = this.#scrollbar(axis);
|
|
156
|
+
if (!scrollbar) return;
|
|
157
|
+
if (!this.#wheeled.has(scrollbar)) {
|
|
158
|
+
// Not passive: a wheel the viewport takes must not scroll the page too.
|
|
159
|
+
scrollbar.addEventListener("wheel", this.#onWheel, { passive: false });
|
|
160
|
+
this.#wheeled.add(scrollbar);
|
|
161
|
+
}
|
|
162
|
+
scrollbar.hidden = !overflows && !scrollbar.hasAttribute("data-keep-mounted");
|
|
163
|
+
const thumb = scrollbar.querySelector<HTMLElement>("[data-thumb]");
|
|
164
|
+
if (!thumb || scrollbar.hidden) return;
|
|
165
|
+
const vertical = axis === "y";
|
|
166
|
+
const track = vertical ? scrollbar.clientHeight : scrollbar.clientWidth;
|
|
167
|
+
const view = vertical ? viewport.clientHeight : viewport.clientWidth;
|
|
168
|
+
const content = vertical ? viewport.scrollHeight : viewport.scrollWidth;
|
|
169
|
+
const position = vertical ? viewport.scrollTop : Math.abs(viewport.scrollLeft);
|
|
170
|
+
const size = content > 0 ? Math.max(MIN_THUMB, Math.min(track, (view / content) * track)) : 0;
|
|
171
|
+
const room = Math.max(0, content - view);
|
|
172
|
+
const offset = room > 0 ? (position / room) * (track - size) : 0;
|
|
173
|
+
const name = vertical ? "--lu-scroll-area-thumb-height" : "--lu-scroll-area-thumb-width";
|
|
174
|
+
scrollbar.style.setProperty(name, `${size}px`);
|
|
175
|
+
thumb.style.setProperty(name, `${size}px`);
|
|
176
|
+
const rtl = !vertical && this.#rtl(viewport);
|
|
177
|
+
thumb.style.transform = vertical
|
|
178
|
+
? `translate3d(0, ${offset}px, 0)`
|
|
179
|
+
: `translate3d(${rtl ? -offset : offset}px, 0, 0)`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#onScroll = (event: Event) => {
|
|
183
|
+
const viewport = this.viewport;
|
|
184
|
+
if (event.target !== viewport || !viewport) return;
|
|
185
|
+
this.#update();
|
|
186
|
+
this.toggleAttribute("data-scrolling", true);
|
|
187
|
+
for (const scrollbar of this.#own<HTMLElement>("[data-scrollbar]")) {
|
|
188
|
+
scrollbar.toggleAttribute("data-scrolling", true);
|
|
189
|
+
}
|
|
190
|
+
clearTimeout(this.#scrolling);
|
|
191
|
+
this.#scrolling = setTimeout(() => {
|
|
192
|
+
this.removeAttribute("data-scrolling");
|
|
193
|
+
for (const scrollbar of this.#own<HTMLElement>("[data-scrollbar]")) {
|
|
194
|
+
scrollbar.removeAttribute("data-scrolling");
|
|
195
|
+
}
|
|
196
|
+
}, SCROLLING);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** The wheel over a scrollbar scrolls the viewport, in pixels, lines or pages. */
|
|
200
|
+
#onWheel = (event: WheelEvent) => {
|
|
201
|
+
const viewport = this.viewport;
|
|
202
|
+
if (!viewport || event.ctrlKey) return;
|
|
203
|
+
const line = Number.parseFloat(getComputedStyle(viewport).lineHeight) || 16;
|
|
204
|
+
const [x, y] =
|
|
205
|
+
event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
|
206
|
+
? [line, line]
|
|
207
|
+
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
|
208
|
+
? [viewport.clientWidth, viewport.clientHeight]
|
|
209
|
+
: [1, 1];
|
|
210
|
+
const { scrollLeft, scrollTop } = viewport;
|
|
211
|
+
// Instant even under `scroll-behavior: smooth`, so the new position can be read back.
|
|
212
|
+
viewport.scrollBy({ left: event.deltaX * x, top: event.deltaY * y, behavior: "instant" });
|
|
213
|
+
if (viewport.scrollLeft !== scrollLeft || viewport.scrollTop !== scrollTop) {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
#onHover = (event: PointerEvent) => {
|
|
219
|
+
const over = event.type === "pointerenter";
|
|
220
|
+
for (const scrollbar of this.#own<HTMLElement>("[data-scrollbar]")) {
|
|
221
|
+
scrollbar.toggleAttribute("data-hovering", over);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** Dragging a thumb scrolls with it; a press on a track scrolls to put the thumb there. */
|
|
226
|
+
#onPress = (event: PointerEvent) => {
|
|
227
|
+
const viewport = this.viewport;
|
|
228
|
+
const target = event.target as Element;
|
|
229
|
+
const scrollbar = target.closest?.<HTMLElement>("[data-scrollbar]");
|
|
230
|
+
if (!viewport || !scrollbar || !this.contains(scrollbar) || event.button !== 0) return;
|
|
231
|
+
const thumb = scrollbar.querySelector<HTMLElement>("[data-thumb]");
|
|
232
|
+
if (!thumb) return;
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
const vertical = scrollbar.dataset.orientation === "vertical";
|
|
235
|
+
const rtl = !vertical && this.#rtl(viewport);
|
|
236
|
+
const sign = rtl ? -1 : 1;
|
|
237
|
+
const track = vertical ? scrollbar.clientHeight : scrollbar.clientWidth;
|
|
238
|
+
const size = vertical ? thumb.offsetHeight : thumb.offsetWidth;
|
|
239
|
+
const room = vertical
|
|
240
|
+
? viewport.scrollHeight - viewport.clientHeight
|
|
241
|
+
: viewport.scrollWidth - viewport.clientWidth;
|
|
242
|
+
const ratio = track > size ? room / (track - size) : 0;
|
|
243
|
+
const coordinate = (e: PointerEvent) => (vertical ? e.clientY : e.clientX);
|
|
244
|
+
const scrollTo = (position: number) => {
|
|
245
|
+
const clamped = Math.max(0, Math.min(room, position));
|
|
246
|
+
if (vertical) viewport.scrollTop = clamped;
|
|
247
|
+
else viewport.scrollLeft = clamped * sign;
|
|
248
|
+
};
|
|
249
|
+
if (!thumb.contains(target)) {
|
|
250
|
+
// A press on the track: the thumb's middle goes under the pointer.
|
|
251
|
+
const box = scrollbar.getBoundingClientRect();
|
|
252
|
+
const from = vertical ? box.top : rtl ? box.right : box.left;
|
|
253
|
+
const along = (coordinate(event) - from) * sign;
|
|
254
|
+
scrollTo((along - size / 2) * ratio);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const start = coordinate(event);
|
|
258
|
+
const origin = vertical ? viewport.scrollTop : Math.abs(viewport.scrollLeft);
|
|
259
|
+
thumb.setPointerCapture?.(event.pointerId);
|
|
260
|
+
thumb.toggleAttribute("data-scrolling", true);
|
|
261
|
+
const move = (next: PointerEvent) => {
|
|
262
|
+
if (next.pointerId === event.pointerId) {
|
|
263
|
+
scrollTo(origin + (coordinate(next) - start) * sign * ratio);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const end = (next: PointerEvent) => {
|
|
267
|
+
if (next.pointerId !== event.pointerId) return;
|
|
268
|
+
thumb.removeEventListener("pointermove", move);
|
|
269
|
+
thumb.removeEventListener("pointerup", end);
|
|
270
|
+
thumb.removeEventListener("pointercancel", end);
|
|
271
|
+
thumb.removeAttribute("data-scrolling");
|
|
272
|
+
};
|
|
273
|
+
thumb.addEventListener("pointermove", move);
|
|
274
|
+
thumb.addEventListener("pointerup", end);
|
|
275
|
+
thumb.addEventListener("pointercancel", end);
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!customElements.get("lu-scroll-area")) {
|
|
280
|
+
customElements.define("lu-scroll-area", ScrollAreaElement);
|
|
281
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Scroll Area (decision-62, TASK-384): native scrolling with custom scrollbars laid over it, after
|
|
2
|
+
// Base UI's ScrollArea. Root renders `<lu-scroll-area>`, whose script (scroll-area.script.ts) hides
|
|
3
|
+
// the native scrollbar and sizes, moves and drags the custom ones, and writes the overflow state as
|
|
4
|
+
// data attributes and CSS variables. The Viewport scrolls natively, is focusable while it
|
|
5
|
+
// overflows (keyboard scrolling) and traps nothing; without JavaScript its native scrollbars show.
|
|
6
|
+
import scrollAreaScript from "./scroll-area.script.ts?client";
|
|
7
|
+
import type { ScrollAreaElement } from "./scroll-area.script.ts";
|
|
8
|
+
|
|
9
|
+
declare module "lumiose/jsx-runtime" {
|
|
10
|
+
namespace JSX {
|
|
11
|
+
interface IntrinsicElements {
|
|
12
|
+
/**
|
|
13
|
+
* Scroll Area's element. Attribute `overflow-edge-threshold`; the overflow data attributes
|
|
14
|
+
* and `--lu-scroll-area-corner-width` and `-height` from its script.
|
|
15
|
+
*/
|
|
16
|
+
"lu-scroll-area": JSX.HTMLProps<ScrollAreaElement> & { "overflow-edge-threshold"?: number };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `ScrollArea.Root` renders `<lu-scroll-area>`, around the Viewport and the scrollbars. Give it a
|
|
23
|
+
* size (a height, a max height): the Viewport fills it.
|
|
24
|
+
*/
|
|
25
|
+
export type ScrollAreaRootProps = JSX.HTMLProps<ScrollAreaElement> & {
|
|
26
|
+
/**
|
|
27
|
+
* How many px of content past an edge before `data-overflow-*-start` or `-end` applies.
|
|
28
|
+
* Default `0`.
|
|
29
|
+
*/
|
|
30
|
+
overflowEdgeThreshold?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The `<div data-viewport>` that scrolls, natively: focusable (`tabindex="0"`) while it overflows,
|
|
35
|
+
* so the keyboard scrolls it. Give it an `aria-label` (and `role="region"`) when it holds a
|
|
36
|
+
* landmark-worthy section.
|
|
37
|
+
*/
|
|
38
|
+
export type ScrollAreaViewportProps = JSX.IntrinsicElements["div"];
|
|
39
|
+
|
|
40
|
+
/** A `<div>` around the content, sized to it. */
|
|
41
|
+
export type ScrollAreaContentProps = JSX.IntrinsicElements["div"];
|
|
42
|
+
|
|
43
|
+
/** A custom scrollbar, `<div data-scrollbar aria-hidden="true">` over an edge of the viewport. */
|
|
44
|
+
export type ScrollAreaScrollbarProps = JSX.IntrinsicElements["div"] & {
|
|
45
|
+
/** The edge and axis: `"vertical"` (default, the inline end) or `"horizontal"` (the bottom). */
|
|
46
|
+
orientation?: "vertical" | "horizontal";
|
|
47
|
+
/** Keeps the scrollbar shown while its axis does not overflow. */
|
|
48
|
+
keepMounted?: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The scrollbar's `<div data-thumb>`, sized and moved by the script; drag it to scroll. */
|
|
52
|
+
export type ScrollAreaThumbProps = JSX.IntrinsicElements["div"];
|
|
53
|
+
|
|
54
|
+
/** A `<div data-corner>` where the two scrollbars meet, shown while both axes overflow. */
|
|
55
|
+
export type ScrollAreaCornerProps = JSX.IntrinsicElements["div"];
|
|
56
|
+
|
|
57
|
+
function Root({ overflowEdgeThreshold, children, ...rest }: ScrollAreaRootProps) {
|
|
58
|
+
// The custom scrollbars and the overflow state (decision-63).
|
|
59
|
+
scrollAreaScript();
|
|
60
|
+
return (
|
|
61
|
+
<lu-scroll-area {...rest} overflow-edge-threshold={overflowEdgeThreshold}>
|
|
62
|
+
{children}
|
|
63
|
+
</lu-scroll-area>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Viewport({ children, ...rest }: ScrollAreaViewportProps) {
|
|
68
|
+
return (
|
|
69
|
+
<div tabindex={0} {...rest} data-viewport="">
|
|
70
|
+
{children}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function Content({ children, ...rest }: ScrollAreaContentProps) {
|
|
76
|
+
return <div {...rest}>{children}</div>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Scrollbar({ orientation, keepMounted, children, ...rest }: ScrollAreaScrollbarProps) {
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
{...rest}
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
data-scrollbar=""
|
|
85
|
+
data-orientation={orientation ?? "vertical"}
|
|
86
|
+
data-keep-mounted={keepMounted ? "" : undefined}
|
|
87
|
+
>
|
|
88
|
+
{children}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function Thumb(props: ScrollAreaThumbProps) {
|
|
94
|
+
return <div {...props} data-thumb="" />;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function Corner(props: ScrollAreaCornerProps) {
|
|
98
|
+
return <div {...props} data-corner="" hidden />;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A scrolling region with custom scrollbars over native scrolling, after Base UI's ScrollArea: the
|
|
103
|
+
* wheel, touch, the keyboard and find-in-page scroll it as any scroller, and the scrollbars show
|
|
104
|
+
* where it is. Unstyled: style the parts through `class`, `[data-orientation]`, `[data-hovering]`,
|
|
105
|
+
* `[data-scrolling]`, `[data-has-overflow-y]`, `[data-overflow-y-end]` and the
|
|
106
|
+
* `--lu-scroll-area-*` custom properties.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* <ScrollArea.Root style={{ height: "12rem" }}>
|
|
110
|
+
* <ScrollArea.Viewport aria-label="Release notes" role="region">
|
|
111
|
+
* <ScrollArea.Content>…</ScrollArea.Content>
|
|
112
|
+
* </ScrollArea.Viewport>
|
|
113
|
+
* <ScrollArea.Scrollbar>
|
|
114
|
+
* <ScrollArea.Thumb />
|
|
115
|
+
* </ScrollArea.Scrollbar>
|
|
116
|
+
* </ScrollArea.Root>
|
|
117
|
+
*/
|
|
118
|
+
export const ScrollArea = { Root, Viewport, Content, Scrollbar, Thumb, Corner };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The CSS Select needs to work, and nothing else (decision-62). Select is a native `<select>`: a
|
|
3
|
+
* select whose first child is a `Select.Trigger` is a customizable select (`appearance:
|
|
4
|
+
* base-select`), since only that renders the trigger, `Select.Value` and options with more than
|
|
5
|
+
* text. Browsers without customizable select ignore it and keep their own control. A select with
|
|
6
|
+
* no Trigger keeps the native look unless your CSS or the theme opts in. It sits in the `lu.core`
|
|
7
|
+
* layer, so any app CSS wins.
|
|
8
|
+
*/
|
|
9
|
+
@layer lu.core, lu.theme;
|
|
10
|
+
|
|
11
|
+
@layer lu.core {
|
|
12
|
+
select:has(> button:first-child),
|
|
13
|
+
select:has(> button:first-child)::picker(select) {
|
|
14
|
+
appearance: base-select;
|
|
15
|
+
}
|
|
16
|
+
/*
|
|
17
|
+
* Without customizable select the trigger is not drawn, but Firefox keeps it in the select:
|
|
18
|
+
* hidden, it is no nameless button for assistive technology and checkers (TASK-419).
|
|
19
|
+
*/
|
|
20
|
+
@supports not (appearance: base-select) {
|
|
21
|
+
select > button {
|
|
22
|
+
display: none !important;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Select (decision-62, TASK-358): a native `<select>`, the customizable select where the browser has
|
|
2
|
+
// it (`appearance: base-select`, Chrome 135, Safari 27) and the classic one elsewhere. The platform
|
|
3
|
+
// already does everything Base UI's Select does around the value: the combobox role, the keyboard
|
|
4
|
+
// and typeahead (on the closed control too), the top-layer picker anchored to the control, forms
|
|
5
|
+
// (`name`, `required`, autofill, reset, a disabled fieldset) and the mobile pickers. So Select
|
|
6
|
+
// ships no script: it works in server HTML, with no JavaScript, and in islands, where `value` drives
|
|
7
|
+
// the control when it changes and its `change` event reports back through `onValueChange`. Where
|
|
8
|
+
// customizable select is supported, `Select.Trigger` and `Select.Value` style the button and show
|
|
9
|
+
// the selected option's content, and options may hold more than text. TASK-358 records why it is
|
|
10
|
+
// native rather than the listbox pattern.
|
|
11
|
+
import { createContext, effect, ref, untrack, useContext } from "lumiose";
|
|
12
|
+
import { fieldProps, useField, useFormValues } from "../field/context.ts";
|
|
13
|
+
import { onFormReset } from "../form/reset.ts";
|
|
14
|
+
|
|
15
|
+
declare module "lumiose/jsx-runtime" {
|
|
16
|
+
namespace JSX {
|
|
17
|
+
interface IntrinsicElements {
|
|
18
|
+
/** Customizable select's copy of the selected option's content, inside its `<button>`. */
|
|
19
|
+
selectedcontent: JSX.HTMLProps<HTMLElement>;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The `<select>` props Root passes through, without the brand of intrinsic elements' props, so that
|
|
26
|
+
* `bind:value` applies to Root.
|
|
27
|
+
*/
|
|
28
|
+
type ElementProps = {
|
|
29
|
+
[
|
|
30
|
+
K in keyof JSX.IntrinsicElements["select"] as K extends string ? K : never
|
|
31
|
+
]: JSX.IntrinsicElements["select"][K];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `Select.Root` renders the `<select>`, with the props it is given (`name`, `required`,
|
|
36
|
+
* `disabled`, `autocomplete`, `form`, `class`, `aria-*`, events). `bind:value={signal}` stands in
|
|
37
|
+
* for `value` and `onValueChange`. Label it with a `<label for>` and an `id`, or `aria-label`.
|
|
38
|
+
*/
|
|
39
|
+
export interface SelectRootProps extends Omit<
|
|
40
|
+
ElementProps,
|
|
41
|
+
"value" | "bind:value" | "multiple" | "size"
|
|
42
|
+
> {
|
|
43
|
+
/**
|
|
44
|
+
* Selects the option with this value when it changes. Pair it with `onValueChange` to keep a
|
|
45
|
+
* signal in sync; leave both out and the select keeps its own state.
|
|
46
|
+
*/
|
|
47
|
+
value?: string;
|
|
48
|
+
/** The option selected at first, when `value` is left out. Default: the first option. */
|
|
49
|
+
defaultValue?: string;
|
|
50
|
+
/** Called after the user selects another option, with its value. */
|
|
51
|
+
onValueChange?: (value: string) => void;
|
|
52
|
+
/** `Trigger` (first, optional), then the `Item`s, `Group`s and `Separator`s. */
|
|
53
|
+
children?: JSX.Element;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The `<button>` customizable select renders as the control, holding `Select.Value` and anything
|
|
58
|
+
* else (an icon). It must be the select's first child. Browsers without customizable select show
|
|
59
|
+
* their own control instead.
|
|
60
|
+
*/
|
|
61
|
+
export type SelectTriggerProps = Omit<JSX.IntrinsicElements["button"], "type">;
|
|
62
|
+
|
|
63
|
+
/** `<selectedcontent>`: a copy of the selected option's content, inside the `Trigger`. */
|
|
64
|
+
export type SelectValueProps = Omit<JSX.IntrinsicElements["selectedcontent"], "children">;
|
|
65
|
+
|
|
66
|
+
/** An `<option>`. */
|
|
67
|
+
export type SelectItemProps = Omit<JSX.IntrinsicElements["option"], "value" | "selected"> & {
|
|
68
|
+
/** The value the select takes, and submits, when the option is selected. */
|
|
69
|
+
value: string;
|
|
70
|
+
/** The option cannot be selected. */
|
|
71
|
+
disabled?: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** An `<optgroup>` of options. */
|
|
75
|
+
export type SelectGroupProps = JSX.IntrinsicElements["optgroup"] & {
|
|
76
|
+
/** The group's name, shown above its options. */
|
|
77
|
+
label: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** An `<hr>` between options or groups. */
|
|
81
|
+
export type SelectSeparatorProps = JSX.IntrinsicElements["hr"];
|
|
82
|
+
|
|
83
|
+
const SelectContext = createContext<{ initial: string | undefined }>();
|
|
84
|
+
|
|
85
|
+
function Root({ value, defaultValue, onValueChange, children, ...rest }: SelectRootProps) {
|
|
86
|
+
const select = ref<HTMLSelectElement>();
|
|
87
|
+
// A failed submission's values (a Form's `values`) replace the default (TASK-430).
|
|
88
|
+
const sent = useFormValues(
|
|
89
|
+
untrack(() => rest.name ?? useField()?.name()),
|
|
90
|
+
untrack(() => rest.disabled),
|
|
91
|
+
);
|
|
92
|
+
const context = { initial: untrack(() => value ?? sent?.[0] ?? defaultValue) };
|
|
93
|
+
// The prop drives the control when it changes, once its options are in; `change` reports back,
|
|
94
|
+
// so a signal written by `onValueChange` sees the same value again and nothing loops.
|
|
95
|
+
effect(() => {
|
|
96
|
+
const element = select();
|
|
97
|
+
const wanted = value;
|
|
98
|
+
if (element?.isConnected && wanted !== undefined && element.value !== wanted) {
|
|
99
|
+
element.value = wanted;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
// A form reset fires no `change`: the reset value goes to `onValueChange` (TASK-435).
|
|
103
|
+
if (onValueChange) onFormReset(select, () => select()!.value, onValueChange);
|
|
104
|
+
// Inside a Field: the field's id, name and ARIA references (TASK-365).
|
|
105
|
+
const field = useField();
|
|
106
|
+
return (
|
|
107
|
+
<SelectContext value={context}>
|
|
108
|
+
<select
|
|
109
|
+
{...rest}
|
|
110
|
+
{...fieldProps(field, rest)}
|
|
111
|
+
ref={(element: HTMLSelectElement) => {
|
|
112
|
+
select(element);
|
|
113
|
+
rest.ref?.(element);
|
|
114
|
+
}}
|
|
115
|
+
on:change={(event: Event) =>
|
|
116
|
+
onValueChange?.((event.currentTarget as HTMLSelectElement).value)
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
</select>
|
|
121
|
+
</SelectContext>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function Trigger({ children, ...rest }: SelectTriggerProps) {
|
|
126
|
+
return (
|
|
127
|
+
<button {...rest} type="button">
|
|
128
|
+
{children}
|
|
129
|
+
</button>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function Value(props: SelectValueProps) {
|
|
134
|
+
return <selectedcontent {...props} />;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Item({ children, value, ...rest }: SelectItemProps) {
|
|
138
|
+
const select = useContext(SelectContext);
|
|
139
|
+
if (!select) throw new Error("Select parts must be inside <Select.Root>.");
|
|
140
|
+
return (
|
|
141
|
+
<option {...rest} value={value} selected={select.initial === value || undefined}>
|
|
142
|
+
{children}
|
|
143
|
+
</option>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function Group({ children, ...rest }: SelectGroupProps) {
|
|
148
|
+
return <optgroup {...rest}>{children}</optgroup>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function Separator(props: SelectSeparatorProps) {
|
|
152
|
+
return <hr {...props} />;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A native select: pick one value from a list. It works with no JavaScript and submits with its
|
|
157
|
+
* form. Where the browser has customizable select (`appearance: base-select`), `Trigger` and
|
|
158
|
+
* `Value` shape the button and options may hold more than text. Unstyled: style it through `class`,
|
|
159
|
+
* `:open`, `::picker(select)`, `option:checked` and `option::checkmark`.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* <label for="fruit">Fruit</label>
|
|
163
|
+
* <Select.Root id="fruit" name="fruit" bind:value={fruit}>
|
|
164
|
+
* <Select.Trigger>
|
|
165
|
+
* <Select.Value />
|
|
166
|
+
* </Select.Trigger>
|
|
167
|
+
* <Select.Item value="apple">Apple</Select.Item>
|
|
168
|
+
* <Select.Item value="pear">Pear</Select.Item>
|
|
169
|
+
* </Select.Root>
|
|
170
|
+
*/
|
|
171
|
+
export const Select = { Root, Trigger, Value, Item, Group, Separator };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A vertical Separator is an `<hr>` between the items of a row: without the browser's auto margins,
|
|
3
|
+
* which would push the row's items apart, and as tall as the row, even one that centres its items.
|
|
4
|
+
*/
|
|
5
|
+
@layer lu.core, lu.theme;
|
|
6
|
+
|
|
7
|
+
@layer lu.core {
|
|
8
|
+
hr[data-orientation="vertical"] {
|
|
9
|
+
margin: 0;
|
|
10
|
+
align-self: stretch;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Separator (decision-62, TASK-374): a native `<hr>`, after Base UI's Separator. Its implicit role
|
|
2
|
+
// is `separator`, horizontal by default; a vertical one gets `aria-orientation="vertical"`. Both
|
|
3
|
+
// carry `data-orientation`. No script. An `<hr>` is flow content: it cannot go inside a `<p>`, a
|
|
4
|
+
// `<span>` or a `<button>`.
|
|
5
|
+
|
|
6
|
+
/** Which way a separator runs. */
|
|
7
|
+
export type SeparatorOrientation = "horizontal" | "vertical";
|
|
8
|
+
|
|
9
|
+
/** A native `<hr>`, with every native prop (`class`, `style`, `id`, `aria-*`, `data-*`, …). */
|
|
10
|
+
export type SeparatorProps = Omit<JSX.IntrinsicElements["hr"], "children"> & {
|
|
11
|
+
/**
|
|
12
|
+
* Which way the separator runs: `"vertical"` between the items of a row, such as a toolbar's.
|
|
13
|
+
* Default `"horizontal"`.
|
|
14
|
+
*/
|
|
15
|
+
orientation?: SeparatorOrientation;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A line between groups of content, announced as a separator. Unstyled: style it through `class`
|
|
20
|
+
* and `[data-orientation]`.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* <nav class="toolbar">
|
|
24
|
+
* <a href="/docs">Docs</a>
|
|
25
|
+
* <Separator orientation="vertical" />
|
|
26
|
+
* <a href="/blog">Blog</a>
|
|
27
|
+
* </nav>
|
|
28
|
+
*/
|
|
29
|
+
export function Separator({ orientation, ...rest }: SeparatorProps) {
|
|
30
|
+
const vertical = () => orientation === "vertical";
|
|
31
|
+
return (
|
|
32
|
+
<hr
|
|
33
|
+
{...rest}
|
|
34
|
+
aria-orientation={vertical() ? "vertical" : undefined}
|
|
35
|
+
data-orientation={vertical() ? "vertical" : "horizontal"}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The slider's values as text, shared by the component (server HTML, islands) and its script.
|
|
2
|
+
|
|
3
|
+
/** The values written with `format` (`Intl.NumberFormat` options as JSON) and `locale`. */
|
|
4
|
+
export function formatValues(
|
|
5
|
+
values: readonly number[],
|
|
6
|
+
format: string | null,
|
|
7
|
+
locale: string | null,
|
|
8
|
+
): string[] {
|
|
9
|
+
let options: Intl.NumberFormatOptions | undefined;
|
|
10
|
+
try {
|
|
11
|
+
options = format ? (JSON.parse(format) as Intl.NumberFormatOptions) : undefined;
|
|
12
|
+
} catch {
|
|
13
|
+
options = undefined;
|
|
14
|
+
}
|
|
15
|
+
const formatter = numberFormat(locale ?? undefined, options);
|
|
16
|
+
return values.map((value) => formatter.format(value));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Options the browser refuses (`{"style":"currency"}` without a currency) are left out, and then
|
|
21
|
+
* an invalid locale, so the slider still works (TASK-473, L12); the server falls back the same way.
|
|
22
|
+
*/
|
|
23
|
+
function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions | undefined) {
|
|
24
|
+
try {
|
|
25
|
+
return new Intl.NumberFormat(locale, options);
|
|
26
|
+
} catch {
|
|
27
|
+
try {
|
|
28
|
+
return new Intl.NumberFormat(locale);
|
|
29
|
+
} catch {
|
|
30
|
+
return new Intl.NumberFormat();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|