@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,42 @@
|
|
|
1
|
+
import type { Collection, ListEdit, ListRow } from "@getaster/data";
|
|
2
|
+
import type { PickerOptions } from "../picker/options.ts";
|
|
3
|
+
import { AsterElement, type View } from "../base/element.ts";
|
|
4
|
+
export interface ListColumn {
|
|
5
|
+
readonly field: string;
|
|
6
|
+
/** A CSS track size; `1fr` by default. */
|
|
7
|
+
readonly width?: string;
|
|
8
|
+
/** Makes a foreign key a picker over these records. */
|
|
9
|
+
readonly options?: Collection | PickerOptions;
|
|
10
|
+
/** Shows an option's text instead of the key, for read-only rows. */
|
|
11
|
+
readonly display?: (row: ListRow) => string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The rows of a list edit as a grid of fields. A new row shows as pending
|
|
15
|
+
* in full. Removing an existing row marks it for deletion: it shows struck
|
|
16
|
+
* through and read-only, with Undo, until the list is saved. Removing a new
|
|
17
|
+
* row discards it. A changed row offers Revert. While `readOnly`, rows show
|
|
18
|
+
* as text, with `readOnlyReason` above them, and nothing can be added.
|
|
19
|
+
*
|
|
20
|
+
* A list with a position field can be reordered: by dragging a row's handle
|
|
21
|
+
* (a touch or pen holds it still first), or with Alt+↑ and Alt+↓ on it, the
|
|
22
|
+
* new place announced.
|
|
23
|
+
*/
|
|
24
|
+
export declare class ListEditor extends AsterElement {
|
|
25
|
+
#private;
|
|
26
|
+
get list(): ListEdit | undefined;
|
|
27
|
+
set list(v: ListEdit | undefined);
|
|
28
|
+
get columns(): readonly ListColumn[];
|
|
29
|
+
set columns(v: readonly ListColumn[]);
|
|
30
|
+
get label(): string;
|
|
31
|
+
set label(v: string);
|
|
32
|
+
get addLabel(): string;
|
|
33
|
+
set addLabel(v: string);
|
|
34
|
+
/** Why the rows can't be edited now; set, even to an empty string, it makes the list read-only. */
|
|
35
|
+
get readOnlyReason(): string | undefined;
|
|
36
|
+
set readOnlyReason(v: string | undefined);
|
|
37
|
+
/** Shown when there are no rows. */
|
|
38
|
+
get empty(): View;
|
|
39
|
+
set empty(v: View);
|
|
40
|
+
protected render(): View;
|
|
41
|
+
protected updated(): void;
|
|
42
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { html, nothing } from "lit-html";
|
|
2
|
+
import { styleMap } from "lit-html/directives/style-map.js";
|
|
3
|
+
import { AsterElement, define, each } from "../base/element.js";
|
|
4
|
+
import { holds, pressDrag } from "../base/drag.js";
|
|
5
|
+
import { controlFor } from "../form/controls.js";
|
|
6
|
+
import { formatValue } from "../field/values.js";
|
|
7
|
+
/**
|
|
8
|
+
* The rows of a list edit as a grid of fields. A new row shows as pending
|
|
9
|
+
* in full. Removing an existing row marks it for deletion: it shows struck
|
|
10
|
+
* through and read-only, with Undo, until the list is saved. Removing a new
|
|
11
|
+
* row discards it. A changed row offers Revert. While `readOnly`, rows show
|
|
12
|
+
* as text, with `readOnlyReason` above them, and nothing can be added.
|
|
13
|
+
*
|
|
14
|
+
* A list with a position field can be reordered: by dragging a row's handle
|
|
15
|
+
* (a touch or pen holds it still first), or with Alt+↑ and Alt+↓ on it, the
|
|
16
|
+
* new place announced.
|
|
17
|
+
*/
|
|
18
|
+
export class ListEditor extends AsterElement {
|
|
19
|
+
#list = this.prop(undefined);
|
|
20
|
+
#columns = this.prop([]);
|
|
21
|
+
#label = this.prop("");
|
|
22
|
+
#addLabel = this.prop("Add");
|
|
23
|
+
#empty = this.prop(nothing);
|
|
24
|
+
#readOnly = this.prop(undefined);
|
|
25
|
+
/** A row just added or moved, focused once it has rendered (a moved row on its handle). */
|
|
26
|
+
#focus;
|
|
27
|
+
#focusHandle = false;
|
|
28
|
+
#said = this.prop("");
|
|
29
|
+
/** A row being dragged: where its drop line is, whether a touch lifted it, and whether that touch is outside the list. */
|
|
30
|
+
#drag = this.prop(undefined);
|
|
31
|
+
get list() {
|
|
32
|
+
return this.#list.value;
|
|
33
|
+
}
|
|
34
|
+
set list(v) {
|
|
35
|
+
this.#list.value = v;
|
|
36
|
+
}
|
|
37
|
+
get columns() {
|
|
38
|
+
return this.#columns.value;
|
|
39
|
+
}
|
|
40
|
+
set columns(v) {
|
|
41
|
+
this.#columns.value = v;
|
|
42
|
+
}
|
|
43
|
+
get label() {
|
|
44
|
+
return this.#label.value;
|
|
45
|
+
}
|
|
46
|
+
set label(v) {
|
|
47
|
+
this.#label.value = v;
|
|
48
|
+
}
|
|
49
|
+
get addLabel() {
|
|
50
|
+
return this.#addLabel.value;
|
|
51
|
+
}
|
|
52
|
+
set addLabel(v) {
|
|
53
|
+
this.#addLabel.value = v;
|
|
54
|
+
}
|
|
55
|
+
/** Why the rows can't be edited now; set, even to an empty string, it makes the list read-only. */
|
|
56
|
+
get readOnlyReason() {
|
|
57
|
+
return this.#readOnly.value;
|
|
58
|
+
}
|
|
59
|
+
set readOnlyReason(v) {
|
|
60
|
+
this.#readOnly.value = v;
|
|
61
|
+
}
|
|
62
|
+
/** Shown when there are no rows. */
|
|
63
|
+
get empty() {
|
|
64
|
+
return this.#empty.value;
|
|
65
|
+
}
|
|
66
|
+
set empty(v) {
|
|
67
|
+
this.#empty.value = v;
|
|
68
|
+
}
|
|
69
|
+
render() {
|
|
70
|
+
const list = this.#list.value;
|
|
71
|
+
if (!list)
|
|
72
|
+
return nothing;
|
|
73
|
+
const columns = this.#columns.value;
|
|
74
|
+
const type = list.type;
|
|
75
|
+
const rows = list.rows.value;
|
|
76
|
+
const reason = this.#readOnly.value;
|
|
77
|
+
const readOnly = reason !== undefined;
|
|
78
|
+
const ordered = list.ordered && !readOnly;
|
|
79
|
+
const tracks = styleMap({ "--list-columns": `${ordered ? "24px " : ""}${columns.map((c) => c.width ?? "1fr").join(" ")} auto` });
|
|
80
|
+
const drag = this.#drag.value;
|
|
81
|
+
return html `${reason ? html `<p class="aster-hint">${reason}</p>` : nothing}
|
|
82
|
+
<div class="aster-list-edit" role="table" aria-label=${this.#label.value} style=${tracks}>
|
|
83
|
+
<div class="aster-list-edit-row aster-list-edit-head" role="row">
|
|
84
|
+
${ordered ? html `<span role="columnheader"><span class="aster-sr-only">Order</span></span>` : nothing}
|
|
85
|
+
${columns.map((c) => html `<span role="columnheader">${type.field(c.field)?.title ?? c.field}</span>`)}
|
|
86
|
+
<span role="columnheader"><span class="aster-sr-only">Actions</span></span>
|
|
87
|
+
</div>
|
|
88
|
+
${each(rows, (r) => r.id, (r) => this.#row(r, columns, readOnly, ordered))}
|
|
89
|
+
${drag && !drag.outside ? html `<div class="aster-list-edit-drop" aria-hidden="true" style=${`--at:${drag.to}`}></div>` : nothing}
|
|
90
|
+
</div>
|
|
91
|
+
<span class="aster-sr-only" aria-live="polite">${this.#said.value}</span>
|
|
92
|
+
${rows.length === 0 ? html `<p class="aster-list-edit-empty">${this.#empty.value}</p>` : nothing}
|
|
93
|
+
${readOnly
|
|
94
|
+
? nothing
|
|
95
|
+
: html `<button type="button" class="aster-list-edit-add" @click=${() => this.#add(list)}>
|
|
96
|
+
<svg viewBox="0 0 14 14" aria-hidden="true"><path d="M7 2.5v9M2.5 7h9"/></svg>${this.#addLabel.value}
|
|
97
|
+
</button>`}`;
|
|
98
|
+
}
|
|
99
|
+
#row(row, columns, readOnly, ordered) {
|
|
100
|
+
const list = this.#list.peek();
|
|
101
|
+
const { edit } = row;
|
|
102
|
+
const first = String(edit.values.value[columns[0]?.field ?? ""] ?? "") || "new row";
|
|
103
|
+
// Struck through or marked new is drawn; the first cell also says it.
|
|
104
|
+
const said = row.removing ? "To be removed: " : row.isNew ? "New: " : "";
|
|
105
|
+
const lead = (i) => (i === 0 && said ? html `<span class="aster-sr-only">${said}</span>` : nothing);
|
|
106
|
+
const cells = row.removing || readOnly
|
|
107
|
+
? columns.map((c, i) => html `<span role="cell" class="aster-list-edit-static" ?data-pending=${!row.removing && edit.pending(c.field).value}>${lead(i)}${c.display?.(row) ?? formatValue(edit.type.field(c.field), edit.values.value[c.field])}</span>`)
|
|
108
|
+
: columns.map((c, i) => {
|
|
109
|
+
// A field this row can't change (a key once set) reads as text, its reason on hover, not repeated on every row.
|
|
110
|
+
const state = edit.state(c.field).value;
|
|
111
|
+
return state.readOnly && !row.isNew
|
|
112
|
+
? html `<span role="cell" class="aster-list-edit-static" title=${state.reasons.readOnly ?? ""}>${lead(i)}${c.display?.(row) ?? formatValue(edit.type.field(c.field), edit.values.value[c.field])}</span>`
|
|
113
|
+
: html `<span role="cell">${lead(i)}${controlFor(edit, c.field, { options: c.options, labelled: false })}</span>`;
|
|
114
|
+
});
|
|
115
|
+
const action = readOnly
|
|
116
|
+
? nothing
|
|
117
|
+
: row.removing
|
|
118
|
+
? html `<button type="button" class="aster-list-edit-action" @click=${() => list.restore(row.id)}
|
|
119
|
+
aria-label=${`Undo removing ${first}`}>Undo</button>`
|
|
120
|
+
: html `${edit.dirty.value && !row.isNew
|
|
121
|
+
? html `<button type="button" class="aster-list-edit-action" @click=${() => edit.revert()}
|
|
122
|
+
aria-label=${`Revert ${first}`}>Revert</button>`
|
|
123
|
+
: nothing}
|
|
124
|
+
<button type="button" class="aster-list-edit-action" @click=${() => list.remove(row.id)}
|
|
125
|
+
aria-label=${`Remove ${first}`}>Remove</button>`;
|
|
126
|
+
const handle = !ordered
|
|
127
|
+
? nothing
|
|
128
|
+
: row.removing
|
|
129
|
+
? html `<span role="cell"></span>`
|
|
130
|
+
: html `<span role="cell"><button type="button" class="aster-list-edit-handle" aria-label=${`Move ${first}`}
|
|
131
|
+
aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown" title="Drag, or Alt+↑ and Alt+↓"
|
|
132
|
+
@keydown=${(e) => this.#moveKey(e, row, first)}
|
|
133
|
+
@pointerdown=${(e) => this.#dragStart(e, row, first)}>
|
|
134
|
+
<svg viewBox="0 0 14 14" aria-hidden="true"><path d="M5 3.5h.01M9 3.5h.01M5 7h.01M9 7h.01M5 10.5h.01M9 10.5h.01"/></svg>
|
|
135
|
+
</button></span>`;
|
|
136
|
+
return html `<div class="aster-list-edit-row" role="row" data-id=${row.id} ?data-new=${row.isNew} ?data-removing=${row.removing}
|
|
137
|
+
?data-pending=${row.isNew || edit.dirty.value} ?data-dragging=${this.#drag.value?.id === row.id}
|
|
138
|
+
?data-lifted=${this.#drag.value?.id === row.id && this.#drag.value.lifted}>
|
|
139
|
+
${handle}${cells}
|
|
140
|
+
<span role="cell" class="aster-list-edit-actions">${action}</span>
|
|
141
|
+
${row.removeError ? html `<p class="aster-error aster-list-edit-error" role="alert">${row.removeError}</p>` : nothing}
|
|
142
|
+
</div>`;
|
|
143
|
+
}
|
|
144
|
+
/** Where `row` is among the rows not being removed, and how many there are. */
|
|
145
|
+
#place(row) {
|
|
146
|
+
const live = this.#list.peek().rows.peek().filter((r) => !r.removing);
|
|
147
|
+
return [live.findIndex((r) => r.id === row.id), live.length];
|
|
148
|
+
}
|
|
149
|
+
#moveTo(row, index, name) {
|
|
150
|
+
const [from, count] = this.#place(row);
|
|
151
|
+
const to = Math.max(0, Math.min(count - 1, index));
|
|
152
|
+
if (to === from)
|
|
153
|
+
return;
|
|
154
|
+
this.#list.peek().move(row.id, to);
|
|
155
|
+
this.#said.value = `${name}, moved to ${to + 1} of ${count}`;
|
|
156
|
+
this.#focus = row.id;
|
|
157
|
+
this.#focusHandle = true;
|
|
158
|
+
}
|
|
159
|
+
#moveKey(e, row, name) {
|
|
160
|
+
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown"))
|
|
161
|
+
return;
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
this.#moveTo(row, this.#place(row)[0] + (e.key === "ArrowUp" ? -1 : 1), name);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Dragging by the handle: a mouse lifts the row past a few pixels, a touch
|
|
167
|
+
* or pen by holding still (`pressDrag`), the row then drawn lifted. The
|
|
168
|
+
* drop line follows the pointer between rows; letting go moves the row
|
|
169
|
+
* there. Escape or an interrupted pointer doesn't, and nor does a touch
|
|
170
|
+
* let go outside the list.
|
|
171
|
+
*/
|
|
172
|
+
#dragStart(e, row, name) {
|
|
173
|
+
if (e.button !== 0)
|
|
174
|
+
return;
|
|
175
|
+
const handle = e.currentTarget;
|
|
176
|
+
const touch = holds(e);
|
|
177
|
+
const table = this.querySelector(".aster-list-edit");
|
|
178
|
+
const rows = () => [...this.querySelectorAll(".aster-list-edit-row[data-id]:not([data-removing])")];
|
|
179
|
+
const others = () => rows().filter((el) => el.dataset.id !== row.id);
|
|
180
|
+
const indexAt = (y) => others().filter((el) => {
|
|
181
|
+
const r = el.getBoundingClientRect();
|
|
182
|
+
return r.top + r.height / 2 < y;
|
|
183
|
+
}).length;
|
|
184
|
+
const [from, count] = this.#place(row);
|
|
185
|
+
let target;
|
|
186
|
+
let outside = false;
|
|
187
|
+
pressDrag(e, { distance: 4, vertical: true, capture: handle, escape: true }, {
|
|
188
|
+
lift: () => (this.#said.value = `Picked up ${name}, ${from + 1} of ${count}`),
|
|
189
|
+
move: (x, y) => {
|
|
190
|
+
target = indexAt(y);
|
|
191
|
+
const el = others()[target - 1];
|
|
192
|
+
const box = table.getBoundingClientRect();
|
|
193
|
+
const line = el ? el.getBoundingClientRect().bottom - box.top : (rows()[0]?.offsetTop ?? 0);
|
|
194
|
+
outside = touch && (x < box.left || x > box.right || y < box.top - 24 || y > box.bottom + 24);
|
|
195
|
+
this.#drag.value = { id: row.id, to: Math.round(line), lifted: touch, outside };
|
|
196
|
+
},
|
|
197
|
+
end: (drop) => {
|
|
198
|
+
this.#drag.value = undefined;
|
|
199
|
+
if (drop && !outside && target !== undefined && target !== from)
|
|
200
|
+
this.#moveTo(row, target, name);
|
|
201
|
+
else
|
|
202
|
+
this.#said.value = `${name} not moved`;
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
updated() {
|
|
207
|
+
if (this.#focus === undefined)
|
|
208
|
+
return;
|
|
209
|
+
const target = this.querySelector(`[data-id="${CSS.escape(this.#focus)}"] ${this.#focusHandle ? ".aster-list-edit-handle" : ":is(input, select)"}`);
|
|
210
|
+
if (!target)
|
|
211
|
+
return;
|
|
212
|
+
this.#focus = undefined;
|
|
213
|
+
this.#focusHandle = false;
|
|
214
|
+
target.focus();
|
|
215
|
+
}
|
|
216
|
+
#add(list) {
|
|
217
|
+
this.#focus = list.add().id;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
define("aster-list-editor", ListEditor);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Collection, type Entity, type KeyText, type Selection } from "@getaster/data";
|
|
2
|
+
import { AsterElement, type View } from "../base/element.ts";
|
|
3
|
+
import "../caret/caret-group.ts";
|
|
4
|
+
export type RowRenderer = (entity: Entity, key: KeyText) => View;
|
|
5
|
+
export interface RowMenuDetail {
|
|
6
|
+
readonly key: KeyText;
|
|
7
|
+
/** Where to open a menu: the pointer, or the row's start for the keyboard. */
|
|
8
|
+
readonly x: number;
|
|
9
|
+
readonly y: number;
|
|
10
|
+
readonly opener: HTMLElement;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A single-select listbox over a selection's collection. The selection is
|
|
14
|
+
* data the owner provides, so other views can follow it; with `publish`, the
|
|
15
|
+
* listbox also publishes it to its page scope under that key. Focus stays on
|
|
16
|
+
* the listbox and the active row is its `aria-activedescendant`.
|
|
17
|
+
*
|
|
18
|
+
* Fires `row-menu` (context menu, Shift+F10 or the Menu key) and `row-open`
|
|
19
|
+
* (Enter, double-click). Keys typed into an editor inside a row are left alone.
|
|
20
|
+
*/
|
|
21
|
+
export declare class Listbox extends AsterElement {
|
|
22
|
+
#private;
|
|
23
|
+
get collection(): Collection | undefined;
|
|
24
|
+
set collection(v: Collection | undefined);
|
|
25
|
+
get selection(): Selection | undefined;
|
|
26
|
+
set selection(v: Selection | undefined);
|
|
27
|
+
/** The page-scope key to publish the selection under. */
|
|
28
|
+
get publish(): string;
|
|
29
|
+
set publish(v: string);
|
|
30
|
+
connectedCallback(): void;
|
|
31
|
+
get renderRow(): RowRenderer;
|
|
32
|
+
set renderRow(v: RowRenderer);
|
|
33
|
+
/** Shown when the collection is loaded and has no rows. */
|
|
34
|
+
get empty(): View;
|
|
35
|
+
set empty(v: View);
|
|
36
|
+
get label(): string;
|
|
37
|
+
set label(v: string);
|
|
38
|
+
/** The row element for a key, if it's rendered. */
|
|
39
|
+
rowFor(key: KeyText): HTMLElement | undefined;
|
|
40
|
+
/** Draws the eye to a row once, after it appears or is revealed. */
|
|
41
|
+
flash(key: KeyText): void;
|
|
42
|
+
focus(options?: FocusOptions): void;
|
|
43
|
+
protected render(): View;
|
|
44
|
+
select(key: KeyText): void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { effect } from "@preact/signals-core";
|
|
2
|
+
import { selectionPublication } from "@getaster/data";
|
|
3
|
+
import { html, nothing } from "lit-html";
|
|
4
|
+
import { AsterElement, define, each, scopeOf, uid } from "../base/element.js";
|
|
5
|
+
import "../caret/caret-group.js";
|
|
6
|
+
/**
|
|
7
|
+
* A single-select listbox over a selection's collection. The selection is
|
|
8
|
+
* data the owner provides, so other views can follow it; with `publish`, the
|
|
9
|
+
* listbox also publishes it to its page scope under that key. Focus stays on
|
|
10
|
+
* the listbox and the active row is its `aria-activedescendant`.
|
|
11
|
+
*
|
|
12
|
+
* Fires `row-menu` (context menu, Shift+F10 or the Menu key) and `row-open`
|
|
13
|
+
* (Enter, double-click). Keys typed into an editor inside a row are left alone.
|
|
14
|
+
*/
|
|
15
|
+
export class Listbox extends AsterElement {
|
|
16
|
+
#collection = this.prop(undefined);
|
|
17
|
+
#selection = this.prop(undefined);
|
|
18
|
+
#publish = this.prop("");
|
|
19
|
+
#row = this.prop(() => nothing);
|
|
20
|
+
#empty = this.prop(nothing);
|
|
21
|
+
#label = this.prop("");
|
|
22
|
+
#prefix = uid("aster-row") + "-";
|
|
23
|
+
get collection() {
|
|
24
|
+
return this.#collection.value;
|
|
25
|
+
}
|
|
26
|
+
set collection(v) {
|
|
27
|
+
this.#collection.value = v;
|
|
28
|
+
}
|
|
29
|
+
get selection() {
|
|
30
|
+
return this.#selection.value;
|
|
31
|
+
}
|
|
32
|
+
set selection(v) {
|
|
33
|
+
this.#selection.value = v;
|
|
34
|
+
}
|
|
35
|
+
/** The page-scope key to publish the selection under. */
|
|
36
|
+
get publish() {
|
|
37
|
+
return this.#publish.value;
|
|
38
|
+
}
|
|
39
|
+
set publish(v) {
|
|
40
|
+
this.#publish.value = v;
|
|
41
|
+
}
|
|
42
|
+
connectedCallback() {
|
|
43
|
+
super.connectedCallback();
|
|
44
|
+
this.onDisconnect(effect(() => {
|
|
45
|
+
const key = this.#publish.value;
|
|
46
|
+
const selection = this.#selection.value;
|
|
47
|
+
const scope = scopeOf(this);
|
|
48
|
+
if (key && selection && scope)
|
|
49
|
+
return scope.publish(key, selectionPublication(selection));
|
|
50
|
+
return undefined;
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
get renderRow() {
|
|
54
|
+
return this.#row.value;
|
|
55
|
+
}
|
|
56
|
+
set renderRow(v) {
|
|
57
|
+
this.#row.value = v;
|
|
58
|
+
}
|
|
59
|
+
/** Shown when the collection is loaded and has no rows. */
|
|
60
|
+
get empty() {
|
|
61
|
+
return this.#empty.value;
|
|
62
|
+
}
|
|
63
|
+
set empty(v) {
|
|
64
|
+
this.#empty.value = v;
|
|
65
|
+
}
|
|
66
|
+
get label() {
|
|
67
|
+
return this.#label.value;
|
|
68
|
+
}
|
|
69
|
+
set label(v) {
|
|
70
|
+
this.#label.value = v;
|
|
71
|
+
}
|
|
72
|
+
/** The row element for a key, if it's rendered. */
|
|
73
|
+
rowFor(key) {
|
|
74
|
+
return this.querySelector(`#${CSS.escape(this.#id(key))}`) ?? undefined;
|
|
75
|
+
}
|
|
76
|
+
/** Draws the eye to a row once, after it appears or is revealed. */
|
|
77
|
+
flash(key) {
|
|
78
|
+
requestAnimationFrame(() => {
|
|
79
|
+
const row = this.rowFor(key);
|
|
80
|
+
if (!row)
|
|
81
|
+
return;
|
|
82
|
+
row.classList.remove("aster-flash");
|
|
83
|
+
void row.offsetWidth;
|
|
84
|
+
row.classList.add("aster-flash");
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
focus(options) {
|
|
88
|
+
this.querySelector('[role="listbox"]')?.focus(options);
|
|
89
|
+
}
|
|
90
|
+
render() {
|
|
91
|
+
const collection = this.#collection.value ?? this.#selection.value?.collection;
|
|
92
|
+
if (!collection)
|
|
93
|
+
return nothing;
|
|
94
|
+
const all = collection.keys.value;
|
|
95
|
+
const items = collection.items.value;
|
|
96
|
+
const selected = this.#selection.value?.key.value;
|
|
97
|
+
const row = this.#row.value;
|
|
98
|
+
const rows = all.flatMap((k, i) => (k === undefined ? [] : [{ key: k, entity: items[i] }]));
|
|
99
|
+
const keys = rows.map((r) => r.key);
|
|
100
|
+
const empty = collection.count.value === 0;
|
|
101
|
+
const active = selected !== undefined && keys.includes(selected) ? this.#id(selected) : undefined;
|
|
102
|
+
return html `<aster-caret-group class="aster-listbox" role="listbox" aria-label=${this.#label.value} aria-busy=${collection.status.value === "loading" ? "true" : "false"}
|
|
103
|
+
tabindex=${empty ? -1 : 0} aria-activedescendant=${active ?? nothing}
|
|
104
|
+
@keydown=${(e) => this.#key(e, keys)}
|
|
105
|
+
@click=${(e) => this.#click(e)}
|
|
106
|
+
@dblclick=${(e) => this.#open(e)}
|
|
107
|
+
@contextmenu=${(e) => this.#menu(e)}>
|
|
108
|
+
${each(rows, (r) => r.key, ({ key, entity }) => html `<div class="aster-option" role="option" id=${this.#id(key)} data-key=${key}
|
|
109
|
+
aria-selected=${key === selected ? "true" : "false"}>${entity ? row(entity, key) : nothing}</div>`)}
|
|
110
|
+
${empty ? html `<div class="aster-listbox-empty" role="presentation">${this.#empty.value}</div>` : nothing}
|
|
111
|
+
</aster-caret-group>`;
|
|
112
|
+
}
|
|
113
|
+
select(key) {
|
|
114
|
+
const selection = this.#selection.peek();
|
|
115
|
+
if (!selection)
|
|
116
|
+
return;
|
|
117
|
+
selection.select(key);
|
|
118
|
+
requestAnimationFrame(() => this.rowFor(key)?.scrollIntoView({ block: "nearest" }));
|
|
119
|
+
}
|
|
120
|
+
#id(key) {
|
|
121
|
+
return this.#prefix + key.replace(/[^\w-]/g, (c) => `_${c.charCodeAt(0).toString(16)}`);
|
|
122
|
+
}
|
|
123
|
+
#rowOf(e) {
|
|
124
|
+
return e.target.closest('[role="option"]') ?? undefined;
|
|
125
|
+
}
|
|
126
|
+
#click(e) {
|
|
127
|
+
const row = this.#rowOf(e);
|
|
128
|
+
if (!row)
|
|
129
|
+
return;
|
|
130
|
+
// Dragging across a name to copy it doesn't change the selection.
|
|
131
|
+
const text = getSelection();
|
|
132
|
+
if (text && !text.isCollapsed && row.contains(text.anchorNode))
|
|
133
|
+
return;
|
|
134
|
+
this.select(row.dataset.key);
|
|
135
|
+
}
|
|
136
|
+
#open(e) {
|
|
137
|
+
if (editing(e))
|
|
138
|
+
return;
|
|
139
|
+
const row = this.#rowOf(e);
|
|
140
|
+
if (row)
|
|
141
|
+
this.emit("row-open", { key: row.dataset.key });
|
|
142
|
+
}
|
|
143
|
+
#menu(e) {
|
|
144
|
+
const row = this.#rowOf(e);
|
|
145
|
+
if (!row)
|
|
146
|
+
return;
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
this.select(row.dataset.key);
|
|
149
|
+
this.emit("row-menu", { key: row.dataset.key, x: e.clientX, y: e.clientY, opener: this.#list() });
|
|
150
|
+
}
|
|
151
|
+
#key(e, keys) {
|
|
152
|
+
if (editing(e))
|
|
153
|
+
return;
|
|
154
|
+
const current = this.#selection.peek()?.key.peek();
|
|
155
|
+
if ((e.key === "F10" && e.shiftKey) || e.key === "ContextMenu") {
|
|
156
|
+
if (current === undefined)
|
|
157
|
+
return;
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
const rect = this.rowFor(current)?.getBoundingClientRect();
|
|
160
|
+
this.emit("row-menu", { key: current, x: rect?.left ?? 0, y: rect?.bottom ?? 0, opener: this.#list() });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (e.key === "Enter" && current !== undefined) {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
this.emit("row-open", { key: current });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const i = current === undefined ? -1 : keys.indexOf(current);
|
|
169
|
+
const n = e.key === "Home" ? 0
|
|
170
|
+
: e.key === "End" ? keys.length - 1
|
|
171
|
+
: e.key === "ArrowDown" ? Math.min(keys.length - 1, i + 1)
|
|
172
|
+
: e.key === "ArrowUp" ? Math.max(0, i - 1)
|
|
173
|
+
: undefined;
|
|
174
|
+
if (n === undefined || keys[n] === undefined)
|
|
175
|
+
return;
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
this.select(keys[n]);
|
|
178
|
+
}
|
|
179
|
+
#list() {
|
|
180
|
+
return this.querySelector('[role="listbox"]') ?? this;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** The event comes from an editor inside a row, which keeps its own keys. */
|
|
184
|
+
function editing(e) {
|
|
185
|
+
return e.target.closest("input, textarea, select, [contenteditable]") !== null;
|
|
186
|
+
}
|
|
187
|
+
define("aster-listbox", Listbox);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { AsterElement, type View } from "../base/element.ts";
|
|
2
|
+
/** An entry of a menu. Without a `kind` it's an action. */
|
|
3
|
+
export type MenuItem = {
|
|
4
|
+
readonly kind?: "action";
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly run: () => void;
|
|
7
|
+
readonly disabled?: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly kind: "link";
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly href: string;
|
|
12
|
+
readonly activate?: (event: MouseEvent) => void;
|
|
13
|
+
} | {
|
|
14
|
+
readonly kind: "check" | "radio";
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly checked: boolean;
|
|
17
|
+
readonly run: () => void;
|
|
18
|
+
} | {
|
|
19
|
+
readonly kind: "submenu";
|
|
20
|
+
readonly label: string;
|
|
21
|
+
readonly items: readonly MenuItem[];
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: "group";
|
|
24
|
+
readonly label: string;
|
|
25
|
+
readonly items: readonly MenuItem[];
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: "separator";
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* A menu in the top layer (Popover API), following the APG menu pattern: it
|
|
31
|
+
* opens at a point (a context menu) or against an element, focuses its
|
|
32
|
+
* first item, and moves with the arrows, Home, End and type-ahead. → opens a
|
|
33
|
+
* submenu and ← closes it; check and radio items toggle in place. Escape
|
|
34
|
+
* closes one level, a press outside or focus leaving closes all, and focus
|
|
35
|
+
* returns to the opener. Disabled items stay focusable and say why.
|
|
36
|
+
*
|
|
37
|
+
* The popovers are manual because an auto popover opened from `contextmenu`
|
|
38
|
+
* is dismissed by the same click's pointer-up in some engines. A menu opened
|
|
39
|
+
* at a point writes only the point; one opened against an element, and every
|
|
40
|
+
* submenu, is placed by CSS anchor positioning and flips at the edges.
|
|
41
|
+
*/
|
|
42
|
+
export declare class Menu extends AsterElement {
|
|
43
|
+
#private;
|
|
44
|
+
/** Opens at a point, such as a right-click. */
|
|
45
|
+
openAt(point: {
|
|
46
|
+
x: number;
|
|
47
|
+
y: number;
|
|
48
|
+
}, items: readonly MenuItem[], label: string, opener: HTMLElement | null): void;
|
|
49
|
+
/** Opens against `anchor`, below it, flipping at the edges; focus returns to `opener`. */
|
|
50
|
+
openFrom(anchor: HTMLElement, items: readonly MenuItem[], label: string, opener?: HTMLElement): void;
|
|
51
|
+
close(): void;
|
|
52
|
+
protected render(): View;
|
|
53
|
+
protected updated(): void;
|
|
54
|
+
disconnectedCallback(): void;
|
|
55
|
+
}
|