@getaster/elements 0.0.0-stage → 0.1.0-preview.1
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 +202 -0
- package/NOTICE +10 -0
- package/README.md +6 -2
- package/bin/aster-theme-check.js +317 -0
- package/dist/base/drag.d.ts +45 -0
- package/dist/base/drag.js +139 -0
- package/dist/base/element.d.ts +51 -0
- package/dist/base/element.js +133 -0
- package/dist/breadcrumbs/breadcrumbs.d.ts +23 -0
- package/dist/breadcrumbs/breadcrumbs.js +51 -0
- package/dist/button/button.d.ts +37 -0
- package/dist/button/button.js +108 -0
- package/dist/caret/caret-group.d.ts +14 -0
- package/dist/caret/caret-group.js +71 -0
- package/dist/carousel/carousel.d.ts +28 -0
- package/dist/carousel/carousel.js +134 -0
- package/dist/component/component.d.ts +73 -0
- package/dist/component/component.js +190 -0
- package/dist/component/echarts.d.ts +46 -0
- package/dist/component/echarts.js +95 -0
- package/dist/composition/authoring.d.ts +157 -0
- package/dist/composition/authoring.js +291 -0
- package/dist/composition/builtins.d.ts +1130 -0
- package/dist/composition/builtins.js +318 -0
- package/dist/composition/command-bar.d.ts +18 -0
- package/dist/composition/command-bar.js +34 -0
- package/dist/composition/commands.d.ts +14 -0
- package/dist/composition/commands.js +59 -0
- package/dist/composition/composite.d.ts +48 -0
- package/dist/composition/composite.js +409 -0
- package/dist/composition/contracts.d.ts +63 -0
- package/dist/composition/contracts.js +97 -0
- package/dist/composition/definition.d.ts +94 -0
- package/dist/composition/definition.js +291 -0
- package/dist/composition/element.d.ts +35 -0
- package/dist/composition/element.js +51 -0
- package/dist/composition/registry.d.ts +83 -0
- package/dist/composition/registry.js +99 -0
- package/dist/composition/schema.d.ts +9 -0
- package/dist/composition/schema.js +139 -0
- package/dist/composition/sessions.d.ts +15 -0
- package/dist/composition/sessions.js +61 -0
- package/dist/dashboard/dashboard.d.ts +26 -0
- package/dist/dashboard/dashboard.js +296 -0
- package/dist/dashboard/layout.d.ts +19 -0
- package/dist/dashboard/layout.js +48 -0
- package/dist/dashboard/widget.d.ts +42 -0
- package/dist/dashboard/widget.js +165 -0
- package/dist/date/calendar.d.ts +34 -0
- package/dist/date/calendar.js +137 -0
- package/dist/date/date-field.d.ts +18 -0
- package/dist/date/date-field.js +81 -0
- package/dist/date/dates.d.ts +22 -0
- package/dist/date/dates.js +80 -0
- package/dist/dialog/dialog.d.ts +23 -0
- package/dist/dialog/dialog.js +70 -0
- package/dist/field/bound-field.d.ts +55 -0
- package/dist/field/bound-field.js +139 -0
- package/dist/field/check.d.ts +11 -0
- package/dist/field/check.js +26 -0
- package/dist/field/composite-field.d.ts +35 -0
- package/dist/field/composite-field.js +88 -0
- package/dist/field/field.d.ts +29 -0
- package/dist/field/field.js +75 -0
- package/dist/field/input.d.ts +15 -0
- package/dist/field/input.js +18 -0
- package/dist/field/number-field.d.ts +33 -0
- package/dist/field/number-field.js +179 -0
- package/dist/field/numeric.d.ts +4 -0
- package/dist/field/numeric.js +57 -0
- package/dist/field/values.d.ts +14 -0
- package/dist/field/values.js +60 -0
- package/dist/filter/filter-builder.d.ts +18 -0
- package/dist/filter/filter-builder.js +305 -0
- package/dist/form/controls.d.ts +20 -0
- package/dist/form/controls.js +34 -0
- package/dist/form/form.d.ts +46 -0
- package/dist/form/form.js +85 -0
- package/dist/format.d.ts +10 -0
- package/dist/format.js +57 -0
- package/dist/grid/filters.d.ts +16 -0
- package/dist/grid/filters.js +66 -0
- package/dist/grid/grid.d.ts +209 -0
- package/dist/grid/grid.js +2587 -0
- package/dist/grid/rows.d.ts +32 -0
- package/dist/grid/rows.js +89 -0
- package/dist/grid/view.d.ts +69 -0
- package/dist/grid/view.js +172 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +61 -0
- package/dist/layout/stack.d.ts +112 -0
- package/dist/layout/stack.js +21 -0
- package/dist/list-edit/list-edit.d.ts +42 -0
- package/dist/list-edit/list-edit.js +220 -0
- package/dist/listbox/listbox.d.ts +45 -0
- package/dist/listbox/listbox.js +187 -0
- package/dist/menu/menu.d.ts +55 -0
- package/dist/menu/menu.js +286 -0
- package/dist/motion.d.ts +8 -0
- package/dist/motion.js +23 -0
- package/dist/overlay/anchor.d.ts +18 -0
- package/dist/overlay/anchor.js +41 -0
- package/dist/picker/options.d.ts +59 -0
- package/dist/picker/options.js +97 -0
- package/dist/picker/picker.d.ts +46 -0
- package/dist/picker/picker.js +492 -0
- package/dist/picker/tree-choices.d.ts +38 -0
- package/dist/picker/tree-choices.js +72 -0
- package/dist/picker/tree-picker.d.ts +29 -0
- package/dist/picker/tree-picker.js +282 -0
- package/dist/progress/policy.d.ts +5 -0
- package/dist/progress/policy.js +28 -0
- package/dist/progress/status.d.ts +20 -0
- package/dist/progress/status.js +38 -0
- package/dist/rule/operators.d.ts +8 -0
- package/dist/rule/operators.js +9 -0
- package/dist/rule/rule-editor.d.ts +57 -0
- package/dist/rule/rule-editor.js +290 -0
- package/dist/section/accordion.d.ts +24 -0
- package/dist/section/accordion.js +90 -0
- package/dist/section/section.d.ts +30 -0
- package/dist/section/section.js +99 -0
- package/dist/select/choice-element.d.ts +20 -0
- package/dist/select/choice-element.js +55 -0
- package/dist/select/choice.d.ts +34 -0
- package/dist/select/choice.js +122 -0
- package/dist/select/many.d.ts +13 -0
- package/dist/select/many.js +20 -0
- package/dist/select/select.d.ts +16 -0
- package/dist/select/select.js +40 -0
- package/dist/sidebar/sidebar.d.ts +47 -0
- package/dist/sidebar/sidebar.js +105 -0
- package/dist/slicer/slicer.d.ts +26 -0
- package/dist/slicer/slicer.js +140 -0
- package/dist/split/split.d.ts +41 -0
- package/dist/split/split.js +237 -0
- package/dist/styles/contract.d.ts +36 -0
- package/dist/styles/contract.js +39 -0
- package/dist/switch/switch.d.ts +22 -0
- package/dist/switch/switch.js +69 -0
- package/dist/tabs/tabs.d.ts +30 -0
- package/dist/tabs/tabs.js +202 -0
- package/dist/tile-grid/scroll.d.ts +9 -0
- package/dist/tile-grid/scroll.js +21 -0
- package/dist/tile-grid/tile-grid.d.ts +64 -0
- package/dist/tile-grid/tile-grid.js +734 -0
- package/dist/toast/toast.d.ts +25 -0
- package/dist/toast/toast.js +92 -0
- package/dist/tree/navigator.d.ts +41 -0
- package/dist/tree/navigator.js +228 -0
- package/dist/window/chrome.d.ts +3 -0
- package/dist/window/chrome.js +7 -0
- package/dist/window/window.d.ts +46 -0
- package/dist/window/window.js +347 -0
- package/dist/window/workspace.d.ts +29 -0
- package/dist/window/workspace.js +137 -0
- package/package.json +45 -4
- package/styles/aster-ui.css +1147 -0
- package/styles/base.css +88 -0
- package/styles/contract.ts +41 -0
- package/styles/elements.css +1058 -0
- package/styles/harbour.css +126 -0
- package/styles/lamplight.css +118 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { html, nothing } from "lit-html";
|
|
2
|
+
import { AsterElement, define, each, uid } from "../base/element.js";
|
|
3
|
+
/**
|
|
4
|
+
* A row of cards that snaps one at a time, the next one peeking in (the APG
|
|
5
|
+
* carousel pattern, without rotation): Previous and Next, and "n of m" said
|
|
6
|
+
* as it changes. Scrolling, swiping and the buttons all move it; the current
|
|
7
|
+
* slide is whichever is most in view. When every slide fits, there are no
|
|
8
|
+
* buttons.
|
|
9
|
+
*/
|
|
10
|
+
export class Carousel extends AsterElement {
|
|
11
|
+
#slides = this.prop([]);
|
|
12
|
+
#label = this.prop("");
|
|
13
|
+
#index = this.prop(0);
|
|
14
|
+
/** The slides don't all fit: only then are there places to move to. */
|
|
15
|
+
#overflows = this.prop(false);
|
|
16
|
+
#resize;
|
|
17
|
+
#id = uid("aster-carousel");
|
|
18
|
+
#observer;
|
|
19
|
+
/** Rebuilt when descriptors change or the element reconnects. */
|
|
20
|
+
#observed;
|
|
21
|
+
#restoreId;
|
|
22
|
+
#restoreFocus;
|
|
23
|
+
get slides() {
|
|
24
|
+
return this.#slides.value;
|
|
25
|
+
}
|
|
26
|
+
set slides(v) {
|
|
27
|
+
const ids = v.filter(s => s.id !== undefined).map(s => s.id);
|
|
28
|
+
if (new Set(ids).size !== ids.length || ids.some(id => !id))
|
|
29
|
+
throw new TypeError("Carousel slide IDs must be unique and nonempty");
|
|
30
|
+
const current = this.#slides.peek()[this.#index.peek()]?.id;
|
|
31
|
+
const active = document.activeElement;
|
|
32
|
+
this.#restoreFocus = active instanceof HTMLElement && active.closest("aster-carousel") === this ? active : undefined;
|
|
33
|
+
this.#restoreId = current && v.some(s => s.id === current) ? current : undefined;
|
|
34
|
+
this.#slides.value = [...v];
|
|
35
|
+
}
|
|
36
|
+
get label() {
|
|
37
|
+
return this.#label.value;
|
|
38
|
+
}
|
|
39
|
+
set label(v) {
|
|
40
|
+
this.#label.value = v;
|
|
41
|
+
}
|
|
42
|
+
/** The slide most in view. */
|
|
43
|
+
get index() {
|
|
44
|
+
return this.#index.value;
|
|
45
|
+
}
|
|
46
|
+
/** Brings a slide into view (smoothly unless motion is reduced). */
|
|
47
|
+
show(index) {
|
|
48
|
+
const slides = this.#slideEls();
|
|
49
|
+
const target = slides[Math.max(0, Math.min(slides.length - 1, index))];
|
|
50
|
+
const rail = this.querySelector(".aster-carousel-rail");
|
|
51
|
+
if (!target || !rail)
|
|
52
|
+
return;
|
|
53
|
+
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth";
|
|
54
|
+
const rtl = getComputedStyle(this).direction === "rtl";
|
|
55
|
+
const first = slides[0];
|
|
56
|
+
const left = first ? rtl ? target.offsetLeft + target.offsetWidth - first.offsetLeft - first.offsetWidth : target.offsetLeft - first.offsetLeft : 0;
|
|
57
|
+
rail.scrollTo({ left, behavior: behavior });
|
|
58
|
+
}
|
|
59
|
+
disconnectedCallback() {
|
|
60
|
+
this.#observer?.disconnect();
|
|
61
|
+
this.#resize?.disconnect();
|
|
62
|
+
this.#observed = undefined;
|
|
63
|
+
super.disconnectedCallback();
|
|
64
|
+
}
|
|
65
|
+
render() {
|
|
66
|
+
const slides = this.#slides.value;
|
|
67
|
+
const index = Math.min(this.#index.value, Math.max(0, slides.length - 1));
|
|
68
|
+
const arrow = (back) => html `<svg viewBox="0 0 16 16" aria-hidden="true"><path d=${back ? "m10 3-5 5 5 5" : "m6 3 5 5-5 5"}/></svg>`;
|
|
69
|
+
return html `<section class="aster-carousel" aria-roledescription="carousel" aria-label=${this.#label.value}>
|
|
70
|
+
<div class="aster-carousel-rail" id=${`${this.#id}-rail`}>
|
|
71
|
+
${each(slides.map((slide, index) => ({ slide, index })), x => x.slide.id === undefined ? `index:${x.index}` : `id:${x.slide.id}`, ({ slide: s, index: i }) => html `<div class="aster-carousel-slide" role="group" aria-roledescription="slide"
|
|
72
|
+
aria-label=${`${i + 1} of ${slides.length}: ${s.label}`} data-index=${i}>${s.view}</div>`)}
|
|
73
|
+
</div>
|
|
74
|
+
${slides.length > 1 && this.#overflows.value
|
|
75
|
+
? html `<div class="aster-carousel-nav">
|
|
76
|
+
<span aria-live="polite" aria-atomic="true">${index + 1} of ${slides.length}</span>
|
|
77
|
+
<button type="button" aria-controls=${`${this.#id}-rail`} aria-label="Previous" title="Previous" ?disabled=${index === 0} @click=${() => this.show(index - 1)}>${arrow(true)}</button>
|
|
78
|
+
<button type="button" aria-controls=${`${this.#id}-rail`} aria-label="Next" title="Next" ?disabled=${index === slides.length - 1} @click=${() => this.show(index + 1)}>${arrow(false)}</button>
|
|
79
|
+
</div>`
|
|
80
|
+
: nothing}
|
|
81
|
+
</section>`;
|
|
82
|
+
}
|
|
83
|
+
updated() {
|
|
84
|
+
if (this.#observed !== this.#slides.peek()) {
|
|
85
|
+
this.#observe();
|
|
86
|
+
if (this.#restoreId) {
|
|
87
|
+
const index = this.#slides.peek().findIndex(s => s.id === this.#restoreId);
|
|
88
|
+
this.#restoreId = undefined;
|
|
89
|
+
if (index >= 0) {
|
|
90
|
+
this.#index.value = index;
|
|
91
|
+
const rail = this.querySelector(".aster-carousel-rail"), slide = this.#slideEls()[index], first = this.#slideEls()[0];
|
|
92
|
+
if (rail && slide && first)
|
|
93
|
+
rail.scrollLeft = getComputedStyle(this).direction === "rtl" ? slide.offsetLeft + slide.offsetWidth - first.offsetLeft - first.offsetWidth : slide.offsetLeft - first.offsetLeft;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const focused = this.#restoreFocus;
|
|
98
|
+
this.#restoreFocus = undefined;
|
|
99
|
+
if (focused?.isConnected && (document.activeElement === document.body || document.activeElement === focused))
|
|
100
|
+
focused.focus({ preventScroll: true });
|
|
101
|
+
}
|
|
102
|
+
/** The slide most in view becomes current. */
|
|
103
|
+
#observe() {
|
|
104
|
+
const rail = this.querySelector(".aster-carousel-rail");
|
|
105
|
+
if (!rail)
|
|
106
|
+
return;
|
|
107
|
+
this.#observer?.disconnect();
|
|
108
|
+
this.#observed = this.#slides.peek();
|
|
109
|
+
this.#index.value = Math.min(this.#index.peek(), Math.max(0, this.#slides.peek().length - 1));
|
|
110
|
+
const ratios = new Map();
|
|
111
|
+
this.#observer = new IntersectionObserver((entries) => {
|
|
112
|
+
for (const e of entries)
|
|
113
|
+
ratios.set(Number(e.target.dataset.index), e.intersectionRatio);
|
|
114
|
+
let best = this.#index.peek();
|
|
115
|
+
let most = -1;
|
|
116
|
+
for (const [i, r] of ratios)
|
|
117
|
+
if (r > most + 0.01 || (Math.abs(r - most) <= 0.01 && i < best))
|
|
118
|
+
[best, most] = [i, r];
|
|
119
|
+
if (most > 0)
|
|
120
|
+
this.#index.value = best;
|
|
121
|
+
}, { root: rail, threshold: [0, 0.25, 0.5, 0.75, 1] });
|
|
122
|
+
for (const el of this.#slideEls())
|
|
123
|
+
this.#observer.observe(el);
|
|
124
|
+
this.#resize?.disconnect();
|
|
125
|
+
this.#resize = new ResizeObserver(() => (this.#overflows.value = rail.scrollWidth > rail.clientWidth + 1));
|
|
126
|
+
this.#resize.observe(rail);
|
|
127
|
+
for (const el of this.#slideEls())
|
|
128
|
+
this.#resize.observe(el);
|
|
129
|
+
}
|
|
130
|
+
#slideEls() {
|
|
131
|
+
return [...this.querySelectorAll(".aster-carousel-slide")].filter(el => el.closest("aster-carousel") === this);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
define("aster-carousel", Carousel);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type ProgressPolicy, type PageScope } from "@getaster/data";
|
|
2
|
+
import "../progress/status.ts";
|
|
3
|
+
import { AsterElement, type View } from "../base/element.ts";
|
|
4
|
+
/** Browser preferences and CSS-owned content dimensions, refreshed by the host. */
|
|
5
|
+
export interface ComponentEnvironment {
|
|
6
|
+
/** Surface content width in CSS pixels, observed rather than computed by layout code. */
|
|
7
|
+
readonly width: number;
|
|
8
|
+
/** Surface content height in CSS pixels. */
|
|
9
|
+
readonly height: number;
|
|
10
|
+
/** Non-zero surface, visible document and no inherited visibility:hidden. Offscreen scrolling does not pause it. */
|
|
11
|
+
readonly visible: boolean;
|
|
12
|
+
/** The live prefers-reduced-motion preference. */
|
|
13
|
+
readonly reducedMotion: boolean;
|
|
14
|
+
/** The live forced-colors preference. */
|
|
15
|
+
readonly forcedColors: boolean;
|
|
16
|
+
/** Explicit host locale, nearest lang or browser language. */
|
|
17
|
+
readonly locale: string;
|
|
18
|
+
/** Computed CSS direction of the host. */
|
|
19
|
+
readonly direction: string;
|
|
20
|
+
/** Browser device pixel ratio. */
|
|
21
|
+
readonly pixelRatio: number;
|
|
22
|
+
/** Current CSS token values; vendor-specific colour conversion belongs in its adapter. */
|
|
23
|
+
readonly theme: Readonly<Record<"surface" | "text" | "muted" | "rule" | "accent" | "positive" | "negative", string>>;
|
|
24
|
+
/** Computed CSS font family. */
|
|
25
|
+
readonly font: string;
|
|
26
|
+
}
|
|
27
|
+
/** One mount's private DOM and cancellation boundary. */
|
|
28
|
+
export interface ComponentMount {
|
|
29
|
+
/** Private subtree for this mount; stale asynchronous work cannot touch a later mount's node. */
|
|
30
|
+
readonly node: HTMLElement;
|
|
31
|
+
/** Aborted on replacement, retry, disconnect or update failure. */
|
|
32
|
+
readonly signal: AbortSignal;
|
|
33
|
+
/** Initial browser environment; subsequent values arrive through update. */
|
|
34
|
+
readonly environment: ComponentEnvironment;
|
|
35
|
+
/** Nearest scope; borrowed, never disposed by the host. */
|
|
36
|
+
readonly scope: PageScope | undefined;
|
|
37
|
+
}
|
|
38
|
+
/** A mounted vendor component. Every resource created by mount belongs here. */
|
|
39
|
+
export interface ComponentSession {
|
|
40
|
+
/** Synchronous signal reads are tracked; called again when they or the environment change. */
|
|
41
|
+
update(environment: ComponentEnvironment): void;
|
|
42
|
+
/** Stops subscriptions, vendor listeners, observers and vendor rendering. Called exactly once. */
|
|
43
|
+
dispose(): void;
|
|
44
|
+
}
|
|
45
|
+
/** Vendor-neutral lifecycle. Lazy imports belong in mount and must honour its signal. */
|
|
46
|
+
export interface ComponentAdapter {
|
|
47
|
+
/** Acquires a session; check cancellation after awaits and release partial acquisitions before throwing. */
|
|
48
|
+
mount(context: ComponentMount): ComponentSession | Promise<ComponentSession>;
|
|
49
|
+
}
|
|
50
|
+
/** A stable vendor-owned surface with an independently rendered accessible alternative. */
|
|
51
|
+
export declare class ComponentHost extends AsterElement {
|
|
52
|
+
#private;
|
|
53
|
+
get progressPolicy(): ProgressPolicy;
|
|
54
|
+
set progressPolicy(value: ProgressPolicy);
|
|
55
|
+
/** Replacing the adapter disposes its previous session and mounts afresh when visible. */
|
|
56
|
+
get adapter(): ComponentAdapter | undefined;
|
|
57
|
+
set adapter(value: ComponentAdapter | undefined);
|
|
58
|
+
/** Required: HTML data and keyboard actions equivalent to the visual content. */
|
|
59
|
+
get alternative(): (() => View) | undefined;
|
|
60
|
+
set alternative(value: (() => View) | undefined);
|
|
61
|
+
/** Visible heading and accessible name for this component. */
|
|
62
|
+
get label(): string;
|
|
63
|
+
set label(value: string);
|
|
64
|
+
/** Defaults to the nearest lang attribute, then the browser language. */
|
|
65
|
+
get locale(): string | undefined;
|
|
66
|
+
set locale(value: string | undefined);
|
|
67
|
+
/** Retries a failed mount or update with a fresh DOM node. */
|
|
68
|
+
retry(): void;
|
|
69
|
+
connectedCallback(): void;
|
|
70
|
+
disconnectedCallback(): void;
|
|
71
|
+
protected render(): View;
|
|
72
|
+
protected updated(): void;
|
|
73
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { effect, signal, untracked } from "@preact/signals-core";
|
|
2
|
+
import { html, nothing } from "lit-html";
|
|
3
|
+
import { progressPolicy } from "@getaster/data";
|
|
4
|
+
import "../progress/status.js";
|
|
5
|
+
import { AsterElement, define, scopeOf, uid } from "../base/element.js";
|
|
6
|
+
/** A stable vendor-owned surface with an independently rendered accessible alternative. */
|
|
7
|
+
export class ComponentHost extends AsterElement {
|
|
8
|
+
#progressPolicy = this.prop({});
|
|
9
|
+
#adapter = this.prop(undefined);
|
|
10
|
+
#alternative = this.prop(undefined);
|
|
11
|
+
#label = this.prop("Chart");
|
|
12
|
+
#locale = this.prop(undefined);
|
|
13
|
+
#attempt = signal(0);
|
|
14
|
+
#status = this.prop("waiting");
|
|
15
|
+
#environment = signal(undefined);
|
|
16
|
+
#id = uid("aster-component");
|
|
17
|
+
#surface;
|
|
18
|
+
#abort;
|
|
19
|
+
#session;
|
|
20
|
+
#stopUpdate;
|
|
21
|
+
#generation = 0;
|
|
22
|
+
#size = { width: 0, height: 0 };
|
|
23
|
+
#refresh;
|
|
24
|
+
get progressPolicy() { return this.#progressPolicy.value; }
|
|
25
|
+
set progressPolicy(value) { progressPolicy(value); this.#progressPolicy.value = Object.freeze({ ...value }); }
|
|
26
|
+
/** Replacing the adapter disposes its previous session and mounts afresh when visible. */
|
|
27
|
+
get adapter() { return this.#adapter.value; }
|
|
28
|
+
set adapter(value) { this.#adapter.value = value; }
|
|
29
|
+
/** Required: HTML data and keyboard actions equivalent to the visual content. */
|
|
30
|
+
get alternative() { return this.#alternative.value; }
|
|
31
|
+
set alternative(value) { this.#alternative.value = value; }
|
|
32
|
+
/** Visible heading and accessible name for this component. */
|
|
33
|
+
get label() { return this.#label.value; }
|
|
34
|
+
set label(value) { this.#label.value = value; }
|
|
35
|
+
/** Defaults to the nearest lang attribute, then the browser language. */
|
|
36
|
+
get locale() { return this.#locale.value; }
|
|
37
|
+
set locale(value) { this.#locale.value = value; }
|
|
38
|
+
/** Retries a failed mount or update with a fresh DOM node. */
|
|
39
|
+
retry() { this.#attempt.value++; }
|
|
40
|
+
connectedCallback() {
|
|
41
|
+
super.connectedCallback();
|
|
42
|
+
this.#surface = this.querySelector(".aster-component-surface");
|
|
43
|
+
const motion = matchMedia("(prefers-reduced-motion: reduce)");
|
|
44
|
+
const contrast = matchMedia("(forced-colors: active)");
|
|
45
|
+
const refresh = () => {
|
|
46
|
+
if (!this.isConnected)
|
|
47
|
+
return;
|
|
48
|
+
const style = getComputedStyle(this);
|
|
49
|
+
const token = (name) => style.getPropertyValue(`--aster-${name}`).trim();
|
|
50
|
+
const next = {
|
|
51
|
+
...this.#size,
|
|
52
|
+
visible: this.#size.width > 0 && this.#size.height > 0 && !document.hidden && style.visibility !== "hidden",
|
|
53
|
+
reducedMotion: motion.matches, forcedColors: contrast.matches,
|
|
54
|
+
locale: this.#locale.peek() ?? this.closest("[lang]")?.getAttribute("lang") ?? navigator.language,
|
|
55
|
+
direction: style.direction, pixelRatio: devicePixelRatio,
|
|
56
|
+
theme: { surface: token("surface"), text: token("text"), muted: token("text-2"), rule: token("rule"), accent: token("accent"), positive: token("positive"), negative: token("negative") },
|
|
57
|
+
font: style.fontFamily,
|
|
58
|
+
};
|
|
59
|
+
if (JSON.stringify(next) !== JSON.stringify(this.#environment.peek()))
|
|
60
|
+
this.#environment.value = next;
|
|
61
|
+
};
|
|
62
|
+
this.#refresh = refresh;
|
|
63
|
+
const resize = new ResizeObserver(([entry]) => {
|
|
64
|
+
if (!entry)
|
|
65
|
+
return;
|
|
66
|
+
this.#size = { width: entry.contentRect.width, height: entry.contentRect.height };
|
|
67
|
+
refresh();
|
|
68
|
+
});
|
|
69
|
+
resize.observe(this.#surface);
|
|
70
|
+
const attributes = new MutationObserver((records) => {
|
|
71
|
+
if (records.some((r) => r.target instanceof Element && r.target.contains(this)))
|
|
72
|
+
refresh();
|
|
73
|
+
});
|
|
74
|
+
attributes.observe(document.documentElement, { attributes: true, subtree: true, attributeFilter: ["class", "style", "hidden", "lang", "dir", "data-identity", "data-scheme", "data-density"] });
|
|
75
|
+
for (const media of [motion, contrast])
|
|
76
|
+
media.addEventListener("change", refresh);
|
|
77
|
+
document.addEventListener("visibilitychange", refresh);
|
|
78
|
+
document.addEventListener("load", refresh, true);
|
|
79
|
+
window.addEventListener("resize", refresh);
|
|
80
|
+
refresh();
|
|
81
|
+
this.onDisconnect(() => {
|
|
82
|
+
resize.disconnect();
|
|
83
|
+
attributes.disconnect();
|
|
84
|
+
for (const media of [motion, contrast])
|
|
85
|
+
media.removeEventListener("change", refresh);
|
|
86
|
+
document.removeEventListener("visibilitychange", refresh);
|
|
87
|
+
document.removeEventListener("load", refresh, true);
|
|
88
|
+
window.removeEventListener("resize", refresh);
|
|
89
|
+
this.#refresh = undefined;
|
|
90
|
+
});
|
|
91
|
+
this.onDisconnect(effect(() => {
|
|
92
|
+
const adapter = this.#adapter.value;
|
|
93
|
+
const alternative = this.#alternative.value;
|
|
94
|
+
this.#attempt.value;
|
|
95
|
+
untracked(() => {
|
|
96
|
+
this.#dispose();
|
|
97
|
+
this.#status.value = "waiting";
|
|
98
|
+
if (!adapter || !alternative)
|
|
99
|
+
return;
|
|
100
|
+
const generation = this.#generation;
|
|
101
|
+
const stop = effect(() => {
|
|
102
|
+
const environment = this.#environment.value;
|
|
103
|
+
if (environment?.visible)
|
|
104
|
+
untracked(() => {
|
|
105
|
+
if (!this.#abort && this.#status.peek() !== "error")
|
|
106
|
+
void this.#mount(adapter, environment, generation);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
this.#stopUpdate = stop;
|
|
110
|
+
});
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
disconnectedCallback() {
|
|
114
|
+
super.disconnectedCallback();
|
|
115
|
+
this.#dispose();
|
|
116
|
+
this.#environment.value = undefined;
|
|
117
|
+
this.#size = { width: 0, height: 0 };
|
|
118
|
+
}
|
|
119
|
+
render() {
|
|
120
|
+
this.#locale.value;
|
|
121
|
+
const status = this.#status.value;
|
|
122
|
+
const alternative = this.#alternative.value;
|
|
123
|
+
return html `<section class="aster-component" aria-labelledby=${this.#id}>
|
|
124
|
+
<h3 id=${this.#id}>${this.#label.value}</h3>
|
|
125
|
+
<div class="aster-component-surface" aria-hidden="true"></div>
|
|
126
|
+
<div class="aster-component-status"><aster-progress-status .busy=${status === "loading"}
|
|
127
|
+
.error=${status === "error" ? "Visualisation unavailable." : !alternative ? "Provide an accessible alternative to enable this visualisation." : undefined}
|
|
128
|
+
.progressPolicy=${this.#progressPolicy.value} .pendingText=${"Loading visualisation…"} .completeText=${"Visualisation ready"}></aster-progress-status>
|
|
129
|
+
${status === "error" ? html `<button class="aster-button" type="button" @click=${() => this.retry()}>Retry visualisation</button>` : nothing}</div>
|
|
130
|
+
<div class="aster-component-alternative" role="region" aria-label=${`${this.#label.value} data and actions`} tabindex="0">${alternative?.() ?? nothing}</div>
|
|
131
|
+
</section>`;
|
|
132
|
+
}
|
|
133
|
+
updated() { this.#refresh?.(); }
|
|
134
|
+
async #mount(adapter, environment, generation) {
|
|
135
|
+
const abort = new AbortController();
|
|
136
|
+
this.#abort = abort;
|
|
137
|
+
const node = document.createElement("div");
|
|
138
|
+
node.className = "aster-component-content";
|
|
139
|
+
this.#surface.replaceChildren(node);
|
|
140
|
+
this.#status.value = "loading";
|
|
141
|
+
try {
|
|
142
|
+
const session = await adapter.mount({ node, signal: abort.signal, environment, scope: scopeOf(this) });
|
|
143
|
+
if (generation !== this.#generation || abort.signal.aborted) {
|
|
144
|
+
session.dispose();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.#session = session;
|
|
148
|
+
this.#stopUpdate?.();
|
|
149
|
+
this.#stopUpdate = effect(() => {
|
|
150
|
+
const current = this.#environment.value;
|
|
151
|
+
if (!current)
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
session.update(current);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
queueMicrotask(() => this.#fail(error, generation));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
this.#status.value = "ready";
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
this.#fail(error, generation);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
#fail(error, generation) {
|
|
167
|
+
if (generation !== this.#generation || !this.isConnected)
|
|
168
|
+
return;
|
|
169
|
+
this.#dispose();
|
|
170
|
+
this.#status.value = "error";
|
|
171
|
+
this.emit("component-error", error);
|
|
172
|
+
}
|
|
173
|
+
#dispose() {
|
|
174
|
+
this.#generation++;
|
|
175
|
+
this.#abort?.abort();
|
|
176
|
+
this.#abort = undefined;
|
|
177
|
+
this.#stopUpdate?.();
|
|
178
|
+
this.#stopUpdate = undefined;
|
|
179
|
+
const session = this.#session;
|
|
180
|
+
this.#session = undefined;
|
|
181
|
+
try {
|
|
182
|
+
session?.dispose();
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
this.emit("component-error", error);
|
|
186
|
+
}
|
|
187
|
+
this.#surface?.replaceChildren();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
define("aster-component", ComponentHost);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ComponentAdapter, ComponentEnvironment } from "./component.ts";
|
|
2
|
+
/** The small structural API the adapter needs; pass an optional ECharts module via load. */
|
|
3
|
+
export interface EChartsRuntime {
|
|
4
|
+
/** Creates one SVG chart. Locale packs are registered by the application loader. */
|
|
5
|
+
init(node: HTMLElement, theme?: undefined, options?: {
|
|
6
|
+
renderer: "svg";
|
|
7
|
+
locale: string;
|
|
8
|
+
devicePixelRatio: number;
|
|
9
|
+
}): EChartsInstance;
|
|
10
|
+
}
|
|
11
|
+
/** Public ECharts methods used by the reference adapter. */
|
|
12
|
+
export interface EChartsInstance {
|
|
13
|
+
/** Updates chart options without publishing synthetic selection events. */
|
|
14
|
+
setOption(option: object, options: {
|
|
15
|
+
notMerge: boolean;
|
|
16
|
+
silent: boolean;
|
|
17
|
+
replaceMerge?: string[];
|
|
18
|
+
}): void;
|
|
19
|
+
/** Resizes internal chart rendering to observed CSS dimensions. */
|
|
20
|
+
resize(options: {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
silent: boolean;
|
|
24
|
+
}): void;
|
|
25
|
+
/** Attaches the adapter's pointer action callback. */
|
|
26
|
+
on(event: "click", listener: (event: unknown) => void): void;
|
|
27
|
+
/** Removes the same callback before disposing. */
|
|
28
|
+
off(event: "click", listener: (event: unknown) => void): void;
|
|
29
|
+
/** Releases vendor-owned rendering and event resources. */
|
|
30
|
+
dispose(): void;
|
|
31
|
+
}
|
|
32
|
+
/** Configuration stays in the application; no ECharts dependency ships in the elements package. */
|
|
33
|
+
export interface EChartsAdapterOptions {
|
|
34
|
+
/** Lazy module load. Register any modular charts/components before returning it. */
|
|
35
|
+
readonly load: () => Promise<EChartsRuntime>;
|
|
36
|
+
/** Reads public data signals; must return a complete option, including empty/loading states. */
|
|
37
|
+
readonly option: (environment: ComponentEnvironment) => Readonly<Record<string, unknown>>;
|
|
38
|
+
/** Merge stable named series/data for incremental transitions; omitted option fields are retained. Default replace. */
|
|
39
|
+
readonly updateMode?: "replace" | "merge";
|
|
40
|
+
/** Explicit mapping of vendor events to public data actions; no implicit record/index mapping. */
|
|
41
|
+
readonly click?: (event: unknown) => void;
|
|
42
|
+
}
|
|
43
|
+
/** CSS colours resolved to sRGB for vendor parsers that don't understand modern CSS colours. */
|
|
44
|
+
export declare function echartsColors(environment: ComponentEnvironment): ComponentEnvironment["theme"];
|
|
45
|
+
/** ECharts reference lifecycle: stable SVG, reactive options, silent updates and exact disposal. */
|
|
46
|
+
export declare function echartsAdapter(options: EChartsAdapterOptions): ComponentAdapter;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** CSS colours resolved to sRGB for vendor parsers that don't understand modern CSS colours. */
|
|
2
|
+
export function echartsColors(environment) {
|
|
3
|
+
const canvas = document.createElement("canvas");
|
|
4
|
+
canvas.width = canvas.height = 1;
|
|
5
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
6
|
+
const colour = (value) => {
|
|
7
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
8
|
+
ctx.fillStyle = value;
|
|
9
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
10
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
11
|
+
return `rgba(${r}, ${g}, ${b}, ${a / 255})`;
|
|
12
|
+
};
|
|
13
|
+
const theme = environment.theme;
|
|
14
|
+
return { surface: colour(theme.surface), text: colour(theme.text), muted: colour(theme.muted), rule: colour(theme.rule), accent: colour(theme.accent), positive: colour(theme.positive), negative: colour(theme.negative) };
|
|
15
|
+
}
|
|
16
|
+
/** ECharts reference lifecycle: stable SVG, reactive options, silent updates and exact disposal. */
|
|
17
|
+
export function echartsAdapter(options) {
|
|
18
|
+
return {
|
|
19
|
+
async mount({ node, signal, environment }) {
|
|
20
|
+
const runtime = await options.load();
|
|
21
|
+
if (signal.aborted)
|
|
22
|
+
return { update() { }, dispose() { } };
|
|
23
|
+
let chart;
|
|
24
|
+
let locale = environment.locale;
|
|
25
|
+
const click = (event) => { if (!signal.aborted)
|
|
26
|
+
options.click?.(event); };
|
|
27
|
+
const dispose = () => {
|
|
28
|
+
const owned = chart;
|
|
29
|
+
chart = undefined;
|
|
30
|
+
if (!owned)
|
|
31
|
+
return;
|
|
32
|
+
try {
|
|
33
|
+
owned.off("click", click);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
owned.dispose();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const init = (current) => {
|
|
40
|
+
chart = runtime.init(node, undefined, { renderer: "svg", locale: current.locale, devicePixelRatio: current.pixelRatio });
|
|
41
|
+
try {
|
|
42
|
+
chart.on("click", click);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
dispose();
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
locale = current.locale;
|
|
49
|
+
};
|
|
50
|
+
init(environment);
|
|
51
|
+
let previous;
|
|
52
|
+
let previousMotion;
|
|
53
|
+
return {
|
|
54
|
+
update(current) {
|
|
55
|
+
// Read data while hidden too, so showing the component uses the latest state.
|
|
56
|
+
const option = options.option(current);
|
|
57
|
+
if (!current.visible || current.forcedColors) {
|
|
58
|
+
node.hidden = true;
|
|
59
|
+
previous = undefined;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
node.hidden = false;
|
|
63
|
+
if (locale !== current.locale) {
|
|
64
|
+
dispose();
|
|
65
|
+
init(current);
|
|
66
|
+
previous = undefined;
|
|
67
|
+
previousMotion = undefined;
|
|
68
|
+
}
|
|
69
|
+
const colors = echartsColors(current);
|
|
70
|
+
chart.setOption({
|
|
71
|
+
backgroundColor: colors.surface,
|
|
72
|
+
color: [colors.accent, colors.muted, colors.negative],
|
|
73
|
+
textStyle: { color: colors.text, fontFamily: current.font },
|
|
74
|
+
...option,
|
|
75
|
+
// HTML tooltips would escape the host's presentation-only subtree.
|
|
76
|
+
tooltip: { show: false },
|
|
77
|
+
animation: !current.reducedMotion && option.animation !== false,
|
|
78
|
+
...(current.reducedMotion ? {
|
|
79
|
+
animationDuration: 0, animationDurationUpdate: 0,
|
|
80
|
+
series: (Array.isArray(option.series) ? option.series : option.series ? [option.series] : []).map((series) => ({ ...series, animation: false, animationDuration: 0, animationDurationUpdate: 0, universalTransition: false })),
|
|
81
|
+
} : {}),
|
|
82
|
+
}, options.updateMode === "merge" && (previousMotion === undefined || previousMotion === current.reducedMotion)
|
|
83
|
+
? { notMerge: false, replaceMerge: ["series"], silent: true }
|
|
84
|
+
: { notMerge: true, silent: true });
|
|
85
|
+
previousMotion = current.reducedMotion;
|
|
86
|
+
if (!previous || previous.width !== current.width || previous.height !== current.height || previous.pixelRatio !== current.pixelRatio) {
|
|
87
|
+
chart.resize({ width: current.width, height: current.height, silent: true });
|
|
88
|
+
}
|
|
89
|
+
previous = current;
|
|
90
|
+
},
|
|
91
|
+
dispose,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|