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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +6 -2
  4. package/bin/aster-theme-check.js +317 -0
  5. package/dist/base/drag.d.ts +45 -0
  6. package/dist/base/drag.js +139 -0
  7. package/dist/base/element.d.ts +51 -0
  8. package/dist/base/element.js +133 -0
  9. package/dist/breadcrumbs/breadcrumbs.d.ts +23 -0
  10. package/dist/breadcrumbs/breadcrumbs.js +51 -0
  11. package/dist/button/button.d.ts +37 -0
  12. package/dist/button/button.js +108 -0
  13. package/dist/caret/caret-group.d.ts +14 -0
  14. package/dist/caret/caret-group.js +71 -0
  15. package/dist/carousel/carousel.d.ts +28 -0
  16. package/dist/carousel/carousel.js +134 -0
  17. package/dist/component/component.d.ts +73 -0
  18. package/dist/component/component.js +190 -0
  19. package/dist/component/echarts.d.ts +46 -0
  20. package/dist/component/echarts.js +95 -0
  21. package/dist/composition/authoring.d.ts +157 -0
  22. package/dist/composition/authoring.js +291 -0
  23. package/dist/composition/builtins.d.ts +1130 -0
  24. package/dist/composition/builtins.js +318 -0
  25. package/dist/composition/command-bar.d.ts +18 -0
  26. package/dist/composition/command-bar.js +34 -0
  27. package/dist/composition/commands.d.ts +14 -0
  28. package/dist/composition/commands.js +59 -0
  29. package/dist/composition/composite.d.ts +48 -0
  30. package/dist/composition/composite.js +409 -0
  31. package/dist/composition/contracts.d.ts +63 -0
  32. package/dist/composition/contracts.js +97 -0
  33. package/dist/composition/definition.d.ts +94 -0
  34. package/dist/composition/definition.js +291 -0
  35. package/dist/composition/element.d.ts +35 -0
  36. package/dist/composition/element.js +51 -0
  37. package/dist/composition/registry.d.ts +83 -0
  38. package/dist/composition/registry.js +99 -0
  39. package/dist/composition/schema.d.ts +9 -0
  40. package/dist/composition/schema.js +139 -0
  41. package/dist/composition/sessions.d.ts +15 -0
  42. package/dist/composition/sessions.js +61 -0
  43. package/dist/dashboard/dashboard.d.ts +26 -0
  44. package/dist/dashboard/dashboard.js +296 -0
  45. package/dist/dashboard/layout.d.ts +19 -0
  46. package/dist/dashboard/layout.js +48 -0
  47. package/dist/dashboard/widget.d.ts +42 -0
  48. package/dist/dashboard/widget.js +165 -0
  49. package/dist/date/calendar.d.ts +34 -0
  50. package/dist/date/calendar.js +137 -0
  51. package/dist/date/date-field.d.ts +18 -0
  52. package/dist/date/date-field.js +81 -0
  53. package/dist/date/dates.d.ts +22 -0
  54. package/dist/date/dates.js +80 -0
  55. package/dist/dialog/dialog.d.ts +23 -0
  56. package/dist/dialog/dialog.js +70 -0
  57. package/dist/field/bound-field.d.ts +55 -0
  58. package/dist/field/bound-field.js +139 -0
  59. package/dist/field/check.d.ts +11 -0
  60. package/dist/field/check.js +26 -0
  61. package/dist/field/composite-field.d.ts +35 -0
  62. package/dist/field/composite-field.js +88 -0
  63. package/dist/field/field.d.ts +29 -0
  64. package/dist/field/field.js +75 -0
  65. package/dist/field/input.d.ts +15 -0
  66. package/dist/field/input.js +18 -0
  67. package/dist/field/number-field.d.ts +33 -0
  68. package/dist/field/number-field.js +179 -0
  69. package/dist/field/numeric.d.ts +4 -0
  70. package/dist/field/numeric.js +57 -0
  71. package/dist/field/values.d.ts +14 -0
  72. package/dist/field/values.js +60 -0
  73. package/dist/filter/filter-builder.d.ts +18 -0
  74. package/dist/filter/filter-builder.js +305 -0
  75. package/dist/form/controls.d.ts +20 -0
  76. package/dist/form/controls.js +34 -0
  77. package/dist/form/form.d.ts +46 -0
  78. package/dist/form/form.js +85 -0
  79. package/dist/format.d.ts +10 -0
  80. package/dist/format.js +57 -0
  81. package/dist/grid/filters.d.ts +16 -0
  82. package/dist/grid/filters.js +66 -0
  83. package/dist/grid/grid.d.ts +209 -0
  84. package/dist/grid/grid.js +2584 -0
  85. package/dist/grid/rows.d.ts +32 -0
  86. package/dist/grid/rows.js +89 -0
  87. package/dist/grid/view.d.ts +69 -0
  88. package/dist/grid/view.js +172 -0
  89. package/dist/index.d.ts +63 -0
  90. package/dist/index.js +61 -0
  91. package/dist/layout/stack.d.ts +112 -0
  92. package/dist/layout/stack.js +21 -0
  93. package/dist/list-edit/list-edit.d.ts +42 -0
  94. package/dist/list-edit/list-edit.js +220 -0
  95. package/dist/listbox/listbox.d.ts +45 -0
  96. package/dist/listbox/listbox.js +187 -0
  97. package/dist/menu/menu.d.ts +55 -0
  98. package/dist/menu/menu.js +286 -0
  99. package/dist/motion.d.ts +8 -0
  100. package/dist/motion.js +23 -0
  101. package/dist/overlay/anchor.d.ts +18 -0
  102. package/dist/overlay/anchor.js +41 -0
  103. package/dist/picker/options.d.ts +59 -0
  104. package/dist/picker/options.js +97 -0
  105. package/dist/picker/picker.d.ts +46 -0
  106. package/dist/picker/picker.js +492 -0
  107. package/dist/picker/tree-choices.d.ts +38 -0
  108. package/dist/picker/tree-choices.js +72 -0
  109. package/dist/picker/tree-picker.d.ts +29 -0
  110. package/dist/picker/tree-picker.js +282 -0
  111. package/dist/progress/policy.d.ts +5 -0
  112. package/dist/progress/policy.js +28 -0
  113. package/dist/progress/status.d.ts +20 -0
  114. package/dist/progress/status.js +38 -0
  115. package/dist/rule/operators.d.ts +8 -0
  116. package/dist/rule/operators.js +9 -0
  117. package/dist/rule/rule-editor.d.ts +57 -0
  118. package/dist/rule/rule-editor.js +290 -0
  119. package/dist/section/accordion.d.ts +24 -0
  120. package/dist/section/accordion.js +90 -0
  121. package/dist/section/section.d.ts +30 -0
  122. package/dist/section/section.js +99 -0
  123. package/dist/select/choice-element.d.ts +20 -0
  124. package/dist/select/choice-element.js +55 -0
  125. package/dist/select/choice.d.ts +34 -0
  126. package/dist/select/choice.js +122 -0
  127. package/dist/select/many.d.ts +13 -0
  128. package/dist/select/many.js +20 -0
  129. package/dist/select/select.d.ts +16 -0
  130. package/dist/select/select.js +40 -0
  131. package/dist/sidebar/sidebar.d.ts +47 -0
  132. package/dist/sidebar/sidebar.js +105 -0
  133. package/dist/slicer/slicer.d.ts +26 -0
  134. package/dist/slicer/slicer.js +140 -0
  135. package/dist/split/split.d.ts +41 -0
  136. package/dist/split/split.js +237 -0
  137. package/dist/styles/contract.d.ts +36 -0
  138. package/dist/styles/contract.js +39 -0
  139. package/dist/switch/switch.d.ts +22 -0
  140. package/dist/switch/switch.js +69 -0
  141. package/dist/tabs/tabs.d.ts +30 -0
  142. package/dist/tabs/tabs.js +202 -0
  143. package/dist/tile-grid/scroll.d.ts +9 -0
  144. package/dist/tile-grid/scroll.js +21 -0
  145. package/dist/tile-grid/tile-grid.d.ts +64 -0
  146. package/dist/tile-grid/tile-grid.js +734 -0
  147. package/dist/toast/toast.d.ts +25 -0
  148. package/dist/toast/toast.js +92 -0
  149. package/dist/tree/navigator.d.ts +41 -0
  150. package/dist/tree/navigator.js +228 -0
  151. package/dist/window/chrome.d.ts +3 -0
  152. package/dist/window/chrome.js +7 -0
  153. package/dist/window/window.d.ts +46 -0
  154. package/dist/window/window.js +347 -0
  155. package/dist/window/workspace.d.ts +29 -0
  156. package/dist/window/workspace.js +137 -0
  157. package/package.json +45 -4
  158. package/styles/aster-ui.css +1145 -0
  159. package/styles/base.css +86 -0
  160. package/styles/contract.ts +41 -0
  161. package/styles/elements.css +1058 -0
  162. package/styles/harbour.css +126 -0
  163. package/styles/lamplight.css +118 -0
