@getaster/elements 0.0.0-stage → 0.1.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +2584 -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 +1145 -0
  159. package/styles/base.css +86 -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,286 @@
1
+ import { html, nothing } from "lit-html";
2
+ import { AsterElement, define, uid } from "../base/element.js";
3
+ import { openWhen, placeStyle } from "../overlay/anchor.js";
4
+ /**
5
+ * A menu in the top layer (Popover API), following the APG menu pattern: it
6
+ * opens at a point (a context menu) or against an element, focuses its
7
+ * first item, and moves with the arrows, Home, End and type-ahead. → opens a
8
+ * submenu and ← closes it; check and radio items toggle in place. Escape
9
+ * closes one level, a press outside or focus leaving closes all, and focus
10
+ * returns to the opener. Disabled items stay focusable and say why.
11
+ *
12
+ * The popovers are manual because an auto popover opened from `contextmenu`
13
+ * is dismissed by the same click's pointer-up in some engines. A menu opened
14
+ * at a point writes only the point; one opened against an element, and every
15
+ * submenu, is placed by CSS anchor positioning and flips at the edges.
16
+ */
17
+ export class Menu extends AsterElement {
18
+ #items = this.prop([]);
19
+ #label = this.prop("");
20
+ /** The open submenus: indexes into each level's focusable items. */
21
+ #path = this.prop([]);
22
+ #open = this.prop(false);
23
+ #anchor = this.prop(undefined);
24
+ /** Check and radio marks changed since the menu opened; items keep the state they were given. */
25
+ #marks = this.prop(new Map());
26
+ #id = uid("aster-menu");
27
+ #opener = null;
28
+ /** The item to focus once its menu has rendered and its popover is open. */
29
+ #focusNext;
30
+ #stop;
31
+ /** Opens at a point, such as a right-click. */
32
+ openAt(point, items, label, opener) {
33
+ this.style.setProperty("--menu-x", `${point.x}px`);
34
+ this.style.setProperty("--menu-y", `${point.y}px`);
35
+ this.#show(items, label, opener, undefined);
36
+ }
37
+ /** Opens against `anchor`, below it, flipping at the edges; focus returns to `opener`. */
38
+ openFrom(anchor, items, label, opener = anchor) {
39
+ const name = `${this.#id}-anchor`;
40
+ anchor.style.setProperty("anchor-name", `--${name}`);
41
+ this.#show(items, label, opener, name);
42
+ }
43
+ close() {
44
+ if (!this.#open.peek())
45
+ return;
46
+ this.#open.value = false;
47
+ this.#path.value = [];
48
+ this.#closed();
49
+ }
50
+ render() {
51
+ if (!this.#open.value)
52
+ return nothing;
53
+ const path = this.#path.value;
54
+ const levels = [{ items: this.#items.value, label: this.#label.value }];
55
+ for (const index of path) {
56
+ const parent = focusables(levels.at(-1).items)[index];
57
+ if (parent?.kind !== "submenu")
58
+ break;
59
+ levels.push({ items: parent.items, label: parent.label });
60
+ }
61
+ const anchored = this.#anchor.value;
62
+ return html `${levels.map((level, depth) => {
63
+ const place = depth > 0 ? placeStyle(this.#itemId(depth - 1, path[depth - 1])) : anchored ? placeStyle(anchored) : nothing;
64
+ return html `<div class="aster-menu" popover="manual" role="menu" aria-label=${level.label || nothing}
65
+ data-depth=${depth} ?data-anchored=${depth > 0 || anchored !== undefined} style=${place} ${openWhen(true)}
66
+ @keydown=${(e) => this.#key(e, depth)}>
67
+ ${this.#entries(level.items, depth, { next: 0 })}
68
+ </div>`;
69
+ })}`;
70
+ }
71
+ #entries(items, depth, counter) {
72
+ return items.map((item) => {
73
+ if (item.kind === "separator")
74
+ return html `<div role="separator" class="aster-menu-separator"></div>`;
75
+ if (item.kind === "group") {
76
+ const id = `${this.#id}-g${depth}-${counter.next}`;
77
+ return html `<div role="group" aria-labelledby=${id}>
78
+ <div class="aster-menu-heading" id=${id}>${item.label}</div>
79
+ ${this.#entries(item.items, depth, counter)}
80
+ </div>`;
81
+ }
82
+ const index = counter.next++;
83
+ const id = this.#itemId(depth, index);
84
+ const expanded = this.#path.value[depth] === index;
85
+ const enter = () => this.#hover(depth, index, item);
86
+ switch (item.kind) {
87
+ case "link":
88
+ return html `<a role="menuitem" tabindex="-1" id=${id} href=${item.href} @pointerenter=${enter}
89
+ @click=${(event) => { this.close(); item.activate?.(event); }}><span class="aster-menu-mark" aria-hidden="true"></span>${item.label}</a>`;
90
+ case "check":
91
+ case "radio":
92
+ {
93
+ const checked = this.#marks.value.get(item) ?? item.checked;
94
+ return html `<button type="button" role=${item.kind === "check" ? "menuitemcheckbox" : "menuitemradio"} tabindex="-1"
95
+ id=${id} aria-checked=${checked ? "true" : "false"} @pointerenter=${enter} @click=${() => this.#choose(item)}>
96
+ <span class="aster-menu-mark" aria-hidden="true">${checked ? (item.kind === "check" ? "✓" : "•") : ""}</span>${item.label}</button>`;
97
+ }
98
+ case "submenu":
99
+ return html `<button type="button" role="menuitem" tabindex="-1" id=${id} aria-haspopup="menu"
100
+ aria-expanded=${expanded ? "true" : "false"} style=${`anchor-name: --${id}`}
101
+ @pointerenter=${enter} @click=${() => this.#openSub(depth, index, true)}>
102
+ <span class="aster-menu-mark" aria-hidden="true"></span>${item.label}<span class="aster-menu-more" aria-hidden="true">›</span></button>`;
103
+ default:
104
+ return html `<button type="button" role="menuitem" tabindex="-1" id=${id}
105
+ aria-disabled=${item.disabled ? "true" : nothing} title=${item.disabled ?? nothing}
106
+ @pointerenter=${enter} @click=${() => this.#choose(item)}>
107
+ <span class="aster-menu-mark" aria-hidden="true"></span>${item.label}</button>`;
108
+ }
109
+ });
110
+ }
111
+ #show(items, label, opener, anchor) {
112
+ this.#items.value = items;
113
+ this.#label.value = label;
114
+ this.#opener = opener;
115
+ this.#anchor.value = anchor;
116
+ this.#path.value = [];
117
+ this.#marks.value = new Map();
118
+ this.#open.value = true;
119
+ this.#focusNext = { depth: 0, index: 0 };
120
+ this.#watchOutside();
121
+ }
122
+ updated() {
123
+ const next = this.#focusNext;
124
+ if (!next)
125
+ return;
126
+ // After the popovers this render opened (they open in a microtask too).
127
+ queueMicrotask(() => {
128
+ const target = this.#level(next.depth)[next.index];
129
+ if (!target)
130
+ return;
131
+ this.#focusNext = undefined;
132
+ target.focus();
133
+ });
134
+ }
135
+ #itemId(depth, index) {
136
+ return `${this.#id}-${depth}-${index}`;
137
+ }
138
+ #level(depth) {
139
+ const menu = this.querySelector(`.aster-menu[data-depth="${depth}"]`);
140
+ return menu ? [...menu.querySelectorAll(':scope > [role^="menuitem"], :scope > [role="group"] > [role^="menuitem"]')] : [];
141
+ }
142
+ #choose(item) {
143
+ if (item.kind === "submenu" || item.kind === "link")
144
+ return;
145
+ if ((item.kind === undefined || item.kind === "action") && item.disabled)
146
+ return;
147
+ if (item.kind === "check" || item.kind === "radio") {
148
+ const marks = new Map(this.#marks.peek());
149
+ if (item.kind === "check")
150
+ marks.set(item, !(marks.get(item) ?? item.checked));
151
+ else
152
+ for (const other of siblings(this.#items.peek(), item) ?? [item])
153
+ if (other.kind === "radio")
154
+ marks.set(other, other === item);
155
+ this.#marks.value = marks;
156
+ item.run();
157
+ return;
158
+ }
159
+ this.close();
160
+ item.run();
161
+ }
162
+ #openSub(depth, index, focus) {
163
+ this.#path.value = [...this.#path.peek().slice(0, depth), index];
164
+ if (focus)
165
+ this.#focusNext = { depth: depth + 1, index: 0 };
166
+ }
167
+ /** Pointing at an item opens its submenu, or closes deeper ones. */
168
+ #hover(depth, index, item) {
169
+ const path = this.#path.peek();
170
+ if (item.kind === "submenu") {
171
+ if (path[depth] !== index)
172
+ this.#openSub(depth, index, false);
173
+ }
174
+ else if (path.length > depth) {
175
+ this.#path.value = path.slice(0, depth);
176
+ }
177
+ this.#level(depth)[index]?.focus();
178
+ }
179
+ #key(e, depth) {
180
+ e.stopPropagation();
181
+ const items = this.#level(depth);
182
+ const i = items.indexOf(document.activeElement);
183
+ const focus = (n) => {
184
+ e.preventDefault();
185
+ items[(n + items.length) % items.length]?.focus();
186
+ };
187
+ switch (e.key) {
188
+ case "ArrowDown":
189
+ return focus(i + 1);
190
+ case "ArrowUp":
191
+ return focus(i - 1);
192
+ case "Home":
193
+ return focus(0);
194
+ case "End":
195
+ return focus(items.length - 1);
196
+ case "ArrowRight":
197
+ case "Enter":
198
+ case " ": {
199
+ const current = items[i];
200
+ if (current?.getAttribute("aria-haspopup") === "menu") {
201
+ e.preventDefault();
202
+ this.#openSub(depth, i, true);
203
+ }
204
+ else if (e.key !== "ArrowRight") {
205
+ e.preventDefault();
206
+ current?.click();
207
+ }
208
+ return;
209
+ }
210
+ case "ArrowLeft":
211
+ case "Escape":
212
+ e.preventDefault();
213
+ if (depth === 0) {
214
+ if (e.key === "Escape")
215
+ this.close();
216
+ return;
217
+ }
218
+ {
219
+ const parent = this.#path.peek()[depth - 1];
220
+ this.#path.value = this.#path.peek().slice(0, depth - 1);
221
+ this.#focusNext = { depth: depth - 1, index: parent };
222
+ }
223
+ return;
224
+ case "Tab":
225
+ this.close();
226
+ return;
227
+ default: {
228
+ if (e.key.length !== 1 || !/\S/.test(e.key))
229
+ return;
230
+ const k = e.key.toLowerCase();
231
+ for (let step = 1; step <= items.length; step++) {
232
+ const n = (i + step) % items.length;
233
+ if ((items[n]?.textContent ?? "").trim().toLowerCase().startsWith(k))
234
+ return focus(n);
235
+ }
236
+ }
237
+ }
238
+ }
239
+ #watchOutside() {
240
+ this.#stop?.();
241
+ const down = (e) => {
242
+ if (!this.contains(e.target))
243
+ this.close();
244
+ };
245
+ const focus = (e) => {
246
+ if (!this.contains(e.target))
247
+ this.close();
248
+ };
249
+ document.addEventListener("pointerdown", down, true);
250
+ document.addEventListener("focusin", focus, true);
251
+ this.#stop = () => {
252
+ document.removeEventListener("pointerdown", down, true);
253
+ document.removeEventListener("focusin", focus, true);
254
+ this.#stop = undefined;
255
+ };
256
+ }
257
+ disconnectedCallback() {
258
+ this.#stop?.();
259
+ super.disconnectedCallback();
260
+ }
261
+ #closed() {
262
+ this.#stop?.();
263
+ const opener = this.#opener;
264
+ this.#opener = null;
265
+ if (opener?.isConnected && (!document.activeElement || document.activeElement === document.body || this.contains(document.activeElement))) {
266
+ opener.focus();
267
+ }
268
+ }
269
+ }
270
+ /** The list that holds `item`, searched through groups and submenus. */
271
+ function siblings(items, item) {
272
+ if (items.includes(item))
273
+ return items;
274
+ for (const i of items) {
275
+ if (i.kind === "group" || i.kind === "submenu") {
276
+ const found = siblings(i.items, item);
277
+ if (found)
278
+ return found;
279
+ }
280
+ }
281
+ return undefined;
282
+ }
283
+ function focusables(items) {
284
+ return items.flatMap((i) => (i.kind === "separator" ? [] : i.kind === "group" ? focusables(i.items) : [i]));
285
+ }
286
+ define("aster-menu", Menu);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Runs a DOM change as a view transition, so a container with a
3
+ * `view-transition-name` morphs to its new shape and the content fades in
4
+ * after it. Without support, or with reduced motion, the change just happens.
5
+ */
6
+ export declare function morph(change: () => void): void;
7
+ /** Selects the colour scheme, cross-fading the whole page. */
8
+ export declare function setScheme(scheme: "light" | "dark", animate?: boolean): void;
package/dist/motion.js ADDED
@@ -0,0 +1,23 @@
1
+ const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
2
+ /**
3
+ * Runs a DOM change as a view transition, so a container with a
4
+ * `view-transition-name` morphs to its new shape and the content fades in
5
+ * after it. Without support, or with reduced motion, the change just happens.
6
+ */
7
+ export function morph(change) {
8
+ if (!document.startViewTransition || reduced()) {
9
+ change();
10
+ return;
11
+ }
12
+ document.startViewTransition(change);
13
+ }
14
+ /** Selects the colour scheme, cross-fading the whole page. */
15
+ export function setScheme(scheme, animate = true) {
16
+ const apply = () => {
17
+ document.documentElement.dataset.scheme = scheme;
18
+ };
19
+ if (animate)
20
+ morph(apply);
21
+ else
22
+ apply();
23
+ }
@@ -0,0 +1,18 @@
1
+ import { nothing } from "lit-html";
2
+ /**
3
+ * Overlays live in the top layer, so no scroll container or section clips
4
+ * them, and are placed against their anchor by CSS anchor positioning,
5
+ * flipping at the viewport's edges (`.aster-popover` in elements.css). JS
6
+ * only names the anchor and opens or closes the popover.
7
+ *
8
+ * Usage: the anchor gets `style=${anchorStyle(id)}`; the overlay gets
9
+ * `popover="manual" class="aster-popover" style=${placeStyle(id)} ${openWhen(open)}`.
10
+ */
11
+ export declare function anchorStyle(id: string): string;
12
+ export declare function placeStyle(id: string): string;
13
+ /**
14
+ * Shows or hides the popover it's on to match `open`. A popover rendered
15
+ * fresh isn't in the document yet when this runs, so it syncs once it is.
16
+ */
17
+ export declare function openWhen(open: boolean): import("lit-html/directive.js").DirectiveResult<typeof import("lit-html/directives/ref.js").RefDirective>;
18
+ export { nothing };
@@ -0,0 +1,41 @@
1
+ import { nothing } from "lit-html";
2
+ import { ref } from "lit-html/directives/ref.js";
3
+ /**
4
+ * Overlays live in the top layer, so no scroll container or section clips
5
+ * them, and are placed against their anchor by CSS anchor positioning,
6
+ * flipping at the viewport's edges (`.aster-popover` in elements.css). JS
7
+ * only names the anchor and opens or closes the popover.
8
+ *
9
+ * Usage: the anchor gets `style=${anchorStyle(id)}`; the overlay gets
10
+ * `popover="manual" class="aster-popover" style=${placeStyle(id)} ${openWhen(open)}`.
11
+ */
12
+ export function anchorStyle(id) {
13
+ return `anchor-name: --${id}`;
14
+ }
15
+ export function placeStyle(id) {
16
+ return `position-anchor: --${id}`;
17
+ }
18
+ /**
19
+ * Shows or hides the popover it's on to match `open`. A popover rendered
20
+ * fresh isn't in the document yet when this runs, so it syncs once it is.
21
+ */
22
+ export function openWhen(open) {
23
+ return ref((el) => {
24
+ if (!(el instanceof HTMLElement))
25
+ return;
26
+ const sync = () => {
27
+ if (!el.isConnected)
28
+ return;
29
+ const showing = el.matches(":popover-open");
30
+ if (open && !showing)
31
+ el.showPopover();
32
+ else if (!open && showing)
33
+ el.hidePopover();
34
+ };
35
+ if (el.isConnected)
36
+ sync();
37
+ else
38
+ queueMicrotask(sync);
39
+ });
40
+ }
41
+ export { nothing };
@@ -0,0 +1,59 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import { type Collection, type Criteria, type DataContext, type Entity, type EntityType, type Query, type SortKey, type Value } from "@getaster/data";
3
+ export interface OptionsSpec {
4
+ readonly set: string;
5
+ /** Limits the options. A signal follows the page scope: the project's people, the country's regions. */
6
+ readonly filter?: Criteria | ReadonlySignal<Criteria | undefined>;
7
+ /** By default the display field (after `groupBy`). */
8
+ readonly orderBy?: readonly SortKey[];
9
+ /** The field an option shows as its name; by default the type's title field. */
10
+ readonly display?: string;
11
+ /** Fields typing searches, case-insensitively; by default the display field. */
12
+ readonly searchFields?: readonly string[];
13
+ /** More fields shown as columns beside the name, with a header that sorts by them. */
14
+ readonly columns?: readonly string[];
15
+ /** Column widths as CSS track sizes, by field (the name's too); shared space otherwise. */
16
+ readonly widths?: Readonly<Record<string, string>>;
17
+ /** Options under a heading per value of this field. */
18
+ readonly groupBy?: string;
19
+ readonly pageSize?: number;
20
+ /** Offers "Create …": a small form with these fields, the typed text in the display field. */
21
+ readonly create?: readonly string[];
22
+ /** Said under the field when its value is outside the options: "isn't on this project's team". */
23
+ readonly outside?: string;
24
+ }
25
+ /**
26
+ * Where a picker's options come from: a query over an entity set, shared by
27
+ * every picker that uses it (a column of a grid, a list of tasks). With
28
+ * more options than one page, typing searches on the server through a
29
+ * collection of its own; with fewer, it filters what's loaded.
30
+ */
31
+ export declare class PickerOptions {
32
+ #private;
33
+ readonly ctx: DataContext;
34
+ readonly spec: OptionsSpec;
35
+ readonly type: EntityType;
36
+ readonly key: string;
37
+ readonly display: string;
38
+ readonly pageSize: number;
39
+ /** The options with no search typed. */
40
+ readonly all: Collection;
41
+ /** The sort a person chose from the columns' header; the spec's order until then. */
42
+ readonly sort: import("@preact/signals-core").Signal<readonly SortKey[] | undefined>;
43
+ constructor(ctx: DataContext, spec: OptionsSpec);
44
+ /** Sorts by a column, or reverses the sort when it's already by that column. */
45
+ sortBy(field: string): void;
46
+ /** The grid tracks for the name and the columns, after a check column when several can be chosen. */
47
+ tracks(multiple: boolean): string;
48
+ /** Every option is loaded or loads in one page, so typing filters them here. */
49
+ get small(): boolean;
50
+ query(base: Criteria | undefined, search?: string): Query;
51
+ /** A collection over the options matching `search`; `release` it when done. */
52
+ search(search: ReadonlySignal<string>): Collection;
53
+ release(collection: Collection): void;
54
+ /** The record a value names, fetched if it isn't loaded. */
55
+ record(value: Value | undefined): ReadonlySignal<Entity | undefined> | undefined;
56
+ /** Whether a record is among the options now; undefined if that can't be told here. */
57
+ fits(entity: Entity): boolean | undefined;
58
+ text(entity: Entity, field?: string): string;
59
+ }
@@ -0,0 +1,97 @@
1
+ import { computed, signal } from "@preact/signals-core";
2
+ import { keyText, matches, } from "@getaster/data";
3
+ /**
4
+ * Where a picker's options come from: a query over an entity set, shared by
5
+ * every picker that uses it (a column of a grid, a list of tasks). With
6
+ * more options than one page, typing searches on the server through a
7
+ * collection of its own; with fewer, it filters what's loaded.
8
+ */
9
+ export class PickerOptions {
10
+ ctx;
11
+ spec;
12
+ type;
13
+ key;
14
+ display;
15
+ pageSize;
16
+ /** The options with no search typed. */
17
+ all;
18
+ #filter;
19
+ /** The sort a person chose from the columns' header; the spec's order until then. */
20
+ sort = signal(undefined);
21
+ constructor(ctx, spec) {
22
+ this.ctx = ctx;
23
+ this.spec = spec;
24
+ this.type = ctx.schema.set(spec.set).type;
25
+ this.key = this.type.key[0];
26
+ this.display = spec.display ?? this.type.titleField ?? this.key;
27
+ this.pageSize = spec.pageSize ?? 50;
28
+ const filter = spec.filter;
29
+ this.#filter = filter === undefined ? computed(() => undefined) : "value" in filter && "peek" in filter ? filter : computed(() => filter);
30
+ this.all = ctx.collection(computed(() => this.query(this.#filter.value)), { pageSize: this.pageSize });
31
+ }
32
+ /** Sorts by a column, or reverses the sort when it's already by that column. */
33
+ sortBy(field) {
34
+ const current = this.sort.peek()?.[0];
35
+ this.sort.value = [{ field, ...(current?.field === field && !current.descending ? { descending: true } : {}) }];
36
+ }
37
+ /** The grid tracks for the name and the columns, after a check column when several can be chosen. */
38
+ tracks(multiple) {
39
+ const widths = this.spec.widths ?? {};
40
+ const fields = [this.display, ...(this.spec.columns ?? [])];
41
+ return [...(multiple ? ["12px"] : []), ...fields.map((f, i) => widths[f] ?? (i === 0 ? "minmax(0, 1.4fr)" : "minmax(0, 1fr)"))].join(" ");
42
+ }
43
+ /** Every option is loaded or loads in one page, so typing filters them here. */
44
+ get small() {
45
+ const n = this.all.count.value;
46
+ return n !== undefined && n <= this.pageSize;
47
+ }
48
+ query(base, search) {
49
+ const fields = this.spec.searchFields ?? [this.display];
50
+ const text = search?.trim();
51
+ const searching = text
52
+ ? fields.length === 1
53
+ ? { op: "contains", field: fields[0], value: text, caseInsensitive: true }
54
+ : { op: "or", args: fields.map((field) => ({ op: "contains", field, value: text, caseInsensitive: true })) }
55
+ : undefined;
56
+ const parts = [base, searching].filter((c) => c !== undefined);
57
+ const orderBy = [
58
+ ...(this.spec.groupBy ? [{ field: this.spec.groupBy }] : []),
59
+ ...(this.sort.value ?? this.spec.orderBy ?? [{ field: this.display }]),
60
+ ];
61
+ return {
62
+ set: this.spec.set,
63
+ ...(parts.length === 0 ? {} : { filter: parts.length === 1 ? parts[0] : { op: "and", args: parts } }),
64
+ orderBy,
65
+ };
66
+ }
67
+ /** A collection over the options matching `search`; `release` it when done. */
68
+ search(search) {
69
+ return this.ctx.collection(computed(() => this.query(this.#filter.value, search.value)), { pageSize: this.pageSize });
70
+ }
71
+ release(collection) {
72
+ this.ctx.release(collection);
73
+ }
74
+ /** The record a value names, fetched if it isn't loaded. */
75
+ record(value) {
76
+ return value === null || value === undefined ? undefined : this.ctx.lookup(this.spec.set, keyText([value]));
77
+ }
78
+ /** Whether a record is among the options now; undefined if that can't be told here. */
79
+ fits(entity) {
80
+ const filter = this.#filter.value;
81
+ if (!filter)
82
+ return true;
83
+ try {
84
+ return matches(filter, entity, this.type);
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
90
+ text(entity, field = this.display) {
91
+ const v = entity[field];
92
+ if (v === null || v === undefined)
93
+ return "";
94
+ const allowed = this.type.field(field)?.allowedValues?.find((a) => a.value === v);
95
+ return allowed?.label ?? String(v);
96
+ }
97
+ }
@@ -0,0 +1,46 @@
1
+ import { type Collection, type Field as FieldMeta, type Value } from "@getaster/data";
2
+ import { type View } from "../base/element.ts";
3
+ import { BoundField, type ControlContext } from "../field/bound-field.ts";
4
+ import "../field/field.ts";
5
+ import "../select/select.ts";
6
+ import { PickerOptions } from "./options.ts";
7
+ /**
8
+ * A combobox over related records (APG editable combobox with list
9
+ * autocomplete). It shows the record's display field and offers the
10
+ * options, filtered as you type: on the server when there are more than a
11
+ * page of them, with the match count announced.
12
+ *
13
+ * With `PickerOptions` it also has columns with a header, headings by a
14
+ * group field, options that follow the page scope (a value they no longer
15
+ * include stays, and the field says so), and "Create …", a small form that
16
+ * selects what it created. `multiple` holds several values as chips and
17
+ * reports them by `change`; it isn't bound to a model.
18
+ *
19
+ * Keyboard: ↓/↑ open and move, Page Down/Up move further, Home/End go to
20
+ * the first and last option, Enter chooses, Escape closes and restores the
21
+ * text, Tab leaves without choosing; with chips, Backspace in the empty
22
+ * input removes the last. The active option is the input's
23
+ * `aria-activedescendant`, so focus stays in the input.
24
+ */
25
+ export declare class Picker extends BoundField {
26
+ #private;
27
+ get options(): Collection | PickerOptions | undefined;
28
+ set options(v: Collection | PickerOptions | undefined);
29
+ /** The field of an option shown as its text (with a plain collection). */
30
+ get display(): string;
31
+ set display(v: string);
32
+ get valueField(): string;
33
+ set valueField(v: string);
34
+ get multiple(): boolean;
35
+ set multiple(v: boolean);
36
+ /** The chosen values when `multiple`. */
37
+ get values(): readonly Value[];
38
+ set values(v: readonly Value[]);
39
+ get placeholder(): string;
40
+ set placeholder(v: string);
41
+ protected describe(_meta: FieldMeta | undefined, value: Value | undefined): string;
42
+ focus(options?: FocusOptions): void;
43
+ disconnectedCallback(): void;
44
+ protected control(c: ControlContext): View;
45
+ }
46
+ export { PickerOptions, type OptionsSpec } from "./options.ts";