@getaster/elements 0.0.0-stage → 0.1.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +6 -2
- package/bin/aster-theme-check.js +317 -0
- package/dist/base/drag.d.ts +45 -0
- package/dist/base/drag.js +139 -0
- package/dist/base/element.d.ts +51 -0
- package/dist/base/element.js +133 -0
- package/dist/breadcrumbs/breadcrumbs.d.ts +23 -0
- package/dist/breadcrumbs/breadcrumbs.js +51 -0
- package/dist/button/button.d.ts +37 -0
- package/dist/button/button.js +108 -0
- package/dist/caret/caret-group.d.ts +14 -0
- package/dist/caret/caret-group.js +71 -0
- package/dist/carousel/carousel.d.ts +28 -0
- package/dist/carousel/carousel.js +134 -0
- package/dist/component/component.d.ts +73 -0
- package/dist/component/component.js +190 -0
- package/dist/component/echarts.d.ts +46 -0
- package/dist/component/echarts.js +95 -0
- package/dist/composition/authoring.d.ts +157 -0
- package/dist/composition/authoring.js +291 -0
- package/dist/composition/builtins.d.ts +1130 -0
- package/dist/composition/builtins.js +318 -0
- package/dist/composition/command-bar.d.ts +18 -0
- package/dist/composition/command-bar.js +34 -0
- package/dist/composition/commands.d.ts +14 -0
- package/dist/composition/commands.js +59 -0
- package/dist/composition/composite.d.ts +48 -0
- package/dist/composition/composite.js +409 -0
- package/dist/composition/contracts.d.ts +63 -0
- package/dist/composition/contracts.js +97 -0
- package/dist/composition/definition.d.ts +94 -0
- package/dist/composition/definition.js +291 -0
- package/dist/composition/element.d.ts +35 -0
- package/dist/composition/element.js +51 -0
- package/dist/composition/registry.d.ts +83 -0
- package/dist/composition/registry.js +99 -0
- package/dist/composition/schema.d.ts +9 -0
- package/dist/composition/schema.js +139 -0
- package/dist/composition/sessions.d.ts +15 -0
- package/dist/composition/sessions.js +61 -0
- package/dist/dashboard/dashboard.d.ts +26 -0
- package/dist/dashboard/dashboard.js +296 -0
- package/dist/dashboard/layout.d.ts +19 -0
- package/dist/dashboard/layout.js +48 -0
- package/dist/dashboard/widget.d.ts +42 -0
- package/dist/dashboard/widget.js +165 -0
- package/dist/date/calendar.d.ts +34 -0
- package/dist/date/calendar.js +137 -0
- package/dist/date/date-field.d.ts +18 -0
- package/dist/date/date-field.js +81 -0
- package/dist/date/dates.d.ts +22 -0
- package/dist/date/dates.js +80 -0
- package/dist/dialog/dialog.d.ts +23 -0
- package/dist/dialog/dialog.js +70 -0
- package/dist/field/bound-field.d.ts +55 -0
- package/dist/field/bound-field.js +139 -0
- package/dist/field/check.d.ts +11 -0
- package/dist/field/check.js +26 -0
- package/dist/field/composite-field.d.ts +35 -0
- package/dist/field/composite-field.js +88 -0
- package/dist/field/field.d.ts +29 -0
- package/dist/field/field.js +75 -0
- package/dist/field/input.d.ts +15 -0
- package/dist/field/input.js +18 -0
- package/dist/field/number-field.d.ts +33 -0
- package/dist/field/number-field.js +179 -0
- package/dist/field/numeric.d.ts +4 -0
- package/dist/field/numeric.js +57 -0
- package/dist/field/values.d.ts +14 -0
- package/dist/field/values.js +60 -0
- package/dist/filter/filter-builder.d.ts +18 -0
- package/dist/filter/filter-builder.js +305 -0
- package/dist/form/controls.d.ts +20 -0
- package/dist/form/controls.js +34 -0
- package/dist/form/form.d.ts +46 -0
- package/dist/form/form.js +85 -0
- package/dist/format.d.ts +10 -0
- package/dist/format.js +57 -0
- package/dist/grid/filters.d.ts +16 -0
- package/dist/grid/filters.js +66 -0
- package/dist/grid/grid.d.ts +209 -0
- package/dist/grid/grid.js +2587 -0
- package/dist/grid/rows.d.ts +32 -0
- package/dist/grid/rows.js +89 -0
- package/dist/grid/view.d.ts +69 -0
- package/dist/grid/view.js +172 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +61 -0
- package/dist/layout/stack.d.ts +112 -0
- package/dist/layout/stack.js +21 -0
- package/dist/list-edit/list-edit.d.ts +42 -0
- package/dist/list-edit/list-edit.js +220 -0
- package/dist/listbox/listbox.d.ts +45 -0
- package/dist/listbox/listbox.js +187 -0
- package/dist/menu/menu.d.ts +55 -0
- package/dist/menu/menu.js +286 -0
- package/dist/motion.d.ts +8 -0
- package/dist/motion.js +23 -0
- package/dist/overlay/anchor.d.ts +18 -0
- package/dist/overlay/anchor.js +41 -0
- package/dist/picker/options.d.ts +59 -0
- package/dist/picker/options.js +97 -0
- package/dist/picker/picker.d.ts +46 -0
- package/dist/picker/picker.js +492 -0
- package/dist/picker/tree-choices.d.ts +38 -0
- package/dist/picker/tree-choices.js +72 -0
- package/dist/picker/tree-picker.d.ts +29 -0
- package/dist/picker/tree-picker.js +282 -0
- package/dist/progress/policy.d.ts +5 -0
- package/dist/progress/policy.js +28 -0
- package/dist/progress/status.d.ts +20 -0
- package/dist/progress/status.js +38 -0
- package/dist/rule/operators.d.ts +8 -0
- package/dist/rule/operators.js +9 -0
- package/dist/rule/rule-editor.d.ts +57 -0
- package/dist/rule/rule-editor.js +290 -0
- package/dist/section/accordion.d.ts +24 -0
- package/dist/section/accordion.js +90 -0
- package/dist/section/section.d.ts +30 -0
- package/dist/section/section.js +99 -0
- package/dist/select/choice-element.d.ts +20 -0
- package/dist/select/choice-element.js +55 -0
- package/dist/select/choice.d.ts +34 -0
- package/dist/select/choice.js +122 -0
- package/dist/select/many.d.ts +13 -0
- package/dist/select/many.js +20 -0
- package/dist/select/select.d.ts +16 -0
- package/dist/select/select.js +40 -0
- package/dist/sidebar/sidebar.d.ts +47 -0
- package/dist/sidebar/sidebar.js +105 -0
- package/dist/slicer/slicer.d.ts +26 -0
- package/dist/slicer/slicer.js +140 -0
- package/dist/split/split.d.ts +41 -0
- package/dist/split/split.js +237 -0
- package/dist/styles/contract.d.ts +36 -0
- package/dist/styles/contract.js +39 -0
- package/dist/switch/switch.d.ts +22 -0
- package/dist/switch/switch.js +69 -0
- package/dist/tabs/tabs.d.ts +30 -0
- package/dist/tabs/tabs.js +202 -0
- package/dist/tile-grid/scroll.d.ts +9 -0
- package/dist/tile-grid/scroll.js +21 -0
- package/dist/tile-grid/tile-grid.d.ts +64 -0
- package/dist/tile-grid/tile-grid.js +734 -0
- package/dist/toast/toast.d.ts +25 -0
- package/dist/toast/toast.js +92 -0
- package/dist/tree/navigator.d.ts +41 -0
- package/dist/tree/navigator.js +228 -0
- package/dist/window/chrome.d.ts +3 -0
- package/dist/window/chrome.js +7 -0
- package/dist/window/window.d.ts +46 -0
- package/dist/window/window.js +347 -0
- package/dist/window/workspace.d.ts +29 -0
- package/dist/window/workspace.js +137 -0
- package/package.json +45 -4
- package/styles/aster-ui.css +1147 -0
- package/styles/base.css +88 -0
- package/styles/contract.ts +41 -0
- package/styles/elements.css +1058 -0
- package/styles/harbour.css +126 -0
- package/styles/lamplight.css +118 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { signal } from "@preact/signals-core";
|
|
2
|
+
import { EditModel } from "@getaster/data";
|
|
3
|
+
import { html, nothing } from "lit-html";
|
|
4
|
+
import { live } from "lit-html/directives/live.js";
|
|
5
|
+
import { define } from "../base/element.js";
|
|
6
|
+
import { BoundField } from "../field/bound-field.js";
|
|
7
|
+
import "../field/field.js";
|
|
8
|
+
import { anchorStyle, openWhen, placeStyle } from "../overlay/anchor.js";
|
|
9
|
+
import "../select/select.js";
|
|
10
|
+
import { PickerOptions } from "./options.js";
|
|
11
|
+
const SEARCH_DELAY = 200;
|
|
12
|
+
const COUNT = new Intl.NumberFormat("en-GB");
|
|
13
|
+
/**
|
|
14
|
+
* A combobox over related records (APG editable combobox with list
|
|
15
|
+
* autocomplete). It shows the record's display field and offers the
|
|
16
|
+
* options, filtered as you type: on the server when there are more than a
|
|
17
|
+
* page of them, with the match count announced.
|
|
18
|
+
*
|
|
19
|
+
* With `PickerOptions` it also has columns with a header, headings by a
|
|
20
|
+
* group field, options that follow the page scope (a value they no longer
|
|
21
|
+
* include stays, and the field says so), and "Create …", a small form that
|
|
22
|
+
* selects what it created. `multiple` holds several values as chips and
|
|
23
|
+
* reports them by `change`; it isn't bound to a model.
|
|
24
|
+
*
|
|
25
|
+
* Keyboard: ↓/↑ open and move, Page Down/Up move further, Home/End go to
|
|
26
|
+
* the first and last option, Enter chooses, Escape closes and restores the
|
|
27
|
+
* text, Tab leaves without choosing; with chips, Backspace in the empty
|
|
28
|
+
* input removes the last. The active option is the input's
|
|
29
|
+
* `aria-activedescendant`, so focus stays in the input.
|
|
30
|
+
*/
|
|
31
|
+
export class Picker extends BoundField {
|
|
32
|
+
#options = this.prop(undefined);
|
|
33
|
+
#display = this.prop("");
|
|
34
|
+
#valueField = this.prop("");
|
|
35
|
+
#multiple = this.prop(false);
|
|
36
|
+
#values = this.prop([]);
|
|
37
|
+
#placeholder = this.prop("");
|
|
38
|
+
#open = this.prop(false);
|
|
39
|
+
#query = this.prop(undefined);
|
|
40
|
+
#active = this.prop(0);
|
|
41
|
+
#creating = this.prop(undefined);
|
|
42
|
+
/** The typed text a server search follows, after a pause. */
|
|
43
|
+
#search = signal("");
|
|
44
|
+
#searchTimer;
|
|
45
|
+
#searching = this.prop(undefined);
|
|
46
|
+
get options() {
|
|
47
|
+
return this.#options.value;
|
|
48
|
+
}
|
|
49
|
+
set options(v) {
|
|
50
|
+
this.#options.value = v;
|
|
51
|
+
}
|
|
52
|
+
/** The field of an option shown as its text (with a plain collection). */
|
|
53
|
+
get display() {
|
|
54
|
+
return this.#display.value;
|
|
55
|
+
}
|
|
56
|
+
set display(v) {
|
|
57
|
+
this.#display.value = v;
|
|
58
|
+
}
|
|
59
|
+
get valueField() {
|
|
60
|
+
return this.#valueField.value;
|
|
61
|
+
}
|
|
62
|
+
set valueField(v) {
|
|
63
|
+
this.#valueField.value = v;
|
|
64
|
+
}
|
|
65
|
+
get multiple() {
|
|
66
|
+
return this.#multiple.value;
|
|
67
|
+
}
|
|
68
|
+
set multiple(v) {
|
|
69
|
+
this.#multiple.value = v;
|
|
70
|
+
}
|
|
71
|
+
/** The chosen values when `multiple`. */
|
|
72
|
+
get values() {
|
|
73
|
+
return this.#values.value;
|
|
74
|
+
}
|
|
75
|
+
set values(v) {
|
|
76
|
+
this.#values.value = v;
|
|
77
|
+
}
|
|
78
|
+
get placeholder() {
|
|
79
|
+
return this.#placeholder.value;
|
|
80
|
+
}
|
|
81
|
+
set placeholder(v) {
|
|
82
|
+
this.#placeholder.value = v;
|
|
83
|
+
}
|
|
84
|
+
describe(_meta, value) {
|
|
85
|
+
if (value === null || value === undefined)
|
|
86
|
+
return "";
|
|
87
|
+
const record = this.#record(value);
|
|
88
|
+
return record ? this.#text(record) : String(value);
|
|
89
|
+
}
|
|
90
|
+
focus(options) {
|
|
91
|
+
this.querySelector("input")?.focus(options);
|
|
92
|
+
}
|
|
93
|
+
disconnectedCallback() {
|
|
94
|
+
this.#endSearch();
|
|
95
|
+
super.disconnectedCallback();
|
|
96
|
+
}
|
|
97
|
+
control(c) {
|
|
98
|
+
const source = this.#source();
|
|
99
|
+
const key = this.#key();
|
|
100
|
+
const multiple = this.#multiple.value;
|
|
101
|
+
const chosen = multiple ? this.#values.value : c.value === null || c.value === undefined ? [] : [c.value];
|
|
102
|
+
const query = this.#query.value;
|
|
103
|
+
const open = this.#open.value && !c.state.readOnly;
|
|
104
|
+
const list = this.#list();
|
|
105
|
+
const loaded = (list?.items.value ?? []).filter((e) => e !== undefined);
|
|
106
|
+
const searching = this.#searching.value;
|
|
107
|
+
const local = !searching;
|
|
108
|
+
const typed = query?.trim().toLowerCase() ?? "";
|
|
109
|
+
// A large set answers typed text from the server; until it has, there's nothing to show or count.
|
|
110
|
+
const waiting = typed !== "" && source !== undefined && !source.small &&
|
|
111
|
+
(!searching || this.#search.value.trim().toLowerCase() !== typed || searching.count.value === undefined);
|
|
112
|
+
const shown = waiting ? [] : local && typed ? loaded.filter((e) => this.#searchText(e).includes(typed)) : loaded;
|
|
113
|
+
const total = waiting ? undefined : local ? (typed || !list ? shown.length : (list.count.value ?? shown.length)) : list?.count.value;
|
|
114
|
+
const createText = source?.spec.create && query?.trim() && !waiting ? query.trim() : undefined;
|
|
115
|
+
const entries = [
|
|
116
|
+
...shown.map((entity) => ({ kind: "option", entity })),
|
|
117
|
+
...(createText ? [{ kind: "create", text: createText }] : []),
|
|
118
|
+
];
|
|
119
|
+
const active = Math.min(this.#active.value, Math.max(0, entries.length - 1));
|
|
120
|
+
const listId = `${c.id}-options`;
|
|
121
|
+
const optionId = (i) => `${c.id}-option-${i}`;
|
|
122
|
+
const current = !multiple && chosen.length ? this.#record(chosen[0]) : undefined;
|
|
123
|
+
const label = multiple ? "" : current ? this.#text(current) : chosen.length ? String(chosen[0]) : "";
|
|
124
|
+
const outside = !multiple && current && source && source.fits(current) === false;
|
|
125
|
+
const noteId = `${c.id}-outside`;
|
|
126
|
+
const statusText = open && list ? this.#status(total, shown.length, loaded.length, typed, list) : "";
|
|
127
|
+
const describedBy = [c.describedBy, outside ? noteId : ""].filter(Boolean).join(" ") || undefined;
|
|
128
|
+
const creating = this.#creating.value;
|
|
129
|
+
const columns = source?.spec.columns?.length ?? 0;
|
|
130
|
+
const choose = (entry) => {
|
|
131
|
+
if (!entry)
|
|
132
|
+
return;
|
|
133
|
+
if (entry.kind === "create")
|
|
134
|
+
return this.#startCreate(entry.text);
|
|
135
|
+
const v = entry.entity[key] ?? null;
|
|
136
|
+
if (multiple) {
|
|
137
|
+
this.#setValues(chosen.includes(v) ? chosen.filter((x) => x !== v) : [...chosen, v]);
|
|
138
|
+
this.#query.value = undefined;
|
|
139
|
+
this.#endSearch();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.write(v);
|
|
143
|
+
this.#close();
|
|
144
|
+
};
|
|
145
|
+
const move = (to) => {
|
|
146
|
+
const n = entries.length;
|
|
147
|
+
if (!n)
|
|
148
|
+
return;
|
|
149
|
+
const i = Math.max(0, Math.min(n - 1, to));
|
|
150
|
+
this.#active.value = i;
|
|
151
|
+
if (list && i >= loaded.length - 5 && !list.complete.peek())
|
|
152
|
+
void list.ensure(0, loaded.length + this.#pageSize());
|
|
153
|
+
this.querySelector(`#${CSS.escape(optionId(i))}`)?.scrollIntoView({ block: "nearest" });
|
|
154
|
+
};
|
|
155
|
+
const more = (e) => {
|
|
156
|
+
const el = e.currentTarget;
|
|
157
|
+
if (list && !list.complete.peek() && el.scrollTop + el.clientHeight > el.scrollHeight - 80)
|
|
158
|
+
void list.ensure(0, loaded.length + this.#pageSize());
|
|
159
|
+
};
|
|
160
|
+
const options = entries.length === 0
|
|
161
|
+
? html `<div class="aster-picker-empty">${waiting || list?.status.value === "loading" ? "Searching…" : typed ? `Nothing matches “${query}”.` : "No options."}</div>`
|
|
162
|
+
: this.#entries(entries, chosen, active, optionId, choose);
|
|
163
|
+
return html `<div class="aster-picker" ?data-multiple=${multiple}>
|
|
164
|
+
<label class="aster-field" data-ring-owner style=${anchorStyle(c.id)}>
|
|
165
|
+
${multiple ? chosen.map((v) => this.#chip(v)) : nothing}
|
|
166
|
+
<input id=${c.id} role="combobox" .value=${live(query ?? label)}
|
|
167
|
+
aria-label=${this.labelled ? nothing : c.label}
|
|
168
|
+
placeholder=${multiple && chosen.length ? "" : this.#placeholder.value || (c.meta?.nullable || multiple ? "None" : "Choose…")}
|
|
169
|
+
aria-expanded=${open ? "true" : "false"} aria-controls=${listId} aria-autocomplete="list"
|
|
170
|
+
aria-activedescendant=${open && entries.length ? optionId(active) : nothing}
|
|
171
|
+
aria-required=${c.state.required ? "true" : nothing}
|
|
172
|
+
aria-invalid=${c.invalid ? "true" : nothing}
|
|
173
|
+
aria-describedby=${describedBy ?? nothing}
|
|
174
|
+
?readonly=${c.state.readOnly}
|
|
175
|
+
autocomplete="off" spellcheck="false"
|
|
176
|
+
@input=${(e) => {
|
|
177
|
+
// Typing replaces the shown name, even where the engine didn't select it on click.
|
|
178
|
+
const t = e.target.value;
|
|
179
|
+
this.#type(query === undefined && label && t.startsWith(label) && t.length > label.length ? t.slice(label.length) : t);
|
|
180
|
+
}}
|
|
181
|
+
@click=${(e) => {
|
|
182
|
+
if (this.#open.peek())
|
|
183
|
+
return this.#close();
|
|
184
|
+
this.#show();
|
|
185
|
+
e.target.select();
|
|
186
|
+
}}
|
|
187
|
+
@focus=${(e) => e.target.select()}
|
|
188
|
+
@blur=${(e) => {
|
|
189
|
+
if (this.contains(e.relatedTarget))
|
|
190
|
+
return;
|
|
191
|
+
this.#close();
|
|
192
|
+
}}
|
|
193
|
+
@keydown=${(e) => {
|
|
194
|
+
if (c.state.readOnly)
|
|
195
|
+
return;
|
|
196
|
+
const isOpen = this.#open.peek();
|
|
197
|
+
switch (e.key) {
|
|
198
|
+
case "ArrowDown":
|
|
199
|
+
case "ArrowUp":
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
if (!isOpen)
|
|
202
|
+
return this.#show();
|
|
203
|
+
return move(active + (e.key === "ArrowDown" ? 1 : -1));
|
|
204
|
+
case "PageDown":
|
|
205
|
+
case "PageUp":
|
|
206
|
+
if (!isOpen)
|
|
207
|
+
return;
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
return move(active + (e.key === "PageDown" ? 10 : -10));
|
|
210
|
+
case "Home":
|
|
211
|
+
case "End":
|
|
212
|
+
if (!isOpen)
|
|
213
|
+
return;
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
return move(e.key === "Home" ? 0 : entries.length - 1);
|
|
216
|
+
case "Enter":
|
|
217
|
+
if (!isOpen)
|
|
218
|
+
return;
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
return choose(entries[active]);
|
|
221
|
+
case "Escape":
|
|
222
|
+
if (creating) {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
creating.dispose();
|
|
226
|
+
this.#creating.value = undefined;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!isOpen)
|
|
230
|
+
return;
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
e.stopPropagation();
|
|
233
|
+
return this.#close();
|
|
234
|
+
case "Backspace":
|
|
235
|
+
if (multiple && !e.target.value && chosen.length)
|
|
236
|
+
this.#setValues(chosen.slice(0, -1));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}}>
|
|
240
|
+
<svg class="aster-picker-chevron" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.8 5 6.8l3-3"/></svg>
|
|
241
|
+
</label>
|
|
242
|
+
${columns
|
|
243
|
+
? html `<div class="aster-picker-list aster-popover" popover="manual" ${openWhen(open && !creating)} data-columns
|
|
244
|
+
style=${`${placeStyle(c.id)}; grid-template-columns: ${source.tracks(multiple)}`} @scroll=${more}>
|
|
245
|
+
${this.#header(source)}
|
|
246
|
+
<div class="aster-picker-options" id=${listId} role="listbox" aria-label=${c.label} aria-multiselectable=${multiple ? "true" : nothing}>
|
|
247
|
+
${options}
|
|
248
|
+
</div>
|
|
249
|
+
</div>`
|
|
250
|
+
: html `<div class="aster-picker-list aster-popover" popover="manual" ${openWhen(open && !creating)} style=${placeStyle(c.id)}
|
|
251
|
+
id=${listId} role="listbox" aria-label=${c.label} aria-multiselectable=${multiple ? "true" : nothing} @scroll=${more}>
|
|
252
|
+
${options}
|
|
253
|
+
</div>`}
|
|
254
|
+
${creating && source ? this.#createForm(c, source, creating) : nothing}
|
|
255
|
+
<div class="aster-sr-only" role="status">${statusText}</div>
|
|
256
|
+
${outside
|
|
257
|
+
? html `<p class="aster-hint aster-picker-outside" id=${noteId}>${label} ${source.spec.outside ?? "isn’t among the options now."}</p>`
|
|
258
|
+
: nothing}
|
|
259
|
+
</div>`;
|
|
260
|
+
}
|
|
261
|
+
#entries(entries, chosen, active, optionId, choose) {
|
|
262
|
+
const source = this.#source();
|
|
263
|
+
const group = source?.spec.groupBy;
|
|
264
|
+
const key = this.#key();
|
|
265
|
+
const option = (entry, i) => {
|
|
266
|
+
const press = (ev) => ev.preventDefault();
|
|
267
|
+
if (entry.kind === "create") {
|
|
268
|
+
return html `<div class="aster-option aster-option-create" role="option" id=${optionId(i)} aria-selected="false"
|
|
269
|
+
?data-active=${i === active} @pointerdown=${press} @click=${() => choose(entry)}>Create “${entry.text}”…</div>`;
|
|
270
|
+
}
|
|
271
|
+
const e = entry.entity;
|
|
272
|
+
const selected = chosen.includes(e[key] ?? null);
|
|
273
|
+
const columns = source?.spec.columns ?? [];
|
|
274
|
+
return html `<div class="aster-option" role="option" id=${optionId(i)} aria-selected=${selected ? "true" : "false"}
|
|
275
|
+
?data-active=${i === active} ?data-columns=${columns.length > 0} @pointerdown=${press} @click=${() => choose(entry)}>
|
|
276
|
+
${this.#multiple.peek() ? html `<span class="aster-option-check" aria-hidden="true">${selected ? "✓" : ""}</span>` : nothing}
|
|
277
|
+
<span>${this.#text(e)}</span>${columns.map((f) => html `<span class="aster-option-col">${source.text(e, f)}</span>`)}
|
|
278
|
+
</div>`;
|
|
279
|
+
};
|
|
280
|
+
if (!group || !source)
|
|
281
|
+
return html `${entries.map(option)}`;
|
|
282
|
+
// Consecutive options with the same group value go under one heading.
|
|
283
|
+
const runs = [];
|
|
284
|
+
entries.forEach((entry, i) => {
|
|
285
|
+
const heading = entry.kind === "option" ? source.text(entry.entity, group) || "None" : "";
|
|
286
|
+
const last = runs.at(-1);
|
|
287
|
+
if (last && last.heading === heading)
|
|
288
|
+
last.items.push([entry, i]);
|
|
289
|
+
else
|
|
290
|
+
runs.push({ heading, items: [[entry, i]] });
|
|
291
|
+
});
|
|
292
|
+
return html `${runs.map((run, g) => run.heading
|
|
293
|
+
? html `<div role="group" aria-labelledby=${`${optionId(0)}-g${g}`}>
|
|
294
|
+
<div class="aster-option-group" id=${`${optionId(0)}-g${g}`} role="presentation">${run.heading}</div>
|
|
295
|
+
${run.items.map(([e, i]) => option(e, i))}
|
|
296
|
+
</div>`
|
|
297
|
+
: run.items.map(([e, i]) => option(e, i)))}`;
|
|
298
|
+
}
|
|
299
|
+
/** Sorts by a column: outside the listbox, reached by Tab (the list stays open while focus is in the picker). */
|
|
300
|
+
#header(source) {
|
|
301
|
+
const title = (f) => source.type.field(f)?.title ?? f;
|
|
302
|
+
const sort = source.sort.value?.[0];
|
|
303
|
+
const fields = [source.display, ...(source.spec.columns ?? [])];
|
|
304
|
+
return html `<div class="aster-option aster-option-head" role="group" aria-label="Sort the options" data-columns>
|
|
305
|
+
${this.#multiple.peek() ? html `<span class="aster-option-check"></span>` : nothing}
|
|
306
|
+
${fields.map((f, i) => {
|
|
307
|
+
const on = sort?.field === f;
|
|
308
|
+
const label = `Sort by ${title(f)}${on ? (sort.descending ? ", now descending" : ", now ascending") : ""}`;
|
|
309
|
+
return html `<button type="button" class=${i === 0 ? "aster-option-sort" : "aster-option-sort aster-option-col"} aria-label=${label} aria-pressed=${on ? "true" : "false"}
|
|
310
|
+
@pointerdown=${(e) => e.preventDefault()}
|
|
311
|
+
@click=${() => source.sortBy(f)}>${title(f)}${on ? html `<span aria-hidden="true"> ${sort.descending ? "↓" : "↑"}</span>` : nothing}</button>`;
|
|
312
|
+
})}
|
|
313
|
+
</div>`;
|
|
314
|
+
}
|
|
315
|
+
#chip(v) {
|
|
316
|
+
const record = this.#record(v);
|
|
317
|
+
const name = record ? this.#text(record) : String(v);
|
|
318
|
+
return html `<span class="aster-chip">${name}<button type="button" aria-label=${`Remove ${name}`}
|
|
319
|
+
@click=${(e) => {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
this.#setValues(this.#values.peek().filter((x) => x !== v));
|
|
322
|
+
this.focus();
|
|
323
|
+
}}>
|
|
324
|
+
<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M3 3l4 4M7 3 3 7"/></svg></button></span>`;
|
|
325
|
+
}
|
|
326
|
+
#createForm(c, source, edit) {
|
|
327
|
+
const fields = [source.display, ...(source.spec.create ?? []).filter((f) => f !== source.display)];
|
|
328
|
+
const title = source.type.title ?? source.spec.set;
|
|
329
|
+
const cancel = () => {
|
|
330
|
+
edit.dispose();
|
|
331
|
+
this.#creating.value = undefined;
|
|
332
|
+
this.focus();
|
|
333
|
+
};
|
|
334
|
+
const save = async () => {
|
|
335
|
+
const saved = await edit.save();
|
|
336
|
+
if (!saved)
|
|
337
|
+
return;
|
|
338
|
+
const value = saved.record.entity[source.key] ?? null;
|
|
339
|
+
edit.dispose();
|
|
340
|
+
this.#creating.value = undefined;
|
|
341
|
+
if (this.#multiple.peek())
|
|
342
|
+
this.#setValues([...this.#values.peek(), value]);
|
|
343
|
+
else
|
|
344
|
+
this.write(value);
|
|
345
|
+
this.#close();
|
|
346
|
+
this.focus();
|
|
347
|
+
};
|
|
348
|
+
return html `<form class="aster-picker-create aster-popover" popover="manual" role="dialog" aria-label=${`New ${title.toLowerCase()}`}
|
|
349
|
+
style=${placeStyle(c.id)} ${openWhen(true)}
|
|
350
|
+
@submit=${(e) => (e.preventDefault(), void save())}
|
|
351
|
+
@keydown=${(e) => {
|
|
352
|
+
if (e.key !== "Escape")
|
|
353
|
+
return;
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
e.stopPropagation();
|
|
356
|
+
cancel();
|
|
357
|
+
}}>
|
|
358
|
+
<p class="aster-picker-create-title">New ${title.toLowerCase()}</p>
|
|
359
|
+
${fields.map((f) => source.type.field(f)?.allowedValues
|
|
360
|
+
? html `<aster-select .model=${edit} .field=${f} .labelled=${true}></aster-select>`
|
|
361
|
+
: html `<aster-field .model=${edit} .field=${f} .labelled=${true}></aster-field>`)}
|
|
362
|
+
${edit.formError.value ? html `<p class="aster-error" role="alert">${edit.formError.value}</p>` : nothing}
|
|
363
|
+
<div class="aster-picker-create-actions">
|
|
364
|
+
<button type="button" class="aster-button" data-variant="quiet" @click=${cancel}>Cancel</button>
|
|
365
|
+
<button type="submit" class="aster-button" data-variant="primary" ?disabled=${edit.busy.value}>Create</button>
|
|
366
|
+
</div>
|
|
367
|
+
</form>`;
|
|
368
|
+
}
|
|
369
|
+
#startCreate(text) {
|
|
370
|
+
const source = this.#source();
|
|
371
|
+
if (!source)
|
|
372
|
+
return;
|
|
373
|
+
const defaults = {};
|
|
374
|
+
for (const f of [source.display, ...(source.spec.create ?? [])]) {
|
|
375
|
+
const meta = source.type.field(f);
|
|
376
|
+
defaults[f] = f === source.display ? text : meta?.nullable ? null : "";
|
|
377
|
+
}
|
|
378
|
+
this.#creating.value = new EditModel(source.ctx, source.spec.set, undefined, defaults);
|
|
379
|
+
this.#focusCreate();
|
|
380
|
+
}
|
|
381
|
+
/** Focuses the create form's first empty field once its fields have rendered. */
|
|
382
|
+
#focusCreate(frames = 10) {
|
|
383
|
+
requestAnimationFrame(() => {
|
|
384
|
+
const inputs = [...this.querySelectorAll(".aster-picker-create input")];
|
|
385
|
+
const target = inputs.find((i) => !i.value) ?? inputs[0];
|
|
386
|
+
target?.focus();
|
|
387
|
+
if (document.activeElement !== target && frames > 0)
|
|
388
|
+
this.#focusCreate(frames - 1);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
#status(total, shown, loaded, typed, list) {
|
|
392
|
+
if (total === undefined)
|
|
393
|
+
return "";
|
|
394
|
+
const n = `${COUNT.format(total)} ${total === 1 ? "match" : "matches"}`;
|
|
395
|
+
if (!typed)
|
|
396
|
+
return "";
|
|
397
|
+
return total > loaded && !list.complete.peek() ? `${n}, ${COUNT.format(shown)} shown` : n;
|
|
398
|
+
}
|
|
399
|
+
#type(text) {
|
|
400
|
+
this.#query.value = text;
|
|
401
|
+
this.#active.value = 0;
|
|
402
|
+
this.#show();
|
|
403
|
+
const source = this.#source();
|
|
404
|
+
if (!source || source.small)
|
|
405
|
+
return;
|
|
406
|
+
clearTimeout(this.#searchTimer);
|
|
407
|
+
this.#searchTimer = setTimeout(() => {
|
|
408
|
+
if (!text.trim())
|
|
409
|
+
return this.#endSearch();
|
|
410
|
+
this.#search.value = text;
|
|
411
|
+
if (!this.#searching.peek()) {
|
|
412
|
+
const searching = source.search(this.#search);
|
|
413
|
+
this.#searching.value = searching;
|
|
414
|
+
void searching.ensure(0, source.pageSize);
|
|
415
|
+
}
|
|
416
|
+
}, SEARCH_DELAY);
|
|
417
|
+
}
|
|
418
|
+
#setValues(values) {
|
|
419
|
+
this.#values.value = values;
|
|
420
|
+
this.emit("change", values);
|
|
421
|
+
}
|
|
422
|
+
#list() {
|
|
423
|
+
const o = this.#options.value;
|
|
424
|
+
return this.#searching.value ?? (o instanceof PickerOptions ? o.all : o);
|
|
425
|
+
}
|
|
426
|
+
#source() {
|
|
427
|
+
const o = this.#options.value;
|
|
428
|
+
return o instanceof PickerOptions ? o : undefined;
|
|
429
|
+
}
|
|
430
|
+
#key() {
|
|
431
|
+
const o = this.#options.value;
|
|
432
|
+
return this.#valueField.value || (o instanceof PickerOptions ? o.key : o?.type.key[0]) || "id";
|
|
433
|
+
}
|
|
434
|
+
#displayField() {
|
|
435
|
+
const o = this.#options.value;
|
|
436
|
+
if (o instanceof PickerOptions)
|
|
437
|
+
return o.display;
|
|
438
|
+
return this.#display.value || o?.type.titleField || this.#key();
|
|
439
|
+
}
|
|
440
|
+
#text(e) {
|
|
441
|
+
const source = this.#source();
|
|
442
|
+
if (source)
|
|
443
|
+
return source.text(e);
|
|
444
|
+
const v = e[this.#displayField()];
|
|
445
|
+
return v === null || v === undefined ? "" : String(v);
|
|
446
|
+
}
|
|
447
|
+
#searchText(e) {
|
|
448
|
+
const source = this.#source();
|
|
449
|
+
const fields = source?.spec.searchFields ?? [this.#displayField()];
|
|
450
|
+
return fields.map((f) => (source ? source.text(e, f) : String(e[f] ?? ""))).join(" ").toLowerCase();
|
|
451
|
+
}
|
|
452
|
+
#record(value) {
|
|
453
|
+
if (value === null || value === undefined)
|
|
454
|
+
return undefined;
|
|
455
|
+
const o = this.#options.value;
|
|
456
|
+
if (o instanceof PickerOptions)
|
|
457
|
+
return o.record(value)?.value;
|
|
458
|
+
const key = this.#key();
|
|
459
|
+
return o?.items.value.find((e) => e?.[key] === value);
|
|
460
|
+
}
|
|
461
|
+
#pageSize() {
|
|
462
|
+
const o = this.#options.peek();
|
|
463
|
+
return o instanceof PickerOptions ? o.pageSize : (o?.pageSize ?? 100);
|
|
464
|
+
}
|
|
465
|
+
#show() {
|
|
466
|
+
if (this.#open.peek())
|
|
467
|
+
return;
|
|
468
|
+
this.#open.value = true;
|
|
469
|
+
const o = this.#options.peek();
|
|
470
|
+
if (o instanceof PickerOptions)
|
|
471
|
+
void o.all.ensure(0, o.pageSize);
|
|
472
|
+
else
|
|
473
|
+
void o?.loadAll();
|
|
474
|
+
}
|
|
475
|
+
#endSearch() {
|
|
476
|
+
clearTimeout(this.#searchTimer);
|
|
477
|
+
const searching = this.#searching.peek();
|
|
478
|
+
if (!searching)
|
|
479
|
+
return;
|
|
480
|
+
this.#searching.value = undefined;
|
|
481
|
+
this.#search.value = "";
|
|
482
|
+
this.#source()?.release(searching);
|
|
483
|
+
}
|
|
484
|
+
#close() {
|
|
485
|
+
this.#open.value = false;
|
|
486
|
+
this.#query.value = undefined;
|
|
487
|
+
this.#active.value = 0;
|
|
488
|
+
this.#endSearch();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
export { PickerOptions } from "./options.js";
|
|
492
|
+
define("aster-picker", Picker);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ReadonlySignal } from "@preact/signals-core";
|
|
2
|
+
import { Tree, type DataContext, type Entity, type EntityType, type SortKey, type Value } from "@getaster/data";
|
|
3
|
+
export interface TreeChoicesSpec {
|
|
4
|
+
readonly set: string;
|
|
5
|
+
/** The field holding a node's parent key. */
|
|
6
|
+
readonly parentField: string;
|
|
7
|
+
/** The field a node shows as its name; by default the type's title field. */
|
|
8
|
+
readonly display?: string;
|
|
9
|
+
/** Fields typing searches, case-insensitively; by default the display field. */
|
|
10
|
+
readonly searchFields?: readonly string[];
|
|
11
|
+
/** Siblings' order. */
|
|
12
|
+
readonly orderBy?: readonly SortKey[];
|
|
13
|
+
/** Whether a node can be chosen; one that can't (a group) only opens. All can by default. */
|
|
14
|
+
readonly choosable?: (entity: Entity) => boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Where a tree picker's choices come from: a self-referencing set, shown as
|
|
18
|
+
* a tree a level at a time. Each picker that opens gets a `Tree` of its own,
|
|
19
|
+
* so open folders are its own; the records are the store's.
|
|
20
|
+
*/
|
|
21
|
+
export declare class TreeChoices {
|
|
22
|
+
#private;
|
|
23
|
+
readonly ctx: DataContext;
|
|
24
|
+
readonly spec: TreeChoicesSpec;
|
|
25
|
+
readonly type: EntityType;
|
|
26
|
+
readonly key: string;
|
|
27
|
+
readonly display: string;
|
|
28
|
+
constructor(ctx: DataContext, spec: TreeChoicesSpec);
|
|
29
|
+
/** A tree whose filter follows `search`, keeping each match's ancestors; `dispose` it when done. */
|
|
30
|
+
tree(search: ReadonlySignal<string>): Tree;
|
|
31
|
+
/** The record a value names, fetched if it isn't loaded. */
|
|
32
|
+
record(value: Value | undefined): ReadonlySignal<Entity | undefined> | undefined;
|
|
33
|
+
text(entity: Entity): string;
|
|
34
|
+
/** The names of a node's ancestors, top first; those still loading are fetched and fill in. */
|
|
35
|
+
trail(entity: Entity): string[];
|
|
36
|
+
canChoose(entity: Entity): boolean;
|
|
37
|
+
matches(entity: Entity, typed: string): boolean;
|
|
38
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { computed } from "@preact/signals-core";
|
|
2
|
+
import { keyText, Tree } from "@getaster/data";
|
|
3
|
+
/**
|
|
4
|
+
* Where a tree picker's choices come from: a self-referencing set, shown as
|
|
5
|
+
* a tree a level at a time. Each picker that opens gets a `Tree` of its own,
|
|
6
|
+
* so open folders are its own; the records are the store's.
|
|
7
|
+
*/
|
|
8
|
+
export class TreeChoices {
|
|
9
|
+
ctx;
|
|
10
|
+
spec;
|
|
11
|
+
type;
|
|
12
|
+
key;
|
|
13
|
+
display;
|
|
14
|
+
#root;
|
|
15
|
+
constructor(ctx, spec) {
|
|
16
|
+
this.ctx = ctx;
|
|
17
|
+
this.spec = spec;
|
|
18
|
+
this.type = ctx.schema.set(spec.set).type;
|
|
19
|
+
this.key = this.type.key[0];
|
|
20
|
+
this.display = spec.display ?? this.type.titleField ?? this.key;
|
|
21
|
+
this.#root = this.type.field(spec.parentField)?.rootValue ?? null;
|
|
22
|
+
}
|
|
23
|
+
/** A tree whose filter follows `search`, keeping each match's ancestors; `dispose` it when done. */
|
|
24
|
+
tree(search) {
|
|
25
|
+
const fields = this.spec.searchFields ?? [this.display];
|
|
26
|
+
const orderBy = this.spec.orderBy ?? [];
|
|
27
|
+
return new Tree(this.ctx, {
|
|
28
|
+
set: this.spec.set,
|
|
29
|
+
parentField: this.spec.parentField,
|
|
30
|
+
orderBy: computed(() => orderBy),
|
|
31
|
+
filter: computed(() => {
|
|
32
|
+
const text = search.value.trim();
|
|
33
|
+
if (!text)
|
|
34
|
+
return undefined;
|
|
35
|
+
const each = fields.map((field) => ({ op: "contains", field, value: text, caseInsensitive: true }));
|
|
36
|
+
return each.length === 1 ? each[0] : { op: "or", args: each };
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** The record a value names, fetched if it isn't loaded. */
|
|
41
|
+
record(value) {
|
|
42
|
+
return value === null || value === undefined ? undefined : this.ctx.lookup(this.spec.set, keyText([value]));
|
|
43
|
+
}
|
|
44
|
+
text(entity) {
|
|
45
|
+
const v = entity[this.display];
|
|
46
|
+
return v === null || v === undefined ? "" : String(v);
|
|
47
|
+
}
|
|
48
|
+
/** The names of a node's ancestors, top first; those still loading are fetched and fill in. */
|
|
49
|
+
trail(entity) {
|
|
50
|
+
const out = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
let parent = entity[this.spec.parentField];
|
|
53
|
+
while (parent !== null && parent !== undefined && parent !== this.#root && !seen.has(parent)) {
|
|
54
|
+
seen.add(parent);
|
|
55
|
+
const record = this.record(parent)?.value;
|
|
56
|
+
if (!record) {
|
|
57
|
+
out.unshift("…");
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
out.unshift(this.text(record));
|
|
61
|
+
parent = record[this.spec.parentField];
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
canChoose(entity) {
|
|
66
|
+
return this.spec.choosable?.(entity) ?? true;
|
|
67
|
+
}
|
|
68
|
+
matches(entity, typed) {
|
|
69
|
+
const text = typed.trim().toLowerCase();
|
|
70
|
+
return text !== "" && (this.spec.searchFields ?? [this.display]).some((f) => String(entity[f] ?? "").toLowerCase().includes(text));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { 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 { TreeChoices } from "./tree-choices.ts";
|
|
5
|
+
/**
|
|
6
|
+
* A combobox whose popup is a tree (APG editable combobox, `aria-haspopup`
|
|
7
|
+
* tree): for choosing one node of a hierarchy where names repeat across
|
|
8
|
+
* branches. The closed control shows the chosen node's name with its path
|
|
9
|
+
* under it. Opening reveals the chosen node; typing filters the tree to the
|
|
10
|
+
* matches and the folders leading to them.
|
|
11
|
+
*
|
|
12
|
+
* Keyboard: ↓/↑ open and move, Home/End go to the first and last row, → opens
|
|
13
|
+
* a folder or moves into it, ← closes it or moves to its parent, Enter
|
|
14
|
+
* chooses (or opens a folder that can't be chosen), Escape closes and
|
|
15
|
+
* restores the text. ←/→ move the caret instead until ↓/↑ have moved in the
|
|
16
|
+
* tree since the last typing. Focus stays in the input; the active row is
|
|
17
|
+
* its `aria-activedescendant`.
|
|
18
|
+
*/
|
|
19
|
+
export declare class TreePicker extends BoundField {
|
|
20
|
+
#private;
|
|
21
|
+
get options(): TreeChoices | undefined;
|
|
22
|
+
set options(v: TreeChoices | undefined);
|
|
23
|
+
get placeholder(): string;
|
|
24
|
+
set placeholder(v: string);
|
|
25
|
+
focus(options?: FocusOptions): void;
|
|
26
|
+
disconnectedCallback(): void;
|
|
27
|
+
protected describe(_meta: FieldMeta | undefined, value: Value | undefined): string;
|
|
28
|
+
protected control(c: ControlContext): View;
|
|
29
|
+
}
|