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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +6 -2
  4. package/bin/aster-theme-check.js +317 -0
  5. package/dist/base/drag.d.ts +45 -0
  6. package/dist/base/drag.js +139 -0
  7. package/dist/base/element.d.ts +51 -0
  8. package/dist/base/element.js +133 -0
  9. package/dist/breadcrumbs/breadcrumbs.d.ts +23 -0
  10. package/dist/breadcrumbs/breadcrumbs.js +51 -0
  11. package/dist/button/button.d.ts +37 -0
  12. package/dist/button/button.js +108 -0
  13. package/dist/caret/caret-group.d.ts +14 -0
  14. package/dist/caret/caret-group.js +71 -0
  15. package/dist/carousel/carousel.d.ts +28 -0
  16. package/dist/carousel/carousel.js +134 -0
  17. package/dist/component/component.d.ts +73 -0
  18. package/dist/component/component.js +190 -0
  19. package/dist/component/echarts.d.ts +46 -0
  20. package/dist/component/echarts.js +95 -0
  21. package/dist/composition/authoring.d.ts +157 -0
  22. package/dist/composition/authoring.js +291 -0
  23. package/dist/composition/builtins.d.ts +1130 -0
  24. package/dist/composition/builtins.js +318 -0
  25. package/dist/composition/command-bar.d.ts +18 -0
  26. package/dist/composition/command-bar.js +34 -0
  27. package/dist/composition/commands.d.ts +14 -0
  28. package/dist/composition/commands.js +59 -0
  29. package/dist/composition/composite.d.ts +48 -0
  30. package/dist/composition/composite.js +409 -0
  31. package/dist/composition/contracts.d.ts +63 -0
  32. package/dist/composition/contracts.js +97 -0
  33. package/dist/composition/definition.d.ts +94 -0
  34. package/dist/composition/definition.js +291 -0
  35. package/dist/composition/element.d.ts +35 -0
  36. package/dist/composition/element.js +51 -0
  37. package/dist/composition/registry.d.ts +83 -0
  38. package/dist/composition/registry.js +99 -0
  39. package/dist/composition/schema.d.ts +9 -0
  40. package/dist/composition/schema.js +139 -0
  41. package/dist/composition/sessions.d.ts +15 -0
  42. package/dist/composition/sessions.js +61 -0
  43. package/dist/dashboard/dashboard.d.ts +26 -0
  44. package/dist/dashboard/dashboard.js +296 -0
  45. package/dist/dashboard/layout.d.ts +19 -0
  46. package/dist/dashboard/layout.js +48 -0
  47. package/dist/dashboard/widget.d.ts +42 -0
  48. package/dist/dashboard/widget.js +165 -0
  49. package/dist/date/calendar.d.ts +34 -0
  50. package/dist/date/calendar.js +137 -0
  51. package/dist/date/date-field.d.ts +18 -0
  52. package/dist/date/date-field.js +81 -0
  53. package/dist/date/dates.d.ts +22 -0
  54. package/dist/date/dates.js +80 -0
  55. package/dist/dialog/dialog.d.ts +23 -0
  56. package/dist/dialog/dialog.js +70 -0
  57. package/dist/field/bound-field.d.ts +55 -0
  58. package/dist/field/bound-field.js +139 -0
  59. package/dist/field/check.d.ts +11 -0
  60. package/dist/field/check.js +26 -0
  61. package/dist/field/composite-field.d.ts +35 -0
  62. package/dist/field/composite-field.js +88 -0
  63. package/dist/field/field.d.ts +29 -0
  64. package/dist/field/field.js +75 -0
  65. package/dist/field/input.d.ts +15 -0
  66. package/dist/field/input.js +18 -0
  67. package/dist/field/number-field.d.ts +33 -0
  68. package/dist/field/number-field.js +179 -0
  69. package/dist/field/numeric.d.ts +4 -0
  70. package/dist/field/numeric.js +57 -0
  71. package/dist/field/values.d.ts +14 -0
  72. package/dist/field/values.js +60 -0
  73. package/dist/filter/filter-builder.d.ts +18 -0
  74. package/dist/filter/filter-builder.js +305 -0
  75. package/dist/form/controls.d.ts +20 -0
  76. package/dist/form/controls.js +34 -0
  77. package/dist/form/form.d.ts +46 -0
  78. package/dist/form/form.js +85 -0
  79. package/dist/format.d.ts +10 -0
  80. package/dist/format.js +57 -0
  81. package/dist/grid/filters.d.ts +16 -0
  82. package/dist/grid/filters.js +66 -0
  83. package/dist/grid/grid.d.ts +209 -0
  84. package/dist/grid/grid.js +2587 -0
  85. package/dist/grid/rows.d.ts +32 -0
  86. package/dist/grid/rows.js +89 -0
  87. package/dist/grid/view.d.ts +69 -0
  88. package/dist/grid/view.js +172 -0
  89. package/dist/index.d.ts +63 -0
  90. package/dist/index.js +61 -0
  91. package/dist/layout/stack.d.ts +112 -0
  92. package/dist/layout/stack.js +21 -0
  93. package/dist/list-edit/list-edit.d.ts +42 -0
  94. package/dist/list-edit/list-edit.js +220 -0
  95. package/dist/listbox/listbox.d.ts +45 -0
  96. package/dist/listbox/listbox.js +187 -0
  97. package/dist/menu/menu.d.ts +55 -0
  98. package/dist/menu/menu.js +286 -0
  99. package/dist/motion.d.ts +8 -0
  100. package/dist/motion.js +23 -0
  101. package/dist/overlay/anchor.d.ts +18 -0
  102. package/dist/overlay/anchor.js +41 -0
  103. package/dist/picker/options.d.ts +59 -0
  104. package/dist/picker/options.js +97 -0
  105. package/dist/picker/picker.d.ts +46 -0
  106. package/dist/picker/picker.js +492 -0
  107. package/dist/picker/tree-choices.d.ts +38 -0
  108. package/dist/picker/tree-choices.js +72 -0
  109. package/dist/picker/tree-picker.d.ts +29 -0
  110. package/dist/picker/tree-picker.js +282 -0
  111. package/dist/progress/policy.d.ts +5 -0
  112. package/dist/progress/policy.js +28 -0
  113. package/dist/progress/status.d.ts +20 -0
  114. package/dist/progress/status.js +38 -0
  115. package/dist/rule/operators.d.ts +8 -0
  116. package/dist/rule/operators.js +9 -0
  117. package/dist/rule/rule-editor.d.ts +57 -0
  118. package/dist/rule/rule-editor.js +290 -0
  119. package/dist/section/accordion.d.ts +24 -0
  120. package/dist/section/accordion.js +90 -0
  121. package/dist/section/section.d.ts +30 -0
  122. package/dist/section/section.js +99 -0
  123. package/dist/select/choice-element.d.ts +20 -0
  124. package/dist/select/choice-element.js +55 -0
  125. package/dist/select/choice.d.ts +34 -0
  126. package/dist/select/choice.js +122 -0
  127. package/dist/select/many.d.ts +13 -0
  128. package/dist/select/many.js +20 -0
  129. package/dist/select/select.d.ts +16 -0
  130. package/dist/select/select.js +40 -0
  131. package/dist/sidebar/sidebar.d.ts +47 -0
  132. package/dist/sidebar/sidebar.js +105 -0
  133. package/dist/slicer/slicer.d.ts +26 -0
  134. package/dist/slicer/slicer.js +140 -0
  135. package/dist/split/split.d.ts +41 -0
  136. package/dist/split/split.js +237 -0
  137. package/dist/styles/contract.d.ts +36 -0
  138. package/dist/styles/contract.js +39 -0
  139. package/dist/switch/switch.d.ts +22 -0
  140. package/dist/switch/switch.js +69 -0
  141. package/dist/tabs/tabs.d.ts +30 -0
  142. package/dist/tabs/tabs.js +202 -0
  143. package/dist/tile-grid/scroll.d.ts +9 -0
  144. package/dist/tile-grid/scroll.js +21 -0
  145. package/dist/tile-grid/tile-grid.d.ts +64 -0
  146. package/dist/tile-grid/tile-grid.js +734 -0
  147. package/dist/toast/toast.d.ts +25 -0
  148. package/dist/toast/toast.js +92 -0
  149. package/dist/tree/navigator.d.ts +41 -0
  150. package/dist/tree/navigator.js +228 -0
  151. package/dist/window/chrome.d.ts +3 -0
  152. package/dist/window/chrome.js +7 -0
  153. package/dist/window/window.d.ts +46 -0
  154. package/dist/window/window.js +347 -0
  155. package/dist/window/workspace.d.ts +29 -0
  156. package/dist/window/workspace.js +137 -0
  157. package/package.json +45 -4
  158. package/styles/aster-ui.css +1147 -0
  159. package/styles/base.css +88 -0
  160. package/styles/contract.ts +41 -0
  161. package/styles/elements.css +1058 -0
  162. package/styles/harbour.css +126 -0
  163. package/styles/lamplight.css +118 -0
@@ -0,0 +1,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
+ }