@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.
Files changed (163) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +6 -2
  4. package/bin/aster-theme-check.js +317 -0
  5. package/dist/base/drag.d.ts +45 -0
  6. package/dist/base/drag.js +139 -0
  7. package/dist/base/element.d.ts +51 -0
  8. package/dist/base/element.js +133 -0
  9. package/dist/breadcrumbs/breadcrumbs.d.ts +23 -0
  10. package/dist/breadcrumbs/breadcrumbs.js +51 -0
  11. package/dist/button/button.d.ts +37 -0
  12. package/dist/button/button.js +108 -0
  13. package/dist/caret/caret-group.d.ts +14 -0
  14. package/dist/caret/caret-group.js +71 -0
  15. package/dist/carousel/carousel.d.ts +28 -0
  16. package/dist/carousel/carousel.js +134 -0
  17. package/dist/component/component.d.ts +73 -0
  18. package/dist/component/component.js +190 -0
  19. package/dist/component/echarts.d.ts +46 -0
  20. package/dist/component/echarts.js +95 -0
  21. package/dist/composition/authoring.d.ts +157 -0
  22. package/dist/composition/authoring.js +291 -0
  23. package/dist/composition/builtins.d.ts +1130 -0
  24. package/dist/composition/builtins.js +318 -0
  25. package/dist/composition/command-bar.d.ts +18 -0
  26. package/dist/composition/command-bar.js +34 -0
  27. package/dist/composition/commands.d.ts +14 -0
  28. package/dist/composition/commands.js +59 -0
  29. package/dist/composition/composite.d.ts +48 -0
  30. package/dist/composition/composite.js +409 -0
  31. package/dist/composition/contracts.d.ts +63 -0
  32. package/dist/composition/contracts.js +97 -0
  33. package/dist/composition/definition.d.ts +94 -0
  34. package/dist/composition/definition.js +291 -0
  35. package/dist/composition/element.d.ts +35 -0
  36. package/dist/composition/element.js +51 -0
  37. package/dist/composition/registry.d.ts +83 -0
  38. package/dist/composition/registry.js +99 -0
  39. package/dist/composition/schema.d.ts +9 -0
  40. package/dist/composition/schema.js +139 -0
  41. package/dist/composition/sessions.d.ts +15 -0
  42. package/dist/composition/sessions.js +61 -0
  43. package/dist/dashboard/dashboard.d.ts +26 -0
  44. package/dist/dashboard/dashboard.js +296 -0
  45. package/dist/dashboard/layout.d.ts +19 -0
  46. package/dist/dashboard/layout.js +48 -0
  47. package/dist/dashboard/widget.d.ts +42 -0
  48. package/dist/dashboard/widget.js +165 -0
  49. package/dist/date/calendar.d.ts +34 -0
  50. package/dist/date/calendar.js +137 -0
  51. package/dist/date/date-field.d.ts +18 -0
  52. package/dist/date/date-field.js +81 -0
  53. package/dist/date/dates.d.ts +22 -0
  54. package/dist/date/dates.js +80 -0
  55. package/dist/dialog/dialog.d.ts +23 -0
  56. package/dist/dialog/dialog.js +70 -0
  57. package/dist/field/bound-field.d.ts +55 -0
  58. package/dist/field/bound-field.js +139 -0
  59. package/dist/field/check.d.ts +11 -0
  60. package/dist/field/check.js +26 -0
  61. package/dist/field/composite-field.d.ts +35 -0
  62. package/dist/field/composite-field.js +88 -0
  63. package/dist/field/field.d.ts +29 -0
  64. package/dist/field/field.js +75 -0
  65. package/dist/field/input.d.ts +15 -0
  66. package/dist/field/input.js +18 -0
  67. package/dist/field/number-field.d.ts +33 -0
  68. package/dist/field/number-field.js +179 -0
  69. package/dist/field/numeric.d.ts +4 -0
  70. package/dist/field/numeric.js +57 -0
  71. package/dist/field/values.d.ts +14 -0
  72. package/dist/field/values.js +60 -0
  73. package/dist/filter/filter-builder.d.ts +18 -0
  74. package/dist/filter/filter-builder.js +305 -0
  75. package/dist/form/controls.d.ts +20 -0
  76. package/dist/form/controls.js +34 -0
  77. package/dist/form/form.d.ts +46 -0
  78. package/dist/form/form.js +85 -0
  79. package/dist/format.d.ts +10 -0
  80. package/dist/format.js +57 -0
  81. package/dist/grid/filters.d.ts +16 -0
  82. package/dist/grid/filters.js +66 -0
  83. package/dist/grid/grid.d.ts +209 -0
  84. package/dist/grid/grid.js +2587 -0
  85. package/dist/grid/rows.d.ts +32 -0
  86. package/dist/grid/rows.js +89 -0
  87. package/dist/grid/view.d.ts +69 -0
  88. package/dist/grid/view.js +172 -0
  89. package/dist/index.d.ts +63 -0
  90. package/dist/index.js +61 -0
  91. package/dist/layout/stack.d.ts +112 -0
  92. package/dist/layout/stack.js +21 -0
  93. package/dist/list-edit/list-edit.d.ts +42 -0
  94. package/dist/list-edit/list-edit.js +220 -0
  95. package/dist/listbox/listbox.d.ts +45 -0
  96. package/dist/listbox/listbox.js +187 -0
  97. package/dist/menu/menu.d.ts +55 -0
  98. package/dist/menu/menu.js +286 -0
  99. package/dist/motion.d.ts +8 -0
  100. package/dist/motion.js +23 -0
  101. package/dist/overlay/anchor.d.ts +18 -0
  102. package/dist/overlay/anchor.js +41 -0
  103. package/dist/picker/options.d.ts +59 -0
  104. package/dist/picker/options.js +97 -0
  105. package/dist/picker/picker.d.ts +46 -0
  106. package/dist/picker/picker.js +492 -0
  107. package/dist/picker/tree-choices.d.ts +38 -0
  108. package/dist/picker/tree-choices.js +72 -0
  109. package/dist/picker/tree-picker.d.ts +29 -0
  110. package/dist/picker/tree-picker.js +282 -0
  111. package/dist/progress/policy.d.ts +5 -0
  112. package/dist/progress/policy.js +28 -0
  113. package/dist/progress/status.d.ts +20 -0
  114. package/dist/progress/status.js +38 -0
  115. package/dist/rule/operators.d.ts +8 -0
  116. package/dist/rule/operators.js +9 -0
  117. package/dist/rule/rule-editor.d.ts +57 -0
  118. package/dist/rule/rule-editor.js +290 -0
  119. package/dist/section/accordion.d.ts +24 -0
  120. package/dist/section/accordion.js +90 -0
  121. package/dist/section/section.d.ts +30 -0
  122. package/dist/section/section.js +99 -0
  123. package/dist/select/choice-element.d.ts +20 -0
  124. package/dist/select/choice-element.js +55 -0
  125. package/dist/select/choice.d.ts +34 -0
  126. package/dist/select/choice.js +122 -0
  127. package/dist/select/many.d.ts +13 -0
  128. package/dist/select/many.js +20 -0
  129. package/dist/select/select.d.ts +16 -0
  130. package/dist/select/select.js +40 -0
  131. package/dist/sidebar/sidebar.d.ts +47 -0
  132. package/dist/sidebar/sidebar.js +105 -0
  133. package/dist/slicer/slicer.d.ts +26 -0
  134. package/dist/slicer/slicer.js +140 -0
  135. package/dist/split/split.d.ts +41 -0
  136. package/dist/split/split.js +237 -0
  137. package/dist/styles/contract.d.ts +36 -0
  138. package/dist/styles/contract.js +39 -0
  139. package/dist/switch/switch.d.ts +22 -0
  140. package/dist/switch/switch.js +69 -0
  141. package/dist/tabs/tabs.d.ts +30 -0
  142. package/dist/tabs/tabs.js +202 -0
  143. package/dist/tile-grid/scroll.d.ts +9 -0
  144. package/dist/tile-grid/scroll.js +21 -0
  145. package/dist/tile-grid/tile-grid.d.ts +64 -0
  146. package/dist/tile-grid/tile-grid.js +734 -0
  147. package/dist/toast/toast.d.ts +25 -0
  148. package/dist/toast/toast.js +92 -0
  149. package/dist/tree/navigator.d.ts +41 -0
  150. package/dist/tree/navigator.js +228 -0
  151. package/dist/window/chrome.d.ts +3 -0
  152. package/dist/window/chrome.js +7 -0
  153. package/dist/window/window.d.ts +46 -0
  154. package/dist/window/window.js +347 -0
  155. package/dist/window/workspace.d.ts +29 -0
  156. package/dist/window/workspace.js +137 -0
  157. package/package.json +45 -4
  158. package/styles/aster-ui.css +1147 -0
  159. package/styles/base.css +88 -0
  160. package/styles/contract.ts +41 -0
  161. package/styles/elements.css +1058 -0
  162. package/styles/harbour.css +126 -0
  163. package/styles/lamplight.css +118 -0