@@ -0,0 +1,734 @@
1
+ import { batch, 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 { TileScroll } from "./scroll.js";
6
+ import { Menu } from "../menu/menu.js";
7
+ const CONTROLS = 'button, a[href], input, select, textarea, [tabindex], [contenteditable="true"]';
8
+ const NONE = new Set();
9
+ /** A collection layout grid, with optional uniform-row windowing and paging. */
10
+ export class TileGrid extends AsterElement {
11
+ #collection = this.prop(undefined);
12
+ #selection = this.prop(undefined);
13
+ #selectable = this.prop(true);
14
+ #label = this.prop("");
15
+ #publish = this.prop("");
16
+ #present = this.prop(undefined);
17
+ #tile = this.prop(undefined);
18
+ #actions = this.prop(undefined);
19
+ #empty = this.prop(html `<p>No records.</p>`);
20
+ #layout = this.prop("uniform");
21
+ #active = this.prop(undefined);
22
+ #inside = this.prop(undefined);
23
+ #columns = this.prop(1);
24
+ #virtual = this.prop(false);
25
+ #top = this.prop(0);
26
+ #height = this.prop(1);
27
+ #stride = this.prop(240);
28
+ #gap = this.prop(0);
29
+ #notice = this.prop("");
30
+ #requested = "";
31
+ #loading;
32
+ #query;
33
+ #bound;
34
+ #navigation = 0;
35
+ #failedFocus;
36
+ #pendingFocus = this.prop(undefined);
37
+ #retrying = false;
38
+ #id = uid("aster-tile-grid");
39
+ #tabStops = new WeakMap();
40
+ #anchor;
41
+ #anchorPosition;
42
+ #anchorQuery;
43
+ #previous = [];
44
+ #focusBefore = null;
45
+ #resize;
46
+ #mutations;
47
+ #observed;
48
+ #menu;
49
+ #search = "";
50
+ #searchedAt = 0;
51
+ get collection() { return this.#collection.value ?? this.#selection.value?.collection; }
52
+ set collection(v) { this.#collection.value = v; }
53
+ get selection() { return this.#selection.value; }
54
+ /** Allows this view to change selection; borrowed selection state remains visible. */
55
+ get selectable() { return this.#selectable.value; }
56
+ set selectable(value) {
57
+ const restore = !value && document.activeElement instanceof Element && this.contains(document.activeElement) && !!document.activeElement.closest("[data-selection-action]");
58
+ this.#selectable.value = value;
59
+ if (restore) {
60
+ this.#inside.value = undefined;
61
+ queueMicrotask(() => this.focus({ preventScroll: true }));
62
+ }
63
+ }
64
+ set selection(v) {
65
+ this.#selection.value = v;
66
+ this.#notice.value = "";
67
+ this.#anchor = undefined;
68
+ this.#anchorPosition = undefined;
69
+ }
70
+ get label() { return this.#label.value; }
71
+ set label(v) { this.#label.value = v; }
72
+ get publish() { return this.#publish.value; }
73
+ set publish(v) { this.#publish.value = v; }
74
+ get presentTile() { return this.#present.value; }
75
+ set presentTile(v) { this.#present.value = v; }
76
+ get renderTile() { return this.#tile.value; }
77
+ set renderTile(v) { this.#tile.value = v; }
78
+ get actions() { return this.#actions.value; }
79
+ set actions(v) { this.#actions.value = v; }
80
+ get empty() { return this.#empty.value; }
81
+ set empty(v) { this.#empty.value = v; }
82
+ get layout() { return this.#layout.value; }
83
+ set layout(v) { this.#layout.value = v; }
84
+ /** Window uniform rows and load their pages. Content layout remains owner-loaded. */
85
+ get virtualized() { return this.#virtual.value; }
86
+ set virtualized(v) { this.#virtual.value = v; }
87
+ get activeKey() { return this.#active.value; }
88
+ connectedCallback() {
89
+ super.connectedCallback();
90
+ this.onDisconnect(effect(() => {
91
+ const key = this.#publish.value;
92
+ const selection = this.#selection.value;
93
+ const scope = scopeOf(this);
94
+ return key && selection && scope ? scope.publish(key, selectionPublication(selection)) : undefined;
95
+ }));
96
+ }
97
+ disconnectedCallback() {
98
+ this.#navigation++;
99
+ this.#requested = "";
100
+ this.#loading = undefined;
101
+ this.#resize?.disconnect();
102
+ this.#mutations?.disconnect();
103
+ this.#observed = undefined;
104
+ this.#menu?.remove();
105
+ this.#menu = undefined;
106
+ super.disconnectedCallback();
107
+ }
108
+ focus(options) {
109
+ (this.tileFor(this.#active.peek()) ?? this.#grid())?.focus(options);
110
+ }
111
+ tileFor(key) {
112
+ const id = key === undefined ? undefined : this.#tileId(key);
113
+ return id ? this.querySelector(`#${id}`) ?? undefined : undefined;
114
+ }
115
+ /** Reveals a loaded key, even when its tile is outside the current window. */
116
+ focusTile(key) {
117
+ const index = this.collection?.keys.peek().indexOf(key) ?? -1;
118
+ if (index < 0)
119
+ return;
120
+ this.#navigation++;
121
+ this.#pendingFocus.value = undefined;
122
+ this.#failedFocus = undefined;
123
+ this.#active.value = key;
124
+ this.#inside.value = undefined;
125
+ if (this.#windowed())
126
+ this.#reveal(index);
127
+ queueMicrotask(() => {
128
+ this.tileFor(key)?.focus({ preventScroll: true });
129
+ (this.#windowed() ? this.#grid() : this.tileFor(key))?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
130
+ });
131
+ }
132
+ /** Loads and focuses a logical position. Returns false on failure or stale navigation. */
133
+ async focusIndex(index) {
134
+ const collection = this.collection;
135
+ if (!collection || !Number.isSafeInteger(index) || index < 0)
136
+ return false;
137
+ const generation = ++this.#navigation;
138
+ const query = collection.query;
139
+ this.#failedFocus = undefined;
140
+ this.#notice.value = "";
141
+ this.#pendingFocus.value = index;
142
+ try {
143
+ await collection.view(index, index + 1, [index, collection.keys.peek().indexOf(this.#active.peek() ?? "")]);
144
+ if (generation !== this.#navigation || this.collection !== collection || collection.query !== query || !this.isConnected)
145
+ return false;
146
+ const key = collection.keys.peek()[index];
147
+ if (key === undefined)
148
+ return false;
149
+ this.focusTile(key);
150
+ return true;
151
+ }
152
+ catch {
153
+ if (generation === this.#navigation && collection.query === query) {
154
+ this.#failedFocus = index;
155
+ this.#notice.value = "Could not load that tile. Retry loading, or move to another tile.";
156
+ }
157
+ return false;
158
+ }
159
+ finally {
160
+ if (generation === this.#navigation)
161
+ this.#pendingFocus.value = undefined;
162
+ }
163
+ }
164
+ #windowed() { return this.#virtual.value && this.#layout.value === "uniform"; }
165
+ #scroll() {
166
+ const count = this.collection?.count.value ?? this.collection?.pageSize ?? 0;
167
+ return new TileScroll(Math.ceil(count / this.#columns.value), this.#stride.value, this.#height.value, this.#gap.value);
168
+ }
169
+ #reveal(index) {
170
+ const grid = this.#grid();
171
+ if (!grid)
172
+ return;
173
+ const top = Math.floor(index / this.#columns.peek()) * this.#stride.peek();
174
+ const bottom = top + this.#stride.peek();
175
+ const model = this.#scroll();
176
+ const current = model.logical(grid.scrollTop);
177
+ if (top < current)
178
+ grid.scrollTop = model.physical(top);
179
+ else if (bottom > current + this.#height.peek())
180
+ grid.scrollTop = model.physical(bottom - this.#height.peek());
181
+ this.#top.value = grid.scrollTop;
182
+ }
183
+ render() {
184
+ this.#focusBefore = this.contains(document.activeElement) ? document.activeElement : null;
185
+ const collection = this.collection;
186
+ if (!collection)
187
+ return nothing;
188
+ const slots = collection.keys.value;
189
+ const virtual = this.#windowed();
190
+ const cols = this.#columns.value;
191
+ const count = collection.count.value ?? (virtual ? collection.pageSize : slots.length);
192
+ const rowCount = Math.ceil(count / cols);
193
+ const model = this.#scroll();
194
+ const logicalTop = model.logical(this.#top.value);
195
+ const start = virtual ? Math.max(0, Math.floor(logicalTop / this.#stride.value) - 2) : 0;
196
+ const end = virtual ? Math.min(rowCount, Math.ceil((logicalTop + this.#height.value) / this.#stride.value) + 2) : rowCount;
197
+ const chosen = this.#selection.value?.keys.value ?? [];
198
+ const old = this.#active.value;
199
+ const keys = virtual ? slots.slice(start * cols, end * cols).filter((k) => k !== undefined) : slots.filter((k) => k !== undefined);
200
+ const active = old !== undefined && slots.includes(old) ? old
201
+ : keys[Math.max(0, Math.min(keys.length - 1, this.#previous.indexOf(old ?? "")))];
202
+ this.#previous = keys;
203
+ const indices = virtual ? Array.from({ length: Math.max(0, end - start) }, (_, i) => start + i)
204
+ : [...new Set(slots.flatMap((key, i) => key === undefined ? [] : [Math.floor(i / cols)]))];
205
+ // The navigation row stays mounted, preserving the grid's single tab entry.
206
+ const pinned = active !== undefined ? Math.floor(slots.indexOf(active) / cols) : -1;
207
+ if (virtual && pinned >= 0 && !indices.includes(pinned))
208
+ indices.push(pinned);
209
+ indices.sort((a, b) => a - b);
210
+ const tiles = [];
211
+ const rows = indices.map((index) => {
212
+ const cells = Array.from({ length: Math.min(cols, count - index * cols) }, (_, col) => {
213
+ const position = index * cols + col;
214
+ const key = slots[position];
215
+ const tile = key === undefined ? undefined : this.#tileAt(position, active, chosen);
216
+ if (!tile)
217
+ return { position, tile: undefined };
218
+ tiles.push(tile);
219
+ return { position, tile };
220
+ });
221
+ return { index, cells };
222
+ });
223
+ const status = collection.status.value;
224
+ const exact = collection.counted.value && !collection.estimated.value;
225
+ return html `<div class="aster-tile-grid" role="grid" aria-label=${this.#label.value}
226
+ aria-describedby=${`${this.#id}-help`} aria-multiselectable=${this.#selectable.value && this.#selection.value?.multiple ? "true" : nothing}
227
+ aria-rowcount=${exact ? Math.max(1, rowCount) : -1} aria-colcount=${cols}
228
+ tabindex=${tiles.length ? -1 : 0} data-layout=${this.#layout.value} data-virtual=${String(virtual)}
229
+ aria-busy=${status === "loading" ? "true" : "false"}
230
+ @scroll=${(e) => { if (virtual)
231
+ this.#top.value = e.currentTarget.scrollTop; }}
232
+ @keydown=${(e) => this.#key(e, tiles, cols)}
233
+ @click=${(e) => this.#click(e)}
234
+ @dblclick=${(e) => this.#doubleClick(e)}
235
+ @contextmenu=${(e) => this.#context(e, tiles)}
236
+ @focusin=${(e) => this.#focusIn(e)}
237
+ @focusout=${() => queueMicrotask(() => {
238
+ if (!this.contains(document.activeElement)) {
239
+ this.#inside.value = undefined;
240
+ this.#navigation++;
241
+ }
242
+ })}>
243
+ <div class="aster-tile-canvas" style=${virtual ? `height: ${model.extent}px` : nothing}>
244
+ ${each(rows, (r) => String(r.index), (r) => html `<div class="aster-tile-row" role="row" aria-rowindex=${r.index + 1}
245
+ data-pinned=${String(virtual && (r.index < start || r.index >= end))}
246
+ style=${virtual ? `top: ${(r.index < start || r.index >= end) ? Math.max(0, Math.min(model.extent - this.#stride.value + this.#gap.value, model.rowTop(r.index * this.#stride.value, this.#top.value))) : model.rowTop(r.index * this.#stride.value, this.#top.value)}px` : nothing}>
247
+ ${each(r.cells, (c) => c.tile?.state.key ?? `placeholder-${c.position}`, (c) => c.tile ? this.#renderTile(c.tile, c.position % cols) : virtual ? html `
248
+ <div class="aster-tile aster-tile-placeholder" role="gridcell" aria-disabled="true" aria-colindex=${c.position % cols + 1}
249
+ aria-label=${`Loading record ${c.position + 1}`}><span>Loading…</span></div>` : nothing)}
250
+ </div>`)}
251
+ </div>
252
+ ${count === 0 || (!virtual && tiles.length === 0) ? html `<div class="aster-tile-empty" role="row"><div role="gridcell">
253
+ ${status === "loading" ? html `<p>Loading…</p>` : collection.count.value === 0 ? this.#empty.value : html `<p>No records loaded.</p>`}
254
+ </div></div>` : nothing}
255
+ </div>
256
+ <p class="aster-sr-only" id=${`${this.#id}-help`}>Arrow keys move between tiles. ${this.#selectable.value ? "Space selects." : "Selection is controlled by another view."} Enter opens. F2 enters actions; Escape returns to the tile.
257
+ ${this.#selectable.value && this.#selection.value?.ordered ? "Alt+Up and Alt+Down change selection order." : ""}</p>
258
+ <div class="aster-tile-status" role="status" aria-live="polite" aria-atomic="true">
259
+ ${status === "error" ? html `<span>Could not load records.</span>` : collection.count.value === undefined ? "Loading…"
260
+ : exact ? `${count} records` : "More records may be available."}
261
+ ${this.#notice.value}
262
+ </div>
263
+ ${status === "error" && virtual ? html `<button type="button" class="aster-button" @click=${() => this.#retry()}>Retry loading tiles</button>` : nothing}
264
+ `;
265
+ }
266
+ #tileAt(index, active, chosen) {
267
+ const collection = this.collection;
268
+ const key = collection.keys.value[index];
269
+ const stored = collection.recordAt(index);
270
+ if (key === undefined || !stored)
271
+ return undefined;
272
+ const state = { key, index, selected: chosen.includes(key), focused: key === active,
273
+ hidden: stored.hidden ?? NONE, selectionOrder: this.#selection.value?.ordered && chosen.includes(key) ? chosen.indexOf(key) + 1 : undefined };
274
+ const field = collection.type.titleField;
275
+ const title = field && state.hidden.has(field) ? "Hidden" : String((field ? stored.entity[field] : undefined) ?? key);
276
+ return { entity: stored.entity, state, content: this.#present.value?.(stored.entity, state) ?? { title } };
277
+ }
278
+ #renderTile(t, col) {
279
+ const { content, state } = t;
280
+ const id = this.#tileId(state.key);
281
+ const menu = this.#actions.value?.(t.entity, state) ?? [];
282
+ const custom = this.#tile.value;
283
+ return html `<div class="aster-tile" id=${id} role="gridcell" data-key=${state.key}
284
+ aria-label=${content.title} aria-describedby=${content.description || state.selectionOrder ? `${id}-description` : nothing}
285
+ aria-colindex=${col + 1} aria-selected=${this.#selection.value ? String(state.selected) : nothing}
286
+ data-interaction=${String(this.#inside.value === state.key)}
287
+ tabindex=${state.focused && this.#inside.value !== state.key ? 0 : -1}>
288
+ <div class="aster-tile-content" tabindex=${this.#windowed() ? -1 : nothing}
289
+ aria-label=${this.#windowed() ? `Content for ${content.title}` : nothing}>${custom ? custom(t.entity, state) : html `
290
+ <div class="aster-tile-media">
291
+ ${content.icon ?? html `<svg viewBox="0 0 48 48" aria-hidden="true"><rect x="9" y="6" width="30" height="36" rx="4"/><path d="M16 17h16M16 24h16M16 31h10"/></svg>`}
292
+ ${content.image ? html `<img src=${content.image} alt=${content.imageAlt ?? ""} loading="lazy" width="320" height="240"
293
+ @error=${(e) => (e.target.hidden = true)}
294
+ @load=${(e) => (e.target.hidden = false)}>` : nothing}
295
+ </div>
296
+ <strong class="aster-tile-title" title=${content.title}>${content.title}</strong>
297
+ ${content.description ? html `<small class="aster-tile-description" aria-hidden="true">${content.description}</small>` : nothing}
298
+ `}</div>
299
+ ${content.description || state.selectionOrder ? html `<span class="aster-sr-only" id=${`${id}-description`}>${content.description ?? ""}
300
+ ${state.selectionOrder ? `Selection ${state.selectionOrder} of ${this.#selection.value.count.value}.` : ""}</span>` : nothing}
301
+ ${state.selectionOrder ? html `<span class="aster-tile-order" aria-hidden="true">${state.selectionOrder}</span>` : nothing}
302
+ <div class="aster-tile-actions">
303
+ ${this.#windowed() ? html `<button type="button" class="aster-button" tabindex="-1"
304
+ aria-label=${`View content of ${content.title}`} @click=${() => this.#enter(state.key)}>Content</button>` : nothing}
305
+ ${this.#selectable.value && this.#selection.value?.multiple ? html `<button type="button" class="aster-button" tabindex="-1" data-selection-action
306
+ aria-label=${`${state.selected ? "Deselect" : "Select"} ${content.title}`} aria-pressed=${String(state.selected)}
307
+ @click=${() => this.#toggle(state.key)}>${state.selected ? "Deselect" : "Select"}</button>` : nothing}
308
+ ${this.#selectable.value && state.selectionOrder ? html `
309
+ <button type="button" class="aster-button" tabindex="-1" data-selection-action aria-label=${`Move ${content.title} earlier in selection`}
310
+ ?disabled=${state.selectionOrder === 1} @click=${() => this.#move(state.key, -1)}>↑</button>
311
+ <button type="button" class="aster-button" tabindex="-1" data-selection-action aria-label=${`Move ${content.title} later in selection`}
312
+ ?disabled=${state.selectionOrder === this.#selection.value.count.value} @click=${() => this.#move(state.key, 1)}>↓</button>` : nothing}
313
+ <button type="button" class="aster-button" tabindex="-1" aria-label=${`Open ${content.title}`}
314
+ @click=${() => this.emit("tile-open", { key: state.key })}>Open</button>
315
+ ${menu.length ? html `<button type="button" class="aster-button" tabindex="-1" aria-haspopup="menu" aria-label=${`Actions for ${content.title}`}
316
+ @click=${(e) => this.#openMenu(t, menu, e.currentTarget)}>…</button>` : nothing}
317
+ </div>
318
+ </div>`;
319
+ }
320
+ updated() {
321
+ const grid = this.#grid();
322
+ const focusBefore = this.#focusBefore;
323
+ if (!grid)
324
+ return;
325
+ const slots = this.collection?.keys.peek() ?? [];
326
+ const inside = this.#inside.peek();
327
+ if (inside !== undefined && !slots.includes(inside))
328
+ this.#inside.value = undefined;
329
+ const active = grid.querySelector('.aster-tile[tabindex="0"]')?.dataset.key;
330
+ if (active !== undefined && active !== this.#active.peek() && !this.#inside.peek())
331
+ this.#active.value = active;
332
+ if (!slots.some((key) => key !== undefined))
333
+ this.#active.value = undefined;
334
+ if (focusBefore && !focusBefore.isConnected && (document.activeElement === document.body || !document.activeElement)) {
335
+ if (this.#retrying)
336
+ grid.focus({ preventScroll: true });
337
+ else
338
+ this.focus({ preventScroll: true });
339
+ }
340
+ if (this.#observed !== grid) {
341
+ this.#resize?.disconnect();
342
+ this.#mutations?.disconnect();
343
+ this.#observed = grid;
344
+ this.#resize = new ResizeObserver(() => this.#measure());
345
+ this.#mutations = new MutationObserver(() => this.#syncControls());
346
+ this.#mutations.observe(grid, { childList: true, subtree: true });
347
+ }
348
+ this.#resize?.disconnect();
349
+ this.#resize?.observe(grid);
350
+ // A track can resize while the outer grid keeps exactly the same dimensions.
351
+ const sample = grid.querySelector(".aster-tile");
352
+ if (sample)
353
+ this.#resize?.observe(sample);
354
+ this.#measure();
355
+ this.#loadWindow();
356
+ this.#syncControls();
357
+ }
358
+ #measure() {
359
+ const grid = this.#grid();
360
+ if (!grid)
361
+ return;
362
+ const tracks = getComputedStyle(this.#windowed() ? grid.querySelector(".aster-tile-canvas") : grid).gridTemplateColumns;
363
+ const cols = tracks === "none" ? 1 : tracks.split(" ").filter(Boolean).length;
364
+ const row = grid.querySelector(".aster-tile-row");
365
+ const gap = parseFloat(getComputedStyle(grid).rowGap) || 0;
366
+ const stride = row ? row.getBoundingClientRect().height + gap : this.#stride.peek();
367
+ const oldCols = this.#columns.peek();
368
+ const oldStride = this.#stride.peek();
369
+ // Reproject the first visible position using CSS's newly measured tracks.
370
+ const oldTop = this.#scroll().logical(grid.scrollTop);
371
+ const anchor = Math.floor(oldTop / oldStride) * oldCols;
372
+ const fraction = oldTop % oldStride / oldStride;
373
+ const style = getComputedStyle(grid);
374
+ const height = grid.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
375
+ batch(() => {
376
+ this.#columns.value = cols;
377
+ if (stride > 0)
378
+ this.#stride.value = stride;
379
+ this.#height.value = height;
380
+ this.#gap.value = gap;
381
+ if (this.#windowed() && (cols !== oldCols || Math.abs(stride - oldStride) > .5)) {
382
+ grid.scrollTop = this.#scroll().physical((Math.floor(anchor / cols) + fraction) * stride);
383
+ }
384
+ this.#top.value = grid.scrollTop;
385
+ });
386
+ }
387
+ #loadWindow() {
388
+ const c = this.collection;
389
+ const grid = this.#grid();
390
+ if (!c || !grid || !this.#windowed()) {
391
+ this.#requested = "";
392
+ return;
393
+ }
394
+ if (c !== this.#bound || c.query !== this.#query) {
395
+ this.#bound = c;
396
+ this.#query = c.query;
397
+ this.#navigation++;
398
+ this.#failedFocus = undefined;
399
+ this.#notice.value = "";
400
+ this.#anchor = undefined;
401
+ this.#anchorPosition = undefined;
402
+ this.#requested = "";
403
+ this.#loading = undefined;
404
+ grid.scrollTop = 0;
405
+ this.#top.value = 0;
406
+ }
407
+ const cols = this.#columns.peek();
408
+ const stride = this.#stride.peek();
409
+ const top = this.#scroll().logical(grid.scrollTop);
410
+ const start = Math.max(0, Math.floor(top / stride) - 2) * cols;
411
+ const end = Math.min(c.count.peek() ?? c.pageSize, (Math.ceil((top + this.#height.peek()) / stride) + 2) * cols);
412
+ const pin = c.keys.peek().indexOf(this.#active.peek() ?? "");
413
+ const pending = this.#pendingFocus.peek();
414
+ const retain = [...(pin < 0 ? [] : [pin]), ...(pending === undefined ? [] : [pending])];
415
+ const request = `${start}:${end}:${pin}:${pending}`;
416
+ if (request === this.#requested || this.#loading)
417
+ return;
418
+ this.#requested = request;
419
+ const loading = {};
420
+ this.#loading = loading;
421
+ void c.view(start, end, retain).catch(() => { }).finally(() => {
422
+ if (this.#loading !== loading || !this.isConnected)
423
+ return;
424
+ this.#loading = undefined;
425
+ this.#loadWindow();
426
+ });
427
+ }
428
+ async #retry() {
429
+ this.#requested = "";
430
+ const index = this.#failedFocus;
431
+ if (index !== undefined) {
432
+ await this.focusIndex(index);
433
+ return;
434
+ }
435
+ const collection = this.collection;
436
+ const grid = this.#grid();
437
+ if (!collection || !grid)
438
+ return;
439
+ const query = collection.query;
440
+ const request = ++this.#navigation;
441
+ const first = Math.floor(this.#scroll().logical(grid.scrollTop) / this.#stride.peek()) * this.#columns.peek();
442
+ this.#retrying = true;
443
+ this.#loadWindow();
444
+ try {
445
+ await collection.ensure(first, first + this.#columns.peek());
446
+ if (!this.isConnected || request !== this.#navigation || collection.query !== query)
447
+ return;
448
+ const key = collection.keys.peek()[first];
449
+ if (key === undefined)
450
+ return;
451
+ this.#active.value = key;
452
+ this.#inside.value = undefined;
453
+ queueMicrotask(() => this.tileFor(key)?.focus({ preventScroll: true }));
454
+ }
455
+ catch {
456
+ // The collection supplies the reachable retry and failure state.
457
+ }
458
+ finally {
459
+ this.#retrying = false;
460
+ }
461
+ }
462
+ #syncControls() {
463
+ for (const tile of this.querySelectorAll(".aster-tile")) {
464
+ const inside = tile.dataset.key === this.#inside.peek();
465
+ const content = tile.querySelector(".aster-tile-content");
466
+ if (content && this.#windowed()) {
467
+ const overflow = content.scrollHeight > content.clientHeight + 1;
468
+ this.#tabStops.set(content, overflow ? "0" : "-1");
469
+ }
470
+ for (const control of tile.querySelectorAll(CONTROLS)) {
471
+ if (!this.#tabStops.has(control))
472
+ this.#tabStops.set(control, control.getAttribute("tabindex"));
473
+ if (inside) {
474
+ const original = this.#tabStops.get(control);
475
+ // The shell's action buttons participate when interaction mode is entered.
476
+ if (original === null || control.closest(".aster-tile-actions"))
477
+ control.removeAttribute("tabindex");
478
+ else
479
+ control.setAttribute("tabindex", original);
480
+ }
481
+ else
482
+ control.setAttribute("tabindex", "-1");
483
+ }
484
+ }
485
+ }
486
+ #focusIn(e) {
487
+ const tile = this.#tileOf(e);
488
+ if (!tile)
489
+ return;
490
+ if (tile.dataset.key !== this.#active.peek()) {
491
+ this.#navigation++;
492
+ this.#failedFocus = undefined;
493
+ }
494
+ this.#active.value = tile.dataset.key;
495
+ if (e.target !== tile)
496
+ this.#inside.value = tile.dataset.key;
497
+ else
498
+ this.#inside.value = undefined;
499
+ if (this.#windowed() && tile.parentElement?.dataset.pinned === "true") {
500
+ const index = this.collection.keys.peek().indexOf(tile.dataset.key);
501
+ if (index >= 0)
502
+ this.#reveal(index);
503
+ }
504
+ }
505
+ #keys() { return (this.collection?.keys.peek() ?? []).filter((key) => key !== undefined); }
506
+ #grid() { return this.querySelector(".aster-tile-grid") ?? undefined; }
507
+ #tileId(key) {
508
+ return `${this.#id}-${Array.from(key, (c) => c.codePointAt(0).toString(16)).join("-")}`;
509
+ }
510
+ #tileOf(e) {
511
+ const tile = e.target.closest(".aster-tile");
512
+ return tile?.dataset.key !== undefined && tile.closest("aster-tile-grid") === this ? tile : undefined;
513
+ }
514
+ #interactive(e) {
515
+ const tile = this.#tileOf(e);
516
+ const control = e.target.closest(CONTROLS);
517
+ if (control?.classList.contains("aster-tile-content"))
518
+ return e.type === "keydown" && e.target === control;
519
+ return !!tile && !!control && control !== tile;
520
+ }
521
+ #choose(key, extend, toggle) {
522
+ if (!this.#selectable.peek())
523
+ return;
524
+ const selection = this.#selection.peek();
525
+ if (!selection)
526
+ return;
527
+ if (extend && selection.multiple) {
528
+ const slots = this.collection.keys.peek();
529
+ const anchor = this.#anchor ?? this.#active.peek() ?? key;
530
+ const loaded = slots.indexOf(anchor);
531
+ const a = loaded >= 0 ? loaded : this.#anchorQuery === this.collection.query ? this.#anchorPosition ?? 0 : 0;
532
+ const b = slots.indexOf(key);
533
+ const range = slots.slice(Math.min(a, b), Math.max(a, b) + 1).filter((k) => k !== undefined);
534
+ this.#selectionResult(selection.selectAll(range));
535
+ }
536
+ else {
537
+ if (toggle || selection.ordered)
538
+ this.#toggle(key);
539
+ else
540
+ selection.select(key);
541
+ this.#anchor = key;
542
+ this.#anchorPosition = this.collection.keys.peek().indexOf(key);
543
+ this.#anchorQuery = this.collection.query;
544
+ }
545
+ }
546
+ #selectionResult(accepted) {
547
+ this.#notice.value = accepted ? "" : `Select at most ${this.#selection.peek()?.maxOrdered} ordered items. Deselect an item to add another.`;
548
+ }
549
+ #toggle(key) {
550
+ if (!this.#selectable.peek())
551
+ return;
552
+ const selection = this.#selection.peek();
553
+ if (selection)
554
+ this.#selectionResult(selection.toggle(key));
555
+ }
556
+ #move(key, delta) {
557
+ if (!this.#selectable.peek())
558
+ return;
559
+ const selection = this.#selection.peek();
560
+ if (!selection)
561
+ return;
562
+ const index = selection.keys.peek().indexOf(key) + delta;
563
+ if (selection.move(key, index))
564
+ this.#notice.value = `Moved to selection position ${index + 1}.`;
565
+ }
566
+ #enter(key) {
567
+ this.#inside.value = key;
568
+ queueMicrotask(() => {
569
+ this.#syncControls();
570
+ [...(this.tileFor(key)?.querySelectorAll(CONTROLS) ?? [])]
571
+ .find((el) => el.tabIndex >= 0 && !el.matches(":disabled") && el.getClientRects().length)?.focus();
572
+ });
573
+ }
574
+ #click(e) {
575
+ if (this.#interactive(e))
576
+ return;
577
+ const tile = this.#tileOf(e);
578
+ const text = getSelection();
579
+ if (!tile || (text && !text.isCollapsed && tile.contains(text.anchorNode)))
580
+ return;
581
+ const key = tile.dataset.key;
582
+ this.#choose(key, e.shiftKey, e.ctrlKey || e.metaKey);
583
+ this.focusTile(key);
584
+ }
585
+ #doubleClick(e) {
586
+ if (this.#interactive(e))
587
+ return;
588
+ const tile = this.#tileOf(e);
589
+ if (tile)
590
+ this.emit("tile-open", { key: tile.dataset.key });
591
+ }
592
+ #context(e, tiles) {
593
+ if (this.#interactive(e))
594
+ return;
595
+ const tile = this.#tileOf(e);
596
+ const t = tiles.find((t) => t.state.key === tile?.dataset.key);
597
+ const items = t ? this.#actions.peek()?.(t.entity, t.state) : undefined;
598
+ if (!t || !items?.length || !tile)
599
+ return;
600
+ e.preventDefault();
601
+ this.#active.value = t.state.key;
602
+ tile.focus({ preventScroll: true });
603
+ this.#openMenu(t, items, tile, { x: e.clientX, y: e.clientY });
604
+ }
605
+ #openMenu(t, items, opener, point) {
606
+ this.#menu ??= new Menu();
607
+ if (!this.#menu.isConnected)
608
+ this.append(this.#menu);
609
+ const rect = opener.getBoundingClientRect();
610
+ this.#menu.openAt(point ?? { x: rect.left, y: rect.bottom }, items, `Actions for ${t.content.title}`, opener);
611
+ }
612
+ #key(e, tiles, cols) {
613
+ const key = this.#tileOf(e)?.dataset.key ?? this.#active.peek();
614
+ const tileIndex = tiles.findIndex((t) => t.state.key === key);
615
+ const index = this.#windowed() ? tiles[tileIndex]?.state.index ?? -1 : tileIndex;
616
+ if (index < 0)
617
+ return;
618
+ if (e.key === "Escape" && this.#inside.peek()) {
619
+ e.preventDefault();
620
+ e.stopPropagation();
621
+ this.focusTile(key);
622
+ return;
623
+ }
624
+ if (this.#interactive(e)) {
625
+ if (e.key === "Tab") {
626
+ const controls = this.tileFor(key).querySelectorAll(CONTROLS);
627
+ const enabled = [...controls].filter((el) => el.tabIndex >= 0 && !el.matches(":disabled") && el.getClientRects().length);
628
+ if (e.shiftKey && e.target === enabled[0]) {
629
+ e.preventDefault();
630
+ this.focusTile(key);
631
+ }
632
+ else if (!e.shiftKey && e.target === enabled.at(-1)) {
633
+ this.#inside.value = undefined;
634
+ }
635
+ }
636
+ return;
637
+ }
638
+ const t = tiles[tileIndex];
639
+ if (e.altKey && this.#selection.peek()?.ordered && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
640
+ e.preventDefault();
641
+ this.#move(key, e.key === "ArrowUp" ? -1 : 1);
642
+ return;
643
+ }
644
+ const total = this.#windowed() ? this.collection.count.peek() ?? tiles.length : tiles.length;
645
+ const rtl = getComputedStyle(this.#grid()).direction === "rtl";
646
+ const keys = this.#windowed() ? this.#keys() : tiles.map((t) => t.state.key);
647
+ let next;
648
+ switch (e.key) {
649
+ case "ArrowRight":
650
+ next = index + (rtl ? -1 : 1);
651
+ break;
652
+ case "ArrowLeft":
653
+ next = index + (rtl ? 1 : -1);
654
+ break;
655
+ case "ArrowDown":
656
+ next = Math.min(total - 1, index + cols);
657
+ break;
658
+ case "ArrowUp":
659
+ next = index - cols;
660
+ break;
661
+ case "PageDown":
662
+ case "PageUp": {
663
+ const height = this.tileFor(key)?.getBoundingClientRect().height ?? 1;
664
+ const page = Math.max(1, Math.floor((this.#windowed() ? this.#grid().clientHeight : window.innerHeight) / height)) * cols;
665
+ next = Math.max(0, Math.min(total - 1, index + (e.key === "PageDown" ? page : -page)));
666
+ break;
667
+ }
668
+ case "Home":
669
+ next = e.ctrlKey ? 0 : Math.floor(index / cols) * cols;
670
+ break;
671
+ case "End":
672
+ next = e.ctrlKey ? total - 1 : Math.min(total - 1, (Math.floor(index / cols) + 1) * cols - 1);
673
+ break;
674
+ case " ":
675
+ this.#choose(key, e.shiftKey, this.#selection.peek()?.multiple ?? false);
676
+ break;
677
+ case "Enter":
678
+ this.emit("tile-open", { key: key });
679
+ break;
680
+ case "F2":
681
+ this.#enter(key);
682
+ break;
683
+ case "ContextMenu":
684
+ case "F10": {
685
+ if (e.key === "F10" && !e.shiftKey)
686
+ return;
687
+ const items = this.#actions.peek()?.(t.entity, t.state) ?? [];
688
+ if (items.length)
689
+ this.#openMenu(t, items, this.tileFor(key));
690
+ break;
691
+ }
692
+ default: {
693
+ if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey)
694
+ return;
695
+ const now = performance.now();
696
+ this.#search = now - this.#searchedAt > 700 ? e.key : this.#search + e.key;
697
+ this.#searchedAt = now;
698
+ const search = this.#search.toLocaleLowerCase();
699
+ const candidates = this.#windowed() ? this.collection.keys.peek().flatMap((key, i) => {
700
+ if (key === undefined)
701
+ return [];
702
+ const tile = this.#tileAt(i, this.#active.peek(), this.#selection.peek()?.keys.peek() ?? []);
703
+ return tile ? [tile] : [];
704
+ }) : tiles;
705
+ let match = candidates.find((tile) => tile.state.index > t.state.index && tile.content.title.toLocaleLowerCase().startsWith(search));
706
+ match ??= candidates.find((tile) => tile.content.title.toLocaleLowerCase().startsWith(search));
707
+ next = match ? this.#windowed() ? match.state.index : tiles.indexOf(match) : -1;
708
+ }
709
+ }
710
+ e.preventDefault();
711
+ if (next === undefined || next < 0 || next >= total)
712
+ return;
713
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
714
+ if (Math.floor(next / cols) !== Math.floor(index / cols))
715
+ return;
716
+ }
717
+ if (this.#windowed()) {
718
+ const anchor = this.#anchor ?? key;
719
+ void this.focusIndex(next).then((focused) => {
720
+ if (!focused || !e.shiftKey)
721
+ return;
722
+ this.#anchor = anchor;
723
+ // Range selection is restricted to keys loaded when navigation completes.
724
+ this.#choose(this.#active.peek(), true, false);
725
+ });
726
+ }
727
+ else {
728
+ if (e.shiftKey)
729
+ this.#choose(keys[next], true, false);
730
+ this.focusTile(keys[next]);
731
+ }
732
+ }
733
+ }
734
+ define("aster-tile-grid", TileGrid);