@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,237 @@
1
+ import { computed } from "@preact/signals-core";
2
+ import { html, nothing } from "lit-html";
3
+ import { styleMap } from "lit-html/directives/style-map.js";
4
+ import { AsterElement, define, uid } from "../base/element.js";
5
+ const STEP = 16;
6
+ /**
7
+ * Two panes side by side with a resize bar between them. The bar is an ARIA
8
+ * `separator`: drag it, or focus it and use ←/→ (Home and End for the
9
+ * limits). The width is clamped to `min` and `max` and written as
10
+ * `--split-start`; CSS lays out the panes and stacks horizontal splits in narrow containers. ← and → follow the text direction.
11
+ * With `persist`, the width is kept per viewer under that name. The width
12
+ * asked for is kept apart from the width shown, which is always inside
13
+ * `min` and `max`, so changing any of them in any order re-clamps it.
14
+ */
15
+ export class Split extends AsterElement {
16
+ #wanted = this.prop(320);
17
+ #min = this.prop(220);
18
+ #max = this.prop(560);
19
+ #start = computed(() => Math.round(Math.min(this.#max.value, Math.max(this.#min.value, this.#wanted.value))));
20
+ #label = this.prop("Resize");
21
+ #persist = "";
22
+ #startView = this.prop(nothing);
23
+ #endView = this.prop(nothing);
24
+ #id = uid("aster-split");
25
+ #orientation = this.prop("horizontal");
26
+ #collapsible = this.prop(false);
27
+ #collapsed = this.prop(false);
28
+ #responsive = this.prop(true);
29
+ #actual = this.prop(undefined);
30
+ #resize;
31
+ #stopDrag;
32
+ get size() { return this.width; }
33
+ set size(v) { this.width = v; }
34
+ get orientation() { return this.#orientation.value; }
35
+ set orientation(v) { if (!["horizontal", "vertical"].includes(v))
36
+ throw new TypeError("Unknown split orientation"); this.#orientation.value = v; }
37
+ get collapsible() { return this.#collapsible.value; }
38
+ set collapsible(v) { this.#collapsible.value = v; }
39
+ get collapsed() { return this.#collapsed.value; }
40
+ set collapsed(v) {
41
+ if (v && this.querySelector("[data-pane=start]")?.contains(document.activeElement)) {
42
+ const bar = this.querySelector(":scope > .aster-split-layout > [role=separator]");
43
+ (bar?.getClientRects().length ? bar : this.querySelector(":scope > .aster-split-tools button, :scope > .aster-split-layout > [data-pane=end]"))?.focus();
44
+ }
45
+ if (v !== this.#collapsed.peek())
46
+ this.#actual.value = undefined;
47
+ this.#collapsed.value = v;
48
+ }
49
+ get responsive() { return this.#responsive.value; }
50
+ set responsive(v) { this.#responsive.value = v; }
51
+ disconnectedCallback() {
52
+ this.#resize?.disconnect();
53
+ this.#resize = undefined;
54
+ this.#stopDrag?.();
55
+ super.disconnectedCallback();
56
+ }
57
+ updated() {
58
+ if (this.#resize)
59
+ return;
60
+ const pane = this.querySelector("[data-pane=start]");
61
+ if (!pane)
62
+ return;
63
+ this.#resize = new ResizeObserver(() => {
64
+ const box = pane.getBoundingClientRect();
65
+ this.#actual.value = Math.round(this.#orientation.peek() === "horizontal" ? box.width : box.height);
66
+ });
67
+ this.#resize.observe(pane);
68
+ }
69
+ get start() {
70
+ return this.#startView.value;
71
+ }
72
+ set start(v) {
73
+ this.#startView.value = v;
74
+ }
75
+ get end() {
76
+ return this.#endView.value;
77
+ }
78
+ set end(v) {
79
+ this.#endView.value = v;
80
+ }
81
+ get width() {
82
+ return this.#start.value;
83
+ }
84
+ set width(v) {
85
+ if (!Number.isFinite(v) || v < 0)
86
+ throw new TypeError("Split size must be a finite nonnegative number");
87
+ this.#wanted.value = v;
88
+ }
89
+ get min() {
90
+ return this.#min.value;
91
+ }
92
+ set min(v) {
93
+ if (!Number.isFinite(v) || v < 0)
94
+ throw new TypeError("Split minimum must be nonnegative");
95
+ this.#min.value = v;
96
+ }
97
+ get max() {
98
+ return this.#max.value;
99
+ }
100
+ set max(v) {
101
+ if (!Number.isFinite(v) || v < 0)
102
+ throw new TypeError("Split maximum must be nonnegative");
103
+ this.#max.value = v;
104
+ }
105
+ /** The separator's accessible name. */
106
+ get label() {
107
+ return this.#label.value;
108
+ }
109
+ set label(v) {
110
+ this.#label.value = v;
111
+ }
112
+ get persist() {
113
+ return this.#persist;
114
+ }
115
+ set persist(v) {
116
+ this.#persist = v;
117
+ try {
118
+ const saved = Number(localStorage.getItem(`aster-split:${v}`));
119
+ if (saved)
120
+ this.#wanted.value = saved;
121
+ }
122
+ catch {
123
+ // Storage can be unavailable; the default width stands.
124
+ }
125
+ }
126
+ render() {
127
+ const width = this.#start.value, vertical = this.#orientation.value === "vertical", collapsed = this.#collapsed.value;
128
+ this.style.setProperty("--split-start", `${width}px`);
129
+ return html `${this.#collapsible.value ? html `<div class="aster-split-tools"><button type="button" aria-expanded=${!collapsed} aria-controls=${`${this.#id}-start`} @click=${() => this.collapsed = !collapsed}>${collapsed ? "Restore pane" : "Collapse pane"}</button></div>` : nothing}
130
+ <div class="aster-split-layout" data-orientation=${this.#orientation.value} ?data-responsive=${this.#responsive.value} ?data-collapsed=${collapsed}
131
+ style=${styleMap({ "--split-start": `${width}px` })}>
132
+ <div class="aster-split-pane" tabindex="0" data-pane="start" id=${`${this.#id}-start`} ?inert=${collapsed} aria-hidden=${collapsed ? "true" : nothing}>${this.#startView.value}</div>
133
+ <div class="aster-split-bar" role="separator" tabindex="0" aria-orientation=${vertical ? "horizontal" : "vertical"}
134
+ aria-label=${this.#label.value} aria-controls=${`${this.#id}-start`}
135
+ aria-valuenow=${collapsed ? 0 : Math.min(this.#max.value, this.#actual.value ?? width)} aria-valuemin=${this.#collapsible.value || collapsed ? 0 : Math.min(this.#min.value, this.#actual.value ?? width)} aria-valuemax=${this.#max.value}
136
+ aria-valuetext=${collapsed ? "Collapsed" : `${this.#actual.value ?? width} pixels`}
137
+ @pointerdown=${(e) => this.#drag(e)}
138
+ @keydown=${(e) => this.#key(e)}></div>
139
+ <div class="aster-split-pane" tabindex="0" data-pane="end">${this.#endView.value}</div></div>`;
140
+ }
141
+ #drag(e) {
142
+ if (e.button !== 0 || this.#collapsed.peek())
143
+ return;
144
+ this.#stopDrag?.();
145
+ const bar = e.currentTarget;
146
+ bar.focus();
147
+ e.preventDefault();
148
+ bar.setPointerCapture(e.pointerId);
149
+ const vertical = this.#orientation.peek() === "vertical";
150
+ const from = vertical ? e.clientY : e.clientX, width = this.#start.peek();
151
+ const dir = !vertical && getComputedStyle(this).direction === "rtl" ? -1 : 1;
152
+ const move = (m) => (this.#wanted.value = this.#clamp(width + ((vertical ? m.clientY : m.clientX) - from) * dir));
153
+ const cleanup = () => {
154
+ bar.removeEventListener("pointermove", move);
155
+ bar.removeEventListener("pointerup", up);
156
+ bar.removeEventListener("pointercancel", cancel);
157
+ bar.removeEventListener("lostpointercapture", cancel);
158
+ document.removeEventListener("keydown", escape, true);
159
+ this.#stopDrag = undefined;
160
+ if (bar.hasPointerCapture(e.pointerId))
161
+ bar.releasePointerCapture(e.pointerId);
162
+ };
163
+ const cancel = () => { this.#wanted.value = width; cleanup(); };
164
+ const up = () => { cleanup(); this.#save(); };
165
+ const escape = (key) => { if (key.key === "Escape") {
166
+ key.preventDefault();
167
+ key.stopPropagation();
168
+ cancel();
169
+ } };
170
+ this.#stopDrag = cancel;
171
+ bar.addEventListener("pointermove", move);
172
+ bar.addEventListener("pointerup", up);
173
+ bar.addEventListener("pointercancel", cancel);
174
+ bar.addEventListener("lostpointercapture", cancel);
175
+ document.addEventListener("keydown", escape, true);
176
+ }
177
+ #key(e) {
178
+ const width = this.#start.peek();
179
+ if (e.key === "Enter" && this.#collapsible.peek()) {
180
+ e.preventDefault();
181
+ this.collapsed = !this.collapsed;
182
+ return;
183
+ }
184
+ if (this.#collapsed.peek())
185
+ return;
186
+ const vertical = this.#orientation.peek() === "vertical";
187
+ const forward = !vertical && getComputedStyle(this).direction === "rtl" ? -STEP : STEP;
188
+ let next;
189
+ switch (e.key) {
190
+ case "ArrowLeft":
191
+ if (vertical)
192
+ return;
193
+ next = width - forward;
194
+ break;
195
+ case "ArrowRight":
196
+ if (vertical)
197
+ return;
198
+ next = width + forward;
199
+ break;
200
+ case "ArrowUp":
201
+ if (!vertical)
202
+ return;
203
+ next = width - STEP;
204
+ break;
205
+ case "ArrowDown":
206
+ if (!vertical)
207
+ return;
208
+ next = width + STEP;
209
+ break;
210
+ case "Home":
211
+ next = this.#min.peek();
212
+ break;
213
+ case "End":
214
+ next = this.#max.peek();
215
+ break;
216
+ default:
217
+ return;
218
+ }
219
+ e.preventDefault();
220
+ this.#wanted.value = this.#clamp(next);
221
+ this.#save();
222
+ }
223
+ #clamp(v) {
224
+ return Math.round(Math.min(this.#max.peek(), Math.max(this.#min.peek(), v)));
225
+ }
226
+ #save() {
227
+ if (!this.#persist)
228
+ return;
229
+ try {
230
+ localStorage.setItem(`aster-split:${this.#persist}`, String(this.#start.peek()));
231
+ }
232
+ catch {
233
+ // Not kept; the width still applies now.
234
+ }
235
+ }
236
+ }
237
+ define("aster-split", Split);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The token contract: every custom property an identity must define, by
3
+ * role. Elements and apps style themselves only through these, so an
4
+ * identity is a complete set of values and nothing else.
5
+ *
6
+ * A dark scheme (`:root[data-scheme="dark"]`) may override any of them and
7
+ * needs to define none.
8
+ */
9
+ export declare const TOKENS: {
10
+ readonly type: readonly ["font-ui", "font-figure", "font-size", "line", "weight", "weight-strong", "weight-label", "figure-settings", "title-settings", "heading-settings", "prose-settings"];
11
+ readonly surfaces: readonly ["page", "surface", "sunk", "pane", "backdrop", "frame", "texture", "texture-blend", "texture-opacity"];
12
+ readonly text: readonly ["text", "text-2", "text-3"];
13
+ /** `mark-ring` outlines identity dots and avatars so a pale hue still shows. */
14
+ readonly edges: readonly ["rule", "rule-2", "mark-ring"];
15
+ /** Accent acts (buttons, links, focus); indicator only marks where to look (the caret). */
16
+ readonly accent: readonly ["accent", "accent-hi", "accent-soft", "on-accent", "accent-sheen", "focus", "focus-offset", "indicator"];
17
+ readonly status: readonly ["positive", "negative", "caution"];
18
+ /** Identity colours take their hue from data and lightness and chroma from here. */
19
+ readonly identity: readonly ["identity-l", "identity-c"];
20
+ /** `press` is the inset shadow colour of a pressed primary button. */
21
+ readonly elevation: readonly ["shadow", "highlight", "ring", "e1", "e2", "e3", "well", "scrim", "press"];
22
+ readonly shape: readonly ["r-ctl", "r-card", "r-pop", "r-dialog"];
23
+ readonly motion: readonly ["ease", "t-morph", "t-ink", "t-micro", "t-caret", "t-scheme"];
24
+ readonly toast: readonly ["toast-bg", "toast-text", "toast-action", "toast-mark"];
25
+ /** Lamplight's lamp, which other identities set to something neutral; `lamp-bulb` is the glow round the switch's bulb. */
26
+ readonly light: readonly ["lamp", "glow", "lamp-bulb"];
27
+ };
28
+ export declare const TOKEN_NAMES: readonly string[];
29
+ /**
30
+ * Sizes that follow density rather than identity: control height, a tighter
31
+ * control for grids, vertical padding of grid cells and listbox rows, of
32
+ * single-line rows, and of panels and bars, and the gap between fields. `base.css` defines them for each density.
33
+ */
34
+ export declare const DENSITY: readonly ["ctl", "ctl-tight", "pad-y", "row-y", "pad", "gap"];
35
+ export declare const DENSITIES: readonly ["compact", "standard", "comfortable"];
36
+ export declare const DENSITY_NAMES: readonly string[];
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The token contract: every custom property an identity must define, by
3
+ * role. Elements and apps style themselves only through these, so an
4
+ * identity is a complete set of values and nothing else.
5
+ *
6
+ * A dark scheme (`:root[data-scheme="dark"]`) may override any of them and
7
+ * needs to define none.
8
+ */
9
+ export const TOKENS = {
10
+ type: [
11
+ "font-ui", "font-figure", "font-size", "line", "weight", "weight-strong", "weight-label",
12
+ "figure-settings", "title-settings", "heading-settings", "prose-settings",
13
+ ],
14
+ surfaces: ["page", "surface", "sunk", "pane", "backdrop", "frame", "texture", "texture-blend", "texture-opacity"],
15
+ text: ["text", "text-2", "text-3"],
16
+ /** `mark-ring` outlines identity dots and avatars so a pale hue still shows. */
17
+ edges: ["rule", "rule-2", "mark-ring"],
18
+ /** Accent acts (buttons, links, focus); indicator only marks where to look (the caret). */
19
+ accent: ["accent", "accent-hi", "accent-soft", "on-accent", "accent-sheen", "focus", "focus-offset", "indicator"],
20
+ status: ["positive", "negative", "caution"],
21
+ /** Identity colours take their hue from data and lightness and chroma from here. */
22
+ identity: ["identity-l", "identity-c"],
23
+ /** `press` is the inset shadow colour of a pressed primary button. */
24
+ elevation: ["shadow", "highlight", "ring", "e1", "e2", "e3", "well", "scrim", "press"],
25
+ shape: ["r-ctl", "r-card", "r-pop", "r-dialog"],
26
+ motion: ["ease", "t-morph", "t-ink", "t-micro", "t-caret", "t-scheme"],
27
+ toast: ["toast-bg", "toast-text", "toast-action", "toast-mark"],
28
+ /** Lamplight's lamp, which other identities set to something neutral; `lamp-bulb` is the glow round the switch's bulb. */
29
+ light: ["lamp", "glow", "lamp-bulb"],
30
+ };
31
+ export const TOKEN_NAMES = Object.values(TOKENS).flat().map((t) => `--aster-${t}`);
32
+ /**
33
+ * Sizes that follow density rather than identity: control height, a tighter
34
+ * control for grids, vertical padding of grid cells and listbox rows, of
35
+ * single-line rows, and of panels and bars, and the gap between fields. `base.css` defines them for each density.
36
+ */
37
+ export const DENSITY = ["ctl", "ctl-tight", "pad-y", "row-y", "pad", "gap"];
38
+ export const DENSITIES = ["compact", "standard", "comfortable"];
39
+ export const DENSITY_NAMES = DENSITY.map((t) => `--aster-${t}`);
@@ -0,0 +1,22 @@
1
+ import type { DataContext, KeyText } from "@getaster/data";
2
+ import { AsterElement, type View } from "../base/element.ts";
3
+ export interface SwitchBinding {
4
+ readonly ctx: DataContext;
5
+ readonly set: string;
6
+ readonly key: KeyText;
7
+ readonly field: string;
8
+ }
9
+ /**
10
+ * A row with a switch, bound to one Boolean field of a stored record. The
11
+ * whole row toggles it. A toggle saves straight away: the switch shows the
12
+ * new state while the save runs, and if the save fails it goes back and the
13
+ * error shows under the row. The label is the field's title from metadata.
14
+ */
15
+ export declare class Switch extends AsterElement {
16
+ #private;
17
+ get binding(): SwitchBinding | undefined;
18
+ set binding(v: SwitchBinding | undefined);
19
+ get label(): string;
20
+ set label(v: string);
21
+ protected render(): View;
22
+ }
@@ -0,0 +1,69 @@
1
+ import { ODataError } from "@getaster/data";
2
+ import { html, nothing } from "lit-html";
3
+ import { AsterElement, define, uid } from "../base/element.js";
4
+ /**
5
+ * A row with a switch, bound to one Boolean field of a stored record. The
6
+ * whole row toggles it. A toggle saves straight away: the switch shows the
7
+ * new state while the save runs, and if the save fails it goes back and the
8
+ * error shows under the row. The label is the field's title from metadata.
9
+ */
10
+ export class Switch extends AsterElement {
11
+ #binding = this.prop(undefined);
12
+ #label = this.prop("");
13
+ #pending = this.prop(undefined);
14
+ #error = this.prop(undefined);
15
+ #id = uid("aster-switch");
16
+ get binding() {
17
+ return this.#binding.value;
18
+ }
19
+ set binding(v) {
20
+ this.#binding.value = v;
21
+ }
22
+ get label() {
23
+ return this.#label.value;
24
+ }
25
+ set label(v) {
26
+ this.#label.value = v;
27
+ }
28
+ render() {
29
+ const b = this.#binding.value;
30
+ if (!b)
31
+ return nothing;
32
+ const stored = b.ctx.store.record(b.set, b.key).value?.entity[b.field];
33
+ const pending = this.#pending.value;
34
+ const on = pending ?? stored === true;
35
+ const label = this.#label.value || b.ctx.schema.set(b.set).type.field(b.field)?.title || b.field;
36
+ const error = this.#error.value;
37
+ return html `<div class="aster-switch-row" data-click @click=${(e) => this.#rowClick(e)}>
38
+ <span id=${`${this.#id}-label`}>${label}</span>
39
+ <button type="button" class="aster-switch" role="switch" aria-checked=${on ? "true" : "false"}
40
+ aria-labelledby=${`${this.#id}-label`} aria-busy=${pending === undefined ? "false" : "true"}
41
+ aria-describedby=${error ? `${this.#id}-error` : nothing}
42
+ @click=${() => this.#toggle(!on)}></button>
43
+ </div>
44
+ ${error ? html `<p class="aster-error" id=${`${this.#id}-error`} role="alert">${error}</p>` : nothing}`;
45
+ }
46
+ #rowClick(e) {
47
+ if (e.target.closest('[role="switch"]'))
48
+ return;
49
+ this.querySelector('[role="switch"]')?.click();
50
+ }
51
+ async #toggle(to) {
52
+ const b = this.#binding.peek();
53
+ if (!b || this.#pending.peek() !== undefined)
54
+ return;
55
+ this.#pending.value = to;
56
+ this.#error.value = undefined;
57
+ try {
58
+ await b.ctx.update(b.set, b.key, { [b.field]: to }, this);
59
+ }
60
+ catch (e) {
61
+ const what = to ? "turn this on" : "turn this off";
62
+ this.#error.value = `Couldn’t ${what}: ${e instanceof ODataError ? e.message : String(e)} It’s back as it was.`;
63
+ }
64
+ finally {
65
+ this.#pending.value = undefined;
66
+ }
67
+ }
68
+ }
69
+ define("aster-switch", Switch);
@@ -0,0 +1,30 @@
1
+ import { AsterElement, type Bindable, type View } from "../base/element.ts";
2
+ import "../caret/caret-group.ts";
3
+ export interface TabItem {
4
+ readonly value: string;
5
+ readonly label: string;
6
+ readonly closable?: boolean;
7
+ readonly disabled?: boolean;
8
+ }
9
+ /** Controlled tabs with optional retained lazy panels and owner-managed close requests. */
10
+ export declare class Tabs extends AsterElement {
11
+ #private;
12
+ /** Render on first activation; keep the panel mounted until its item is removed. */
13
+ get renderPanel(): ((item: TabItem) => View) | undefined;
14
+ set renderPanel(v: ((item: TabItem) => View) | undefined);
15
+ get activation(): "automatic" | "manual";
16
+ set activation(v: "automatic" | "manual");
17
+ get items(): Bindable<readonly TabItem[]>;
18
+ set items(v: Bindable<readonly TabItem[]>);
19
+ get value(): Bindable<string | undefined>;
20
+ set value(v: Bindable<string | undefined>);
21
+ /** The tablist's accessible name. */
22
+ get label(): string;
23
+ set label(v: string);
24
+ /** The id of the panel the tabs control. */
25
+ get controls(): string | undefined;
26
+ set controls(v: string | undefined);
27
+ protected render(): View;
28
+ protected updated(): void;
29
+ disconnectedCallback(): void;
30
+ }
@@ -0,0 +1,202 @@
1
+ import { html, nothing } from "lit-html";
2
+ import { ifDefined } from "lit-html/directives/if-defined.js";
3
+ import { AsterElement, define, each, read, uid } from "../base/element.js";
4
+ import "../caret/caret-group.js";
5
+ /** Controlled tabs with optional retained lazy panels and owner-managed close requests. */
6
+ export class Tabs extends AsterElement {
7
+ #items = this.prop([]);
8
+ #value = this.prop(undefined);
9
+ #label = this.prop("");
10
+ #controls = this.prop(undefined);
11
+ #id = uid("aster-tab");
12
+ /** The panel last labelled, so a new `controls` or a disconnect clears the old one. */
13
+ #panel = null;
14
+ #renderPanel = this.prop(undefined);
15
+ #activation = this.prop("automatic");
16
+ #overflow = this.prop(false);
17
+ #visited = new Set();
18
+ #observer;
19
+ #resizeFrame = 0;
20
+ #selected;
21
+ #focusAfterClose = false;
22
+ /** Render on first activation; keep the panel mounted until its item is removed. */
23
+ get renderPanel() { return this.#renderPanel.value; }
24
+ set renderPanel(v) { this.#renderPanel.value = v; }
25
+ get activation() { return this.#activation.value; }
26
+ set activation(v) { this.#activation.value = v; }
27
+ get items() {
28
+ return this.#items.value;
29
+ }
30
+ set items(v) {
31
+ this.#items.value = v;
32
+ }
33
+ get value() {
34
+ return this.#value.value;
35
+ }
36
+ set value(v) {
37
+ this.#value.value = v;
38
+ }
39
+ /** The tablist's accessible name. */
40
+ get label() {
41
+ return this.#label.value;
42
+ }
43
+ set label(v) {
44
+ this.#label.value = v;
45
+ }
46
+ /** The id of the panel the tabs control. */
47
+ get controls() {
48
+ return this.#controls.value;
49
+ }
50
+ set controls(v) {
51
+ this.#controls.value = v;
52
+ }
53
+ render() {
54
+ const items = read(this.#items.value);
55
+ const active = this.#active(items);
56
+ const renderer = this.#renderPanel.value;
57
+ const separateActions = items.some(t => t.closable);
58
+ for (const value of this.#visited)
59
+ if (!items.some(t => t.value === value))
60
+ this.#visited.delete(value);
61
+ if (renderer && active !== undefined)
62
+ this.#visited.add(active);
63
+ // Close buttons remain siblings; the tablist owns only tabs in the accessibility tree.
64
+ return html `${separateActions ? html `<span class="aster-sr-only" role="tablist" aria-label=${this.#label.value}
65
+ aria-owns=${items.map(t => this.#tabId(t.value)).join(" ")}></span>` : nothing}
66
+ <div class="aster-tabs-strip">
67
+ <button type="button" class="aster-tabs-scroll" aria-label="Scroll tabs backward" ?hidden=${!this.#overflow.value}
68
+ @click=${() => this.#scroll(-1)}><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="m10 4-4 4 4 4"/></svg></button>
69
+ <div class="aster-tabs-viewport">
70
+ <aster-caret-group axis="x" class="aster-tabs" role=${separateActions || items.length === 0 ? "presentation" : "tablist"} aria-label=${ifDefined(separateActions || items.length === 0 ? undefined : this.#label.value)}
71
+ @keydown=${(e) => this.#key(e, items)}>
72
+ ${each(items, t => t.value, t => html `<span class="aster-tab-item" role="presentation">
73
+ <button type="button" role="tab" id=${this.#tabId(t.value)} data-value=${t.value} title=${t.label}
74
+ aria-selected=${t.value === active ? "true" : "false"} ?disabled=${t.disabled}
75
+ aria-controls=${ifDefined(renderer ? this.#panelId(t.value) : this.#controls.value)}
76
+ tabindex=${t.value === active ? 0 : -1}
77
+ @click=${() => this.#choose(t.value)}>${t.label}</button>
78
+ ${t.closable ? html `<button type="button" class="aster-tab-close" aria-label=${`Close ${t.label}`}
79
+ ?disabled=${t.disabled} tabindex=${t.value === active ? 0 : -1}
80
+ @click=${() => this.#close(t.value)}><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button>` : nothing}
81
+ </span>`)}
82
+ </aster-caret-group>
83
+ </div>
84
+ <button type="button" class="aster-tabs-scroll" aria-label="Scroll tabs forward" ?hidden=${!this.#overflow.value}
85
+ @click=${() => this.#scroll(1)}><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="m6 4 4 4-4 4"/></svg></button>
86
+ </div>
87
+ ${renderer ? each(items, t => t.value, t => html `<div role="tabpanel" id=${this.#panelId(t.value)}
88
+ aria-labelledby=${this.#tabId(t.value)} tabindex="0" ?hidden=${t.value !== active}
89
+ >${this.#visited.has(t.value) ? renderer(t) : nothing}</div>`) : nothing}`;
90
+ }
91
+ #active(items) {
92
+ const value = read(this.#value.value);
93
+ return items.find(t => t.value === value && !t.disabled)?.value ?? items.find(t => !t.disabled)?.value;
94
+ }
95
+ #panelId(value) { return `${this.#tabId(value)}-panel`; }
96
+ #scroll(direction) {
97
+ const viewport = this.querySelector(".aster-tabs-viewport");
98
+ if (viewport)
99
+ viewport.scrollBy({ left: direction * viewport.clientWidth * (getComputedStyle(this).direction === "rtl" ? -1 : 1), behavior: "instant" });
100
+ }
101
+ #close(value) {
102
+ this.#focusAfterClose = this.contains(document.activeElement);
103
+ this.emit("close", value);
104
+ queueMicrotask(() => {
105
+ if (this.#focusAfterClose && !read(this.#items.peek()).some(t => t.value === value))
106
+ this.#focusSelected();
107
+ this.#focusAfterClose = false;
108
+ });
109
+ }
110
+ #revealSelected() {
111
+ this.querySelector(".aster-tabs")?.querySelector('[role="tab"][aria-selected="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
112
+ }
113
+ #focusSelected() {
114
+ this.querySelector(".aster-tabs")?.querySelector('[role="tab"][aria-selected="true"]')?.focus();
115
+ }
116
+ /** Stable for a value while the element lives; encoded so any value is a valid single id token. */
117
+ #tabId(value) {
118
+ return `${this.#id}-${encodeURIComponent(value)}`;
119
+ }
120
+ updated() {
121
+ const viewport = this.querySelector(".aster-tabs-viewport");
122
+ if (!this.#observer) {
123
+ this.#observer = new ResizeObserver(() => {
124
+ if (this.#resizeFrame)
125
+ return;
126
+ this.#resizeFrame = requestAnimationFrame(() => {
127
+ this.#resizeFrame = 0;
128
+ this.#overflow.value = viewport.scrollWidth > viewport.parentElement.clientWidth + 1;
129
+ this.#revealSelected();
130
+ });
131
+ });
132
+ this.#observer.observe(viewport);
133
+ this.#observer.observe(this.querySelector(".aster-tabs"));
134
+ }
135
+ const active = this.#active(read(this.#items.peek()));
136
+ if (active !== this.#selected) {
137
+ this.#selected = active;
138
+ this.#revealSelected();
139
+ }
140
+ const id = this.#renderPanel.peek() ? undefined : this.#controls.peek();
141
+ const panel = id ? this.getRootNode().getElementById(id) : null;
142
+ if (this.#panel && this.#panel !== panel)
143
+ this.#panel.removeAttribute("aria-labelledby");
144
+ this.#panel = panel;
145
+ const selected = this.querySelector(".aster-tabs")?.querySelector('[role="tab"][aria-selected="true"]');
146
+ if (panel && selected)
147
+ panel.setAttribute("aria-labelledby", selected.id);
148
+ else
149
+ panel?.removeAttribute("aria-labelledby");
150
+ }
151
+ disconnectedCallback() {
152
+ super.disconnectedCallback();
153
+ this.#panel?.removeAttribute("aria-labelledby");
154
+ this.#panel = null;
155
+ this.#observer?.disconnect();
156
+ this.#observer = undefined;
157
+ cancelAnimationFrame(this.#resizeFrame);
158
+ this.#resizeFrame = 0;
159
+ }
160
+ #choose(value) {
161
+ if (value !== this.#active(read(this.#items.peek())))
162
+ this.emit("change", value);
163
+ }
164
+ #key(e, items) {
165
+ if (!(e.target instanceof HTMLElement) || e.target.getAttribute("role") !== "tab")
166
+ return;
167
+ const tabs = [...this.querySelector(".aster-tabs").querySelectorAll('[role="tab"]:not(:disabled)')];
168
+ const i = tabs.indexOf(e.target);
169
+ if (i < 0)
170
+ return;
171
+ const item = items.find(t => t.value === tabs[i].dataset.value);
172
+ if (e.key === "Delete" && item.closable) {
173
+ e.preventDefault();
174
+ this.#close(item.value);
175
+ return;
176
+ }
177
+ const rtl = getComputedStyle(this).direction === "rtl";
178
+ let n;
179
+ switch (e.key) {
180
+ case "Home":
181
+ n = 0;
182
+ break;
183
+ case "End":
184
+ n = tabs.length - 1;
185
+ break;
186
+ case "ArrowRight":
187
+ n = (i + (rtl ? -1 : 1) + tabs.length) % tabs.length;
188
+ break;
189
+ case "ArrowLeft":
190
+ n = (i + (rtl ? 1 : -1) + tabs.length) % tabs.length;
191
+ break;
192
+ default: return;
193
+ }
194
+ e.preventDefault();
195
+ tabs.forEach((tab, index) => { tab.tabIndex = index === n ? 0 : -1; });
196
+ tabs[n].focus();
197
+ tabs[n].scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
198
+ if (this.#activation.peek() === "automatic")
199
+ this.#choose(tabs[n].dataset.value);
200
+ }
201
+ }
202
+ define("aster-tabs", Tabs);
@@ -0,0 +1,9 @@
1
+ /** Projects measured logical row offsets into a browser-safe scroll extent. */
2
+ export declare class TileScroll {
3
+ #private;
4
+ readonly extent: number;
5
+ constructor(rows: number, stride: number, height: number, gap: number);
6
+ logical(top: number): number;
7
+ physical(top: number): number;
8
+ rowTop(offset: number, top: number): number;
9
+ }
@@ -0,0 +1,21 @@
1
+ /** Projects measured logical row offsets into a browser-safe scroll extent. */
2
+ export class TileScroll {
3
+ extent;
4
+ #logical;
5
+ #physical;
6
+ constructor(rows, stride, height, gap) {
7
+ const natural = Math.max(0, rows * stride - gap);
8
+ this.extent = Math.min(8_000_000, natural);
9
+ this.#logical = Math.max(0, natural - height);
10
+ this.#physical = Math.max(0, this.extent - height);
11
+ }
12
+ logical(top) {
13
+ return this.#physical ? Math.max(0, Math.min(this.#physical, top)) * this.#logical / this.#physical : 0;
14
+ }
15
+ physical(top) {
16
+ return this.#logical ? Math.max(0, Math.min(this.#logical, top)) * this.#physical / this.#logical : 0;
17
+ }
18
+ rowTop(offset, top) {
19
+ return offset - (this.logical(top) - top);
20
+ }
21
+ }