@@ -0,0 +1,139 @@
1
+ export function asSchema(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
2
+ export function schemaProperty(schema) { return { schema, validate: value => matchesSchema(value, schema) }; }
3
+ export function matchesSchema(value, schema) {
4
+ if (Array.isArray(schema.allOf) && !schema.allOf.every(s => matchesSchema(value, asSchema(s))))
5
+ return false;
6
+ if (Array.isArray(schema.anyOf) && !schema.anyOf.some(s => matchesSchema(value, asSchema(s))))
7
+ return false;
8
+ if (Object.hasOwn(schema, "const") && value !== schema.const)
9
+ return false;
10
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value))
11
+ return false;
12
+ if (schema.asterType === "undefined")
13
+ return value === undefined;
14
+ if (schema.type) {
15
+ switch (schema.type) {
16
+ case "string":
17
+ if (typeof value !== "string")
18
+ return false;
19
+ break;
20
+ case "boolean":
21
+ if (typeof value !== "boolean")
22
+ return false;
23
+ break;
24
+ case "number":
25
+ if (typeof value !== "number" || !Number.isFinite(value))
26
+ return false;
27
+ break;
28
+ case "integer":
29
+ if (typeof value !== "number" || !Number.isSafeInteger(value))
30
+ return false;
31
+ break;
32
+ case "null":
33
+ if (value !== null)
34
+ return false;
35
+ break;
36
+ case "array":
37
+ if (!Array.isArray(value))
38
+ return false;
39
+ break;
40
+ case "object":
41
+ if (!value || typeof value !== "object" || Array.isArray(value))
42
+ return false;
43
+ break;
44
+ }
45
+ }
46
+ if (typeof value === "number" && (typeof schema.minimum === "number" && value < schema.minimum || typeof schema.maximum === "number" && value > schema.maximum))
47
+ return false;
48
+ if (Array.isArray(value) && schema.items && !value.every(item => matchesSchema(item, asSchema(schema.items))))
49
+ return false;
50
+ if (schema.type === "object" && value && typeof value === "object") {
51
+ const values = value;
52
+ const fields = asSchema(schema.properties);
53
+ if (Array.isArray(schema.required) && !schema.required.every(key => typeof key === "string" && Object.hasOwn(values, key)))
54
+ return false;
55
+ for (const [key, item] of Object.entries(values)) {
56
+ if (Object.hasOwn(fields, key) && !matchesSchema(item, asSchema(fields[key])))
57
+ return false;
58
+ if (!Object.hasOwn(fields, key) && schema.additionalProperties === false)
59
+ return false;
60
+ }
61
+ }
62
+ return true;
63
+ }
64
+ /** Unknown/custom refinements are runtime checks; metadata cannot prove their assignability. */
65
+ export function compatibility(source, target) {
66
+ if (Array.isArray(source.anyOf)) {
67
+ const results = source.anyOf.map(item => compatibility(asSchema(item), target));
68
+ return results.includes(false) ? false : results.every(item => item === true) ? true : undefined;
69
+ }
70
+ if (Array.isArray(target.anyOf)) {
71
+ const results = target.anyOf.map(item => compatibility(source, asSchema(item)));
72
+ return results.includes(true) ? true : results.every(item => item === false) ? false : undefined;
73
+ }
74
+ if (target.allOf || source.allOf)
75
+ return undefined;
76
+ if (target.asterType) {
77
+ if (!source.asterType)
78
+ return source.type ? false : undefined;
79
+ if (source.asterType !== target.asterType)
80
+ return Array.isArray(source.asterBases) && source.asterBases.includes(target.asterType);
81
+ if (target.asterType === "signal")
82
+ return compatibility(asSchema(source.value), asSchema(target.value));
83
+ return true;
84
+ }
85
+ if (target.type) {
86
+ if (source.asterType)
87
+ return false;
88
+ if (!source.type)
89
+ return undefined;
90
+ if (source.type !== target.type && !(source.type === "integer" && target.type === "number"))
91
+ return false;
92
+ if (Array.isArray(target.enum)) {
93
+ if (!Array.isArray(source.enum))
94
+ return undefined;
95
+ if (!source.enum.every(value => target.enum.includes(value)))
96
+ return false;
97
+ }
98
+ if (Object.hasOwn(target, "const"))
99
+ return Object.hasOwn(source, "const") ? source.const === target.const : undefined;
100
+ if (target.minimum !== undefined || target.maximum !== undefined)
101
+ return undefined;
102
+ if (target.type === "array")
103
+ return compatibility(asSchema(source.items), asSchema(target.items));
104
+ if (target.type === "object") {
105
+ const sourceFields = asSchema(source.properties);
106
+ const targetFields = asSchema(target.properties);
107
+ if (target.additionalProperties === false && Object.keys(sourceFields).some(key => !Object.hasOwn(targetFields, key)))
108
+ return false;
109
+ const results = Object.entries(targetFields).map(([key, field]) => {
110
+ if (!Object.hasOwn(sourceFields, key))
111
+ return Array.isArray(target.required) && target.required.includes(key) ? false : true;
112
+ if (Array.isArray(target.required) && target.required.includes(key) && !(Array.isArray(source.required) && source.required.includes(key)))
113
+ return false;
114
+ return compatibility(asSchema(sourceFields[key]), asSchema(field));
115
+ });
116
+ if (results.includes(false))
117
+ return false;
118
+ if (target.additionalProperties === false && source.additionalProperties !== false)
119
+ return undefined;
120
+ return results.every(result => result === true) ? true : undefined;
121
+ }
122
+ return true;
123
+ }
124
+ return undefined;
125
+ }
126
+ /** JSON cannot express instance identity, signals or undefined as literal values. */
127
+ export function literalSchema(schema) {
128
+ if (schema.asterType)
129
+ return { not: {} };
130
+ const result = { ...schema };
131
+ if (schema.properties)
132
+ result.properties = Object.fromEntries(Object.entries(asSchema(schema.properties)).map(([key, field]) => [key, literalSchema(asSchema(field))]));
133
+ if (schema.items)
134
+ result.items = literalSchema(asSchema(schema.items));
135
+ for (const key of ["anyOf", "allOf", "oneOf"])
136
+ if (Array.isArray(schema[key]))
137
+ result[key] = schema[key].map(item => literalSchema(asSchema(item)));
138
+ return result;
139
+ }
@@ -0,0 +1,15 @@
1
+ import { KeyedSessions, type PageScope, type SessionActivity, type SessionDescriptor } from "@getaster/data";
2
+ import { type CompositeResolver, type CompositeSession } from "./registry.ts";
3
+ export interface CompositeSessionsOptions {
4
+ readonly parentScope?: PageScope;
5
+ readonly activity?: SessionActivity;
6
+ }
7
+ export interface CompositeKeyedSession extends CompositeSession {
8
+ /** Stable host for placement in Tabs, renderers or ordinary DOM. */
9
+ readonly element: HTMLElement;
10
+ }
11
+ /** Keyed registered components, including compiled composites via compositeComponent.
12
+ * Definitions consume this through ordinary model references and renderer bindings. */
13
+ export declare class CompositeSessions extends KeyedSessions<SessionDescriptor, CompositeKeyedSession> {
14
+ constructor(registry: CompositeResolver, options?: CompositeSessionsOptions);
15
+ }
@@ -0,0 +1,61 @@
1
+ import { effect } from "@preact/signals-core";
2
+ import { KeyedSessions } from "@getaster/data";
3
+ import { provideScope } from "../base/element.js";
4
+ import { checkContract } from "./registry.js";
5
+ /** Keyed registered components, including compiled composites via compositeComponent.
6
+ * Definitions consume this through ordinary model references and renderer bindings. */
7
+ export class CompositeSessions extends KeyedSessions {
8
+ constructor(registry, options = {}) {
9
+ super({
10
+ ...options,
11
+ validate: descriptor => {
12
+ const type = registry.component(descriptor.type);
13
+ if (type.mutableInputs?.length && !type.apply)
14
+ throw new Error(`Component '${descriptor.type}' needs apply for mutable inputs`);
15
+ for (const key of type.mutableInputs ?? [])
16
+ if (!Object.hasOwn(type.props, key))
17
+ throw new Error(`Unknown mutable input '${key}'`);
18
+ checkContract(descriptor.inputs ?? {}, type.props, `sessions.${descriptor.key}.inputs`);
19
+ },
20
+ checkUpdate: (previous, next) => {
21
+ const mutable = new Set(registry.component(next.type).mutableInputs ?? []);
22
+ for (const key of new Set([...Object.keys(previous.inputs ?? {}), ...Object.keys(next.inputs ?? {})])) {
23
+ if (!mutable.has(key) && !Object.is(previous.inputs?.[key], next.inputs?.[key]))
24
+ throw new Error(`Session '${next.key}' cannot change immutable input '${key}'`);
25
+ }
26
+ if (previous.inactive !== next.inactive)
27
+ throw new Error(`Session '${next.key}' cannot change inactive policy`);
28
+ },
29
+ create: async (descriptor, mount) => {
30
+ const type = registry.component(descriptor.type);
31
+ const create = await registry.load(descriptor.type);
32
+ mount.lifetime.signal.throwIfAborted();
33
+ const session = await create({ props: descriptor.inputs ?? {}, ...mount });
34
+ mount.lifetime.defer(() => session.dispose?.());
35
+ mount.lifetime.signal.throwIfAborted();
36
+ const element = document.createElement("div");
37
+ element.className = "aster-composite-session";
38
+ element.dataset.session = descriptor.key;
39
+ provideScope(element, session.scope ?? mount.scope);
40
+ element.append(session.element);
41
+ mount.lifetime.defer(effect(() => {
42
+ const active = mount.activity.active.value;
43
+ if (!active && element.contains(document.activeElement)) {
44
+ const target = element.parentElement?.closest('[tabindex]');
45
+ target?.focus();
46
+ }
47
+ element.hidden = !active;
48
+ element.inert = !active;
49
+ }));
50
+ return { element, ...(session.scope ? { scope: session.scope } : {}),
51
+ ...(session.canClose ? { canClose: signal => session.canClose(signal) } : {}),
52
+ ...(session.setActive ? { setActive: active => session.setActive(active) } : {}),
53
+ dispose: () => element.remove(), };
54
+ },
55
+ update: (resource, _previous, next) => {
56
+ const type = registry.component(next.type);
57
+ type.apply?.(resource.element.firstElementChild, next.inputs ?? {});
58
+ },
59
+ });
60
+ }
61
+ }
@@ -0,0 +1,26 @@
1
+ import { AsterElement, type View } from "../base/element.ts";
2
+ import { type DashboardConfig, type DashboardSize, type DashboardSnapshot, type DashboardWidget } from "./layout.ts";
3
+ import { type DashboardWidgetType } from "./widget.ts";
4
+ /** CSS-owned composition; the page owns storage, shared data and widget factories. */
5
+ export declare class Dashboard extends AsterElement {
6
+ #private;
7
+ get label(): string;
8
+ set label(value: string);
9
+ get widgets(): readonly DashboardWidget[];
10
+ get maximized(): string | null;
11
+ get busy(): boolean;
12
+ connectedCallback(): void;
13
+ registerType(name: string, type: DashboardWidgetType): () => void;
14
+ add(widget: DashboardWidget): void;
15
+ move(id: string, index: number): void;
16
+ resize(id: string, width: DashboardSize, height: DashboardSize): void;
17
+ configure(id: string, config: DashboardConfig, title?: string): void;
18
+ maximize(id: string | null): void;
19
+ removeWidget(id: string): Promise<boolean>;
20
+ retry(id: string): Promise<boolean>;
21
+ snapshot(): DashboardSnapshot;
22
+ restore(snapshot: DashboardSnapshot): Promise<boolean>;
23
+ disconnectedCallback(): void;
24
+ protected render(): View;
25
+ protected updated(): void;
26
+ }
@@ -0,0 +1,296 @@
1
+ import { PageScope } from "@getaster/data";
2
+ import { html } from "lit-html";
3
+ import { AsterElement, define, scopeOf } from "../base/element.js";
4
+ import { copyLayout } from "./layout.js";
5
+ import { DashboardPortlet } from "./widget.js";
6
+ /** CSS-owned composition; the page owns storage, shared data and widget factories. */
7
+ export class Dashboard extends AsterElement {
8
+ #label = this.prop("Dashboard");
9
+ #widgets = this.prop([]);
10
+ #maximized = this.prop(null);
11
+ #busy = this.prop(false);
12
+ #message = this.prop("");
13
+ #registryVersion = this.prop(0);
14
+ #types = new Map();
15
+ #cards = new Map();
16
+ #generation = 0;
17
+ #pending;
18
+ #focusAfter;
19
+ #dragEnd;
20
+ get label() { return this.#label.value; }
21
+ set label(value) { this.#label.value = value; }
22
+ get widgets() { return this.#widgets.value; }
23
+ get maximized() { return this.#maximized.value; }
24
+ get busy() { return this.#busy.value; }
25
+ connectedCallback() {
26
+ super.connectedCallback();
27
+ const report = (event) => {
28
+ if (event.target === this)
29
+ return;
30
+ event.stopPropagation();
31
+ this.emit("widgeterror", event.detail);
32
+ };
33
+ this.addEventListener("widgeterror", report);
34
+ this.onDisconnect(() => this.removeEventListener("widgeterror", report));
35
+ }
36
+ registerType(name, type) {
37
+ if (!name.trim() || this.#types.has(name) || typeof type.create !== "function")
38
+ throw new TypeError("Widget types require a unique name and create function");
39
+ this.#types.set(name, type);
40
+ this.#registryVersion.value++;
41
+ let registered = true;
42
+ return () => {
43
+ if (!registered)
44
+ return;
45
+ if (this.#widgets.peek().some(widget => widget.type === name))
46
+ throw new Error("Remove widgets before unregistering their type");
47
+ registered = false;
48
+ this.#types.delete(name);
49
+ };
50
+ }
51
+ add(widget) {
52
+ this.#assertIdle();
53
+ if (!this.#types.has(widget.type))
54
+ throw new Error(`Unknown widget type: ${widget.type}`);
55
+ this.#commit(copyLayout([...this.#widgets.peek(), widget]));
56
+ this.#focusAfter = widget.id;
57
+ this.#message.value = `${widget.title} added.`;
58
+ }
59
+ move(id, index) {
60
+ if (this.#busy.peek() || this.#maximized.peek())
61
+ return;
62
+ if (!Number.isInteger(index))
63
+ throw new TypeError("Widget position must be an integer");
64
+ const list = [...this.#widgets.peek()];
65
+ const from = this.#index(id);
66
+ const to = Math.max(0, Math.min(list.length - 1, index));
67
+ if (from === to)
68
+ return;
69
+ const [widget] = list.splice(from, 1);
70
+ list.splice(to, 0, widget);
71
+ this.#commit(list);
72
+ this.#message.value = `${widget.title} moved to position ${to + 1} of ${list.length}.`;
73
+ }
74
+ resize(id, width, height) {
75
+ if (this.#busy.peek() || this.#maximized.peek())
76
+ return;
77
+ const widget = this.#widgets.peek()[this.#index(id)];
78
+ const next = copyLayout(this.#widgets.peek().map(item => item.id === id ? { ...item, width, height } : item));
79
+ this.#commit(next);
80
+ this.#message.value = `${widget.title} size: ${width} columns by ${height} rows. Narrow screens may show fewer columns.`;
81
+ }
82
+ configure(id, config, title) {
83
+ this.#assertIdle();
84
+ this.#index(id);
85
+ this.#commit(copyLayout(this.#widgets.peek().map(widget => widget.id === id ? { ...widget, config, ...(title === undefined ? {} : { title }) } : widget)));
86
+ }
87
+ maximize(id) {
88
+ if (this.#busy.peek() || document.querySelector("dialog:modal"))
89
+ return;
90
+ if (id !== null)
91
+ this.#index(id);
92
+ const previous = this.#maximized.peek();
93
+ this.#maximized.value = id;
94
+ queueMicrotask(() => {
95
+ const card = this.#cards.get(id ?? previous ?? "");
96
+ card?.querySelector(".aster-widget-maximize")?.focus({ preventScroll: true });
97
+ });
98
+ }
99
+ removeWidget(id) {
100
+ const index = this.#index(id);
101
+ const widget = this.#widgets.peek()[index];
102
+ return this.#guard(`remove:${id}`, [id], () => {
103
+ const next = this.#widgets.peek().filter(item => item.id !== id);
104
+ this.#focusAfter = next[Math.min(index, next.length - 1)]?.id ?? null;
105
+ this.#commit(next);
106
+ this.#message.value = `${widget.title} removed.`;
107
+ });
108
+ }
109
+ retry(id) {
110
+ this.#index(id);
111
+ return this.#guard(`retry:${id}`, [id], () => {
112
+ const card = this.#cards.get(id);
113
+ const spec = this.#widgets.peek().find(widget => widget.id === id);
114
+ card?.dispose();
115
+ if (card)
116
+ void card.mount(this.#types.get(spec.type));
117
+ });
118
+ }
119
+ snapshot() { return { version: 1, widgets: copyLayout(this.#widgets.peek()) }; }
120
+ restore(snapshot) {
121
+ if (snapshot?.version !== 1)
122
+ throw new TypeError("Unsupported dashboard layout version");
123
+ const next = copyLayout(snapshot.widgets);
124
+ const removed = this.#widgets.peek().filter(widget => !next.some(item => item.id === widget.id && item.type === widget.type)).map(widget => widget.id);
125
+ return this.#guard(`layout:${JSON.stringify(next)}`, removed, () => {
126
+ const active = document.activeElement;
127
+ if (removed.some(id => this.#cards.get(id)?.contains(active)))
128
+ this.#focusAfter = next[0]?.id ?? null;
129
+ this.#commit(next);
130
+ this.#message.value = "Dashboard layout restored.";
131
+ });
132
+ }
133
+ disconnectedCallback() {
134
+ this.#generation++;
135
+ this.#pending = undefined;
136
+ this.#busy.value = false;
137
+ this.#dragEnd?.();
138
+ for (const card of this.#cards.values()) {
139
+ card.dispose();
140
+ card.remove();
141
+ }
142
+ this.#cards.clear();
143
+ super.disconnectedCallback();
144
+ }
145
+ render() {
146
+ this.#registryVersion.value;
147
+ this.#widgets.value;
148
+ this.#maximized.value;
149
+ return html `<section class="aster-dashboard" aria-label=${this.#label.value}>
150
+ <div class="aster-dashboard-status aster-sr-only" role="status" aria-live="polite">${this.#message.value}</div>
151
+ <div class="aster-dashboard-grid" role="list" aria-label=${`${this.#label.value} widgets`} tabindex="-1" aria-busy=${String(this.#busy.value)}></div>
152
+ ${this.#widgets.value.length ? html `` : html `<p class="aster-dashboard-empty">No widgets yet. Add a widget to build your dashboard.</p>`}
153
+ </section>`;
154
+ }
155
+ updated() {
156
+ const grid = this.querySelector(".aster-dashboard-grid");
157
+ const widgets = this.#widgets.peek();
158
+ const focused = document.activeElement;
159
+ for (const [id, card] of this.#cards) {
160
+ const spec = widgets.find(widget => widget.id === id);
161
+ if (!spec || card.dataset.type !== spec.type) {
162
+ card.dispose();
163
+ card.remove();
164
+ this.#cards.delete(id);
165
+ }
166
+ }
167
+ let cursor = grid.firstElementChild;
168
+ for (const widget of widgets) {
169
+ let card = this.#cards.get(widget.id);
170
+ if (!card) {
171
+ card = new DashboardPortlet();
172
+ card.dataset.type = widget.type;
173
+ card.initialize(widget, scopeOf(this)?.child() ?? new PageScope(), {
174
+ move: by => this.move(widget.id, this.#index(widget.id) + by),
175
+ resize: (w, h) => {
176
+ const current = this.#widgets.peek()[this.#index(widget.id)];
177
+ this.resize(widget.id, limit(current.width + w), limit(current.height + h));
178
+ },
179
+ maximize: () => this.maximize(this.#maximized.peek() === widget.id ? null : widget.id),
180
+ remove: () => { void this.removeWidget(widget.id); },
181
+ retry: () => { void this.retry(widget.id); },
182
+ drag: event => this.#drag(widget.id, event),
183
+ edges: () => ({ first: this.#index(widget.id) === 0, last: this.#index(widget.id) === this.#widgets.peek().length - 1 }),
184
+ });
185
+ this.#cards.set(widget.id, card);
186
+ }
187
+ card.update(widget, this.#maximized.peek() === widget.id, this.#busy.peek());
188
+ if (card !== cursor)
189
+ grid.insertBefore(card, cursor);
190
+ cursor = card.nextElementSibling;
191
+ void card.mount(this.#types.get(widget.type));
192
+ }
193
+ if (focused?.isConnected && grid.contains(focused) && document.activeElement !== focused)
194
+ focused.focus({ preventScroll: true });
195
+ const focus = this.#focusAfter;
196
+ if (focus !== undefined && !this.#busy.peek()) {
197
+ this.#focusAfter = undefined;
198
+ const generation = this.#generation;
199
+ requestAnimationFrame(() => {
200
+ if (generation !== this.#generation || !this.isConnected || document.querySelector("dialog:modal"))
201
+ return;
202
+ if (focus === null)
203
+ grid.focus({ preventScroll: true });
204
+ else
205
+ this.#cards.get(focus)?.focusActions();
206
+ });
207
+ }
208
+ }
209
+ #guard(key, ids, apply) {
210
+ if (this.#pending)
211
+ return this.#pending.key === key ? this.#pending.task : Promise.resolve(false);
212
+ const generation = this.#generation;
213
+ this.#busy.value = true;
214
+ const task = Promise.resolve().then(async () => {
215
+ for (const id of ids) {
216
+ if (!(await this.#cards.get(id)?.allowRemove() ?? true))
217
+ return false;
218
+ if (generation !== this.#generation || !this.isConnected)
219
+ return false;
220
+ }
221
+ if (generation !== this.#generation || !this.isConnected)
222
+ return false;
223
+ apply();
224
+ return true;
225
+ }).catch((error) => {
226
+ if (generation === this.#generation) {
227
+ this.#message.value = "Layout change failed. Your widgets remain open.";
228
+ this.emit("layouterror", error);
229
+ }
230
+ return false;
231
+ }).finally(() => {
232
+ if (generation === this.#generation) {
233
+ this.#pending = undefined;
234
+ this.#busy.value = false;
235
+ }
236
+ });
237
+ this.#pending = { key, task };
238
+ return task;
239
+ }
240
+ #commit(widgets) {
241
+ if (!widgets.some(widget => widget.id === this.#maximized.peek()))
242
+ this.#maximized.value = null;
243
+ this.#widgets.value = Object.freeze([...widgets]);
244
+ this.emit("layoutchange", this.snapshot());
245
+ }
246
+ #index(id) {
247
+ const index = this.#widgets.peek().findIndex(widget => widget.id === id);
248
+ if (index < 0)
249
+ throw new Error(`Unknown dashboard widget: ${id}`);
250
+ return index;
251
+ }
252
+ #assertIdle() { if (this.#busy.peek())
253
+ throw new Error("A dashboard layout change is pending"); }
254
+ #drag(id, event) {
255
+ if (event.button !== 0 || this.#busy.peek() || this.#maximized.peek())
256
+ return;
257
+ const handle = event.currentTarget;
258
+ this.#dragEnd?.();
259
+ let target;
260
+ handle.setPointerCapture(event.pointerId);
261
+ event.preventDefault();
262
+ handle.focus({ preventScroll: true });
263
+ const clear = () => { if (target)
264
+ delete target.dataset.drop; };
265
+ const move = (e) => {
266
+ clear();
267
+ const hit = document.elementFromPoint(e.clientX, e.clientY)?.closest("aster-dashboard-widget");
268
+ target = [...this.#cards.values()].find(card => card === hit && card.dataset.widget !== id);
269
+ if (target)
270
+ target.dataset.drop = "true";
271
+ };
272
+ const finish = () => {
273
+ clear();
274
+ handle.removeEventListener("pointermove", move);
275
+ handle.removeEventListener("pointerup", drop);
276
+ handle.removeEventListener("pointercancel", cancel);
277
+ handle.removeEventListener("keydown", escape);
278
+ this.#dragEnd = undefined;
279
+ };
280
+ const drop = () => { const to = target?.dataset.widget; finish(); if (to)
281
+ this.move(id, this.#index(to)); };
282
+ const cancel = () => { finish(); };
283
+ const escape = (e) => { if (e.key === "Escape") {
284
+ e.preventDefault();
285
+ e.stopPropagation();
286
+ cancel();
287
+ } };
288
+ handle.addEventListener("pointermove", move);
289
+ handle.addEventListener("pointerup", drop);
290
+ handle.addEventListener("pointercancel", cancel);
291
+ handle.addEventListener("keydown", escape);
292
+ this.#dragEnd = cancel;
293
+ }
294
+ }
295
+ function limit(value) { return Math.max(1, Math.min(3, value)); }
296
+ define("aster-dashboard", Dashboard);
@@ -0,0 +1,19 @@
1
+ export type DashboardValue = null | boolean | number | string | readonly DashboardValue[] | {
2
+ readonly [key: string]: DashboardValue;
3
+ };
4
+ export type DashboardConfig = Readonly<Record<string, DashboardValue>>;
5
+ export type DashboardSize = 1 | 2 | 3;
6
+ export interface DashboardWidget {
7
+ readonly id: string;
8
+ readonly type: string;
9
+ readonly title: string;
10
+ readonly width: DashboardSize;
11
+ readonly height: DashboardSize;
12
+ readonly config?: DashboardConfig;
13
+ }
14
+ export interface DashboardSnapshot {
15
+ readonly version: 1;
16
+ readonly widgets: readonly DashboardWidget[];
17
+ }
18
+ /** Validate and copy before a layout operation acquires or releases content. */
19
+ export declare function copyLayout(widgets: readonly DashboardWidget[]): readonly DashboardWidget[];
@@ -0,0 +1,48 @@
1
+ /** Validate and copy before a layout operation acquires or releases content. */
2
+ export function copyLayout(widgets) {
3
+ if (!Array.isArray(widgets))
4
+ throw new TypeError("Dashboard widgets must be an array");
5
+ const ids = new Set();
6
+ return Object.freeze(Array.from(widgets, widget => {
7
+ if (!widget || typeof widget.id !== "string" || !widget.id.trim() || ids.has(widget.id))
8
+ throw new TypeError("Dashboard widget IDs must be unique and non-empty");
9
+ ids.add(widget.id);
10
+ if (typeof widget.type !== "string" || !widget.type.trim() || typeof widget.title !== "string" || !widget.title.trim())
11
+ throw new TypeError("Widgets require a type and title");
12
+ if (![1, 2, 3].includes(widget.width) || ![1, 2, 3].includes(widget.height))
13
+ throw new TypeError("Widget sizes must be 1, 2 or 3");
14
+ const config = widget.config === undefined ? {} : widget.config;
15
+ if (!config || Array.isArray(config) || typeof config !== "object")
16
+ throw new TypeError("Widget configuration must be a JSON object");
17
+ return Object.freeze({ id: widget.id, type: widget.type, title: widget.title, width: widget.width, height: widget.height, config: copyValue(config, new Set(), 0) });
18
+ }));
19
+ }
20
+ function copyValue(value, seen, depth) {
21
+ if (depth > 32)
22
+ throw new TypeError("Widget configuration is too deeply nested");
23
+ switch (typeof value) {
24
+ case "string":
25
+ case "boolean": return value;
26
+ case "number":
27
+ if (Number.isFinite(value))
28
+ return value;
29
+ break;
30
+ case "object": {
31
+ if (value === null)
32
+ return null;
33
+ if (Object.getOwnPropertySymbols(value).length)
34
+ throw new TypeError("Widget configuration cannot contain symbol keys");
35
+ if (Array.isArray(value) && Object.keys(value).length !== value.length)
36
+ throw new TypeError("Widget arrays cannot have holes or extra properties");
37
+ if (seen.has(value))
38
+ throw new TypeError("Widget configuration cannot contain cycles");
39
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
40
+ break;
41
+ seen.add(value);
42
+ const copied = Array.isArray(value) ? Array.from(value, item => copyValue(item, seen, depth + 1)) : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyValue(item, seen, depth + 1)]));
43
+ seen.delete(value);
44
+ return Object.freeze(copied);
45
+ }
46
+ }
47
+ throw new TypeError("Widget configuration must contain only finite JSON values");
48
+ }
@@ -0,0 +1,42 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import type { PageScope } from "@getaster/data";
3
+ import { AsterElement, type View } from "../base/element.ts";
4
+ import type { DashboardConfig, DashboardWidget } from "./layout.ts";
5
+ export interface DashboardMount {
6
+ readonly id: string;
7
+ readonly scope: PageScope;
8
+ readonly config: ReadonlySignal<DashboardConfig>;
9
+ readonly signal: AbortSignal;
10
+ }
11
+ export interface DashboardSession {
12
+ render(): View;
13
+ dispose(): void;
14
+ beforeRemove?(): boolean | Promise<boolean>;
15
+ }
16
+ export interface DashboardWidgetType {
17
+ create(context: DashboardMount): DashboardSession | Promise<DashboardSession>;
18
+ }
19
+ interface Actions {
20
+ move(by: number): void;
21
+ resize(width: number, height: number): void;
22
+ maximize(): void;
23
+ remove(): void;
24
+ retry(): void;
25
+ drag(event: PointerEvent): void;
26
+ edges(): {
27
+ first: boolean;
28
+ last: boolean;
29
+ };
30
+ }
31
+ /** The dashboard owns sessions; native DOM moves only reconnect the view. */
32
+ export declare class DashboardPortlet extends AsterElement {
33
+ #private;
34
+ initialize(widget: DashboardWidget, scope: PageScope, actions: Actions): void;
35
+ update(widget: DashboardWidget, maximized: boolean, busy: boolean): void;
36
+ mount(type: DashboardWidgetType | undefined): Promise<void>;
37
+ allowRemove(): Promise<boolean>;
38
+ dispose(): void;
39
+ focusActions(): void;
40
+ protected render(): View;
41
+ }
42
+ export {};