@mosaicajs/components 0.3.0 → 0.4.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 (52) hide show
  1. package/README.md +2 -0
  2. package/dist/aggregate.d.ts +4 -10
  3. package/dist/aggregate.js +7 -13
  4. package/dist/artifact-card.d.ts +21 -7
  5. package/dist/artifact-card.js +136 -79
  6. package/dist/base.d.ts +44 -5
  7. package/dist/base.js +54 -5
  8. package/dist/cluster-filter.d.ts +29 -25
  9. package/dist/cluster-filter.js +123 -106
  10. package/dist/colour-editor.d.ts +15 -4
  11. package/dist/colour-editor.js +107 -34
  12. package/dist/colour-picker.d.ts +12 -5
  13. package/dist/colour-picker.js +119 -32
  14. package/dist/display.js +77 -9
  15. package/dist/events.d.ts +21 -7
  16. package/dist/explorer.d.ts +116 -37
  17. package/dist/explorer.js +476 -210
  18. package/dist/field-card.d.ts +304 -48
  19. package/dist/field-card.js +1364 -244
  20. package/dist/filter-panel.d.ts +54 -10
  21. package/dist/filter-panel.js +242 -42
  22. package/dist/filter.d.ts +22 -4
  23. package/dist/filter.js +113 -34
  24. package/dist/hierarchy.d.ts +68 -45
  25. package/dist/hierarchy.js +320 -243
  26. package/dist/item-card.d.ts +47 -16
  27. package/dist/item-card.js +241 -108
  28. package/dist/key-picker.d.ts +2 -1
  29. package/dist/key-picker.js +4 -3
  30. package/dist/layer-picker.d.ts +4 -2
  31. package/dist/layer-picker.js +16 -7
  32. package/dist/lineage.d.ts +132 -0
  33. package/dist/lineage.js +307 -0
  34. package/dist/map.d.ts +135 -24
  35. package/dist/map.js +387 -117
  36. package/dist/mosaica-components.js +1837 -833
  37. package/dist/mosaica-components.js.sri +1 -1
  38. package/dist/mosaica-components.json +2 -2
  39. package/dist/parts.d.ts +4 -4
  40. package/dist/parts.js +18 -4
  41. package/dist/paths.d.ts +34 -12
  42. package/dist/paths.js +84 -46
  43. package/dist/selection.d.ts +22 -3
  44. package/dist/selection.js +125 -59
  45. package/dist/states.d.ts +72 -0
  46. package/dist/states.js +135 -26
  47. package/dist/status.d.ts +63 -5
  48. package/dist/status.js +215 -36
  49. package/dist/tokens.d.ts +8 -4
  50. package/dist/tokens.js +144 -14
  51. package/dist/view-picker.js +5 -0
  52. package/package.json +4 -3
@@ -1,19 +1,19 @@
1
1
  import { nothing, type PropertyValues, type TemplateResult } from 'lit';
2
2
  import { type BrowseRow, type Refusal } from '@mosaicajs/client';
3
3
  import { MosaicaElement } from './base.js';
4
- /** One cluster the list offers: its row and, once walked, its path of parents' names, nearest last. */
4
+ /** One cluster the list offers. */
5
5
  type Offer = {
6
6
  row: BrowseRow;
7
- path: string[] | null;
8
- beyond: boolean;
9
7
  };
10
8
  /**
11
9
  * Use `<mosaica-cluster-filter>` when you want to search a layer's clusters without the field card
12
10
  * around it. Set `layer` to a layer `meta.layers` lists.
13
11
  *
14
12
  * With the box empty, the list offers the layer's top-level clusters, largest first. Typing
15
- * searches the cluster names at every level. In the list, the arrow keys move between rows, Enter
16
- * chooses one, and Escape closes the list.
13
+ * searches the cluster names at every level. The arrow keys move between the rows, as do Home and
14
+ * End once one is reached. Enter chooses the row they reached, and Escape closes the list. A line
15
+ * under the box says how many clusters are listed, or why none is, and that they are loading once
16
+ * that has taken a quarter of a second.
17
17
  *
18
18
  * Each row counts the items the filters match in that cluster without applying this layer's own
19
19
  * filter, so a cluster the viewer has filtered out still shows its count. Choosing a row filters
@@ -26,12 +26,15 @@ type Offer = {
26
26
  * row added or removed a `member_of` clause.
27
27
  * @csspart entry - The search box.
28
28
  * @csspart values - The list, while it is open.
29
- * @csspart option - One cluster in the list, with `data-id` and `aria-selected` while its clause is
30
- * on.
31
- * @csspart name - A row's name, with `data-unnamed` if the cluster has none.
32
- * @csspart path - A row's path of parents' names.
29
+ * @csspart option - One cluster in the list, with `data-id`, `aria-selected` while its clause is
30
+ * on, and `data-active` on the one the arrow keys reached.
31
+ * @csspart name - A row's name. Where the cluster has none it is the cluster's key, with
32
+ * `data-key-name`, and where it has no key either it is the word Unnamed, with `data-unnamed`.
33
+ * @csspart path - A row's path of its parents' names, each step naming every served parent of the
34
+ * step below it, by its key where it has no name.
33
35
  * @csspart value-count - A row's exact count.
34
- * @csspart more - The note under the box when nothing matches, or when more match than are listed.
36
+ * @csspart more - The note under the box when there is nothing to list, or when more match than
37
+ * are listed.
35
38
  * @csspart refusal - The words "Clusters unavailable", with `data-code`, if the list was refused.
36
39
  */
37
40
  export declare class MosaicaClusterFilter extends MosaicaElement {
@@ -55,7 +58,7 @@ export declare class MosaicaClusterFilter extends MosaicaElement {
55
58
  accessor search: string;
56
59
  /** Whether the list is open. @internal */
57
60
  accessor listOpen: boolean;
58
- /** The row the arrow keys reached, by `mosaicaId`; `null` is the first that can be chosen. @internal */
61
+ /** The row the arrow keys reached, by `mosaicaId`; `null` before they reach one. @internal */
59
62
  accessor activeId: bigint | null;
60
63
  /** The top-level clusters, once fetched. @internal */
61
64
  accessor tops: Offer[] | null;
@@ -68,11 +71,11 @@ export declare class MosaicaClusterFilter extends MosaicaElement {
68
71
  /** @internal */
69
72
  accessor refusal: Refusal | null;
70
73
  private readonly counts;
74
+ /** Whether the open list's clusters have been awaited long enough to say so. */
75
+ private readonly awaited;
71
76
  private readonly floating;
72
- /** Every artifact's row as the field has met it, for names and parents. */
73
- private readonly rows;
74
- /** Each artifact's served parents as a children-form page gave them. */
75
- private readonly parentsOf;
77
+ /** The names of the offered clusters' parents, walked {@link PATH_DEPTH} steps up. */
78
+ private readonly paths;
76
79
  /** The layer and view the top-level clusters were fetched for. */
77
80
  private fetchedFor;
78
81
  private searchTimer;
@@ -81,21 +84,21 @@ export declare class MosaicaClusterFilter extends MosaicaElement {
81
84
  protected resetServerData(): void;
82
85
  disconnectedCallback(): void;
83
86
  private declared;
84
- /** The levels a request addresses on the layer: each declared level on a levelled one, else one request with none. */
85
- private levels;
87
+ /** The first declared level of a levelled layer, whose clusters are the top level; `undefined` on any other. */
88
+ private firstLevel;
86
89
  /** One browse page of the layer. Counts come from the aggregate, so the page is asked unfiltered. */
87
90
  private browse;
91
+ /** Offer the rows of a page, and walk the parents of each. */
88
92
  private offer;
93
+ /** An offer's path of parents, once walked; empty for a cluster with none. */
94
+ private pathOf;
89
95
  /** Fetch the top-level clusters, once per layer and view. */
90
96
  private fetchTops;
91
- /** Search the layer's names for `q`, at every level of a levelled layer, and walk each match's path. */
92
- private searchFor;
93
97
  /**
94
- * Fill in an offer's path, its first parent's name and so on up to {@link PATH_DEPTH} parents. On
95
- * a layer whose children form needs no level, a missing parent is asked for; on a levelled layer
96
- * only parents already met are named.
98
+ * Search the layer's names for `q`, and walk each match's path. One request with no level
99
+ * searches every level of a levelled layer.
97
100
  */
98
- private walk;
101
+ private searchFor;
99
102
  /** The clauses this field holds: the layer's, in its position. */
100
103
  private held;
101
104
  private toggle;
@@ -109,8 +112,9 @@ export declare class MosaicaClusterFilter extends MosaicaElement {
109
112
  /** The rows and width {@link fitPaths} last measured. */
110
113
  private pathsMeasured;
111
114
  /**
112
- * Name as many of each listed path's nearest parents as fit its line, dropping whole names from
113
- * the left. Measured with the line's own font, after the list is placed.
115
+ * Where a path is kept to one line, name as many of each listed path's nearest parents as fit
116
+ * it, dropping whole names from the left. Measured with the line's own font, after the list is
117
+ * placed. A path that wraps names them all.
114
118
  */
115
119
  private fitPaths;
116
120
  protected updated(changed: PropertyValues<this>): void;
@@ -38,10 +38,12 @@ import { repeat } from 'lit/directives/repeat.js';
38
38
  import { artifactName, withMember, withoutMember } from '@mosaicajs/client';
39
39
  import { refusalOf } from '@mosaicajs/client/internal';
40
40
  import { HeldAggregate, artifactGroupings, countsByKey } from './aggregate.js';
41
- import { MosaicaElement, UNNAMED, emit, idString } from './base.js';
41
+ import { MosaicaElement, called, emit, idString, labelOf } from './base.js';
42
42
  import { FloatingList } from './float.js';
43
43
  import { attachContextRoot, defineOnce } from './define.js';
44
44
  import { icon } from './icons.js';
45
+ import { ClusterPaths } from './paths.js';
46
+ import { NamedQuery } from './states.js';
45
47
  import { chrome, tokens } from './tokens.js';
46
48
  /** How long typing must be quiet before a name search is sent. */
47
49
  const SEARCH_DEBOUNCE_MS = 250;
@@ -51,8 +53,8 @@ const TOP_LEVEL_ROWS = 50;
51
53
  * A path of `n` of its nearest parents, nearest last, led by an ellipsis where parents further up
52
54
  * are left out.
53
55
  */
54
- function pathText(path, beyond, n) {
55
- return `${beyond || n < path.length ? '… › ' : ''}${path.slice(path.length - n).join(' › ')}`;
56
+ function pathText(path, n) {
57
+ return `${path.beyond || n < path.steps.length ? '… › ' : ''}${path.steps.slice(path.steps.length - n).join(' › ')}`;
56
58
  }
57
59
  /** How many parents a match's path is walked up, nearest first. */
58
60
  const PATH_DEPTH = 3;
@@ -61,8 +63,10 @@ const PATH_DEPTH = 3;
61
63
  * around it. Set `layer` to a layer `meta.layers` lists.
62
64
  *
63
65
  * With the box empty, the list offers the layer's top-level clusters, largest first. Typing
64
- * searches the cluster names at every level. In the list, the arrow keys move between rows, Enter
65
- * chooses one, and Escape closes the list.
66
+ * searches the cluster names at every level. The arrow keys move between the rows, as do Home and
67
+ * End once one is reached. Enter chooses the row they reached, and Escape closes the list. A line
68
+ * under the box says how many clusters are listed, or why none is, and that they are loading once
69
+ * that has taken a quarter of a second.
66
70
  *
67
71
  * Each row counts the items the filters match in that cluster without applying this layer's own
68
72
  * filter, so a cluster the viewer has filtered out still shows its count. Choosing a row filters
@@ -75,12 +79,15 @@ const PATH_DEPTH = 3;
75
79
  * row added or removed a `member_of` clause.
76
80
  * @csspart entry - The search box.
77
81
  * @csspart values - The list, while it is open.
78
- * @csspart option - One cluster in the list, with `data-id` and `aria-selected` while its clause is
79
- * on.
80
- * @csspart name - A row's name, with `data-unnamed` if the cluster has none.
81
- * @csspart path - A row's path of parents' names.
82
+ * @csspart option - One cluster in the list, with `data-id`, `aria-selected` while its clause is
83
+ * on, and `data-active` on the one the arrow keys reached.
84
+ * @csspart name - A row's name. Where the cluster has none it is the cluster's key, with
85
+ * `data-key-name`, and where it has no key either it is the word Unnamed, with `data-unnamed`.
86
+ * @csspart path - A row's path of its parents' names, each step naming every served parent of the
87
+ * step below it, by its key where it has no name.
82
88
  * @csspart value-count - A row's exact count.
83
- * @csspart more - The note under the box when nothing matches, or when more match than are listed.
89
+ * @csspart more - The note under the box when there is nothing to list, or when more match than
90
+ * are listed.
84
91
  * @csspart refusal - The words "Clusters unavailable", with `data-code`, if the list was refused.
85
92
  */
86
93
  let MosaicaClusterFilter = (() => {
@@ -186,6 +193,11 @@ let MosaicaClusterFilter = (() => {
186
193
  background: var(--_mosaica-surface-2);
187
194
  font-weight: 600;
188
195
  }
196
+ /* The box keeps focus, so the row the arrow keys reached is ringed as if it had it. */
197
+ [part~='option'][data-active] {
198
+ outline: 2px solid var(--_mosaica-focus);
199
+ outline-offset: -2px;
200
+ }
189
201
  .opt {
190
202
  display: flex;
191
203
  flex-direction: column;
@@ -193,11 +205,12 @@ let MosaicaClusterFilter = (() => {
193
205
  }
194
206
  [part='name'],
195
207
  [part='path'] {
208
+ white-space: var(--_mosaica-long-text, nowrap);
196
209
  overflow: hidden;
197
210
  text-overflow: ellipsis;
198
- white-space: nowrap;
211
+ overflow-wrap: anywhere;
199
212
  }
200
- /* Shortened from the left a whole name at a time, so the nearest parents stay; see pathText. */
213
+ /* On one line, shortened from the left a whole name at a time, so the nearest parents stay; see pathText. */
201
214
  [part='path'] {
202
215
  font-size: 12px;
203
216
  font-weight: 400;
@@ -216,6 +229,14 @@ let MosaicaClusterFilter = (() => {
216
229
  font-size: 12px;
217
230
  color: var(--_mosaica-ink-3);
218
231
  }
232
+ .skel {
233
+ margin-top: 8px;
234
+ }
235
+ @media (forced-colors: active) {
236
+ [part~='option'][data-active] {
237
+ outline-color: Highlight;
238
+ }
239
+ }
219
240
  `
220
241
  ];
221
242
  #layer_accessor_storage = __runInitializers(this, _layer_initializers, '');
@@ -243,7 +264,7 @@ let MosaicaClusterFilter = (() => {
243
264
  get listOpen() { return this.#listOpen_accessor_storage; }
244
265
  set listOpen(value) { this.#listOpen_accessor_storage = value; }
245
266
  #activeId_accessor_storage = (__runInitializers(this, _listOpen_extraInitializers), __runInitializers(this, _activeId_initializers, null));
246
- /** The row the arrow keys reached, by `mosaicaId`; `null` is the first that can be chosen. @internal */
267
+ /** The row the arrow keys reached, by `mosaicaId`; `null` before they reach one. @internal */
247
268
  get activeId() { return this.#activeId_accessor_storage; }
248
269
  set activeId(value) { this.#activeId_accessor_storage = value; }
249
270
  #tops_accessor_storage = (__runInitializers(this, _activeId_extraInitializers), __runInitializers(this, _tops_initializers, null));
@@ -259,15 +280,15 @@ let MosaicaClusterFilter = (() => {
259
280
  get refusal() { return this.#refusal_accessor_storage; }
260
281
  set refusal(value) { this.#refusal_accessor_storage = value; }
261
282
  counts = (__runInitializers(this, _refusal_extraInitializers), new HeldAggregate('clusters'));
283
+ /** Whether the open list's clusters have been awaited long enough to say so. */
284
+ awaited = new NamedQuery(this);
262
285
  floating = new FloatingList(() => {
263
286
  const list = this.renderRoot.querySelector('[part="values"]');
264
287
  const anchor = this.renderRoot.querySelector('.combo');
265
288
  return list && anchor ? { list, anchor } : null;
266
289
  });
267
- /** Every artifact's row as the field has met it, for names and parents. */
268
- rows = new Map();
269
- /** Each artifact's served parents as a children-form page gave them. */
270
- parentsOf = new Map();
290
+ /** The names of the offered clusters' parents, walked {@link PATH_DEPTH} steps up. */
291
+ paths = new ClusterPaths(() => this.requestUpdate());
271
292
  /** The layer and view the top-level clusters were fetched for. */
272
293
  fetchedFor = '';
273
294
  searchTimer = null;
@@ -278,8 +299,7 @@ let MosaicaClusterFilter = (() => {
278
299
  this.tops = null;
279
300
  this.found = null;
280
301
  this.refusal = null;
281
- this.rows.clear();
282
- this.parentsOf.clear();
302
+ this.paths.reset();
283
303
  this.fetchedFor = '';
284
304
  }
285
305
  disconnectedCallback() {
@@ -288,15 +308,16 @@ let MosaicaClusterFilter = (() => {
288
308
  this.searchTimer = null;
289
309
  this.counts.set(null, null);
290
310
  this.floating.stop();
311
+ this.awaited.stop();
291
312
  super.disconnectedCallback();
292
313
  }
293
314
  declared() {
294
315
  return this.resolvedStore?.get('meta')?.layers.find((l) => l.name === this.layer) ?? null;
295
316
  }
296
- /** The levels a request addresses on the layer: each declared level on a levelled one, else one request with none. */
297
- levels(layer) {
317
+ /** The first declared level of a levelled layer, whose clusters are the top level; `undefined` on any other. */
318
+ firstLevel(layer) {
298
319
  const levelled = layer.hierarchy.kind === 'stacked' || layer.hierarchy.kind === 'tiered';
299
- return levelled && layer.levels.length > 0 ? layer.levels.map((l) => l.level) : [undefined];
320
+ return levelled ? layer.levels[0]?.level : undefined;
300
321
  }
301
322
  /** One browse page of the layer. Counts come from the aggregate, so the page is asked unfiltered. */
302
323
  browse(req) {
@@ -307,9 +328,17 @@ let MosaicaClusterFilter = (() => {
307
328
  const { level, ...rest } = req;
308
329
  return s.browse({ ...rest, layer: this.layer, filters: null, limit: Math.max(1, Math.min(req.limit, ceiling)), ...(level === undefined ? {} : { level }) });
309
330
  }
310
- offer(row) {
311
- this.rows.set(row.mosaicaId, row);
312
- return { row, path: row.parentIds.length === 0 ? [] : null, beyond: false };
331
+ /** Offer the rows of a page, and walk the parents of each. */
332
+ offer(rows, layer) {
333
+ const s = this.resolvedStore;
334
+ this.paths.meet(rows);
335
+ if (s)
336
+ this.paths.ask(s, layer, rows.map((r) => r.mosaicaId), PATH_DEPTH);
337
+ return rows.map((row) => ({ row }));
338
+ }
339
+ /** An offer's path of parents, once walked; empty for a cluster with none. */
340
+ pathOf(o) {
341
+ return this.paths.path(o.row.mosaicaId, PATH_DEPTH);
313
342
  }
314
343
  /** Fetch the top-level clusters, once per layer and view. */
315
344
  fetchTops() {
@@ -322,12 +351,12 @@ let MosaicaClusterFilter = (() => {
322
351
  return;
323
352
  this.fetchedFor = key;
324
353
  const epoch = this.epoch;
325
- const level = this.levels(layer)[0];
354
+ const level = this.firstLevel(layer);
326
355
  this.browse({ limit: TOP_LEVEL_ROWS, ...(level === undefined ? {} : { level }) })
327
356
  .then((page) => {
328
357
  if (epoch !== this.epoch)
329
358
  return;
330
- this.tops = page.artifacts.map((r) => this.offer(r));
359
+ this.tops = this.offer(page.artifacts, layer);
331
360
  this.refusal = null;
332
361
  })
333
362
  .catch((error) => {
@@ -339,23 +368,22 @@ let MosaicaClusterFilter = (() => {
339
368
  this.fetchedFor = '';
340
369
  });
341
370
  }
342
- /** Search the layer's names for `q`, at every level of a levelled layer, and walk each match's path. */
371
+ /**
372
+ * Search the layer's names for `q`, and walk each match's path. One request with no level
373
+ * searches every level of a levelled layer.
374
+ */
343
375
  searchFor(q) {
344
376
  const layer = this.declared();
345
377
  if (!layer)
346
378
  return;
347
379
  const epoch = this.epoch;
348
380
  const limit = this.resolvedStore?.get('meta')?.selection.maxBrowseRows ?? 50;
349
- Promise.all(this.levels(layer).map((level) => this.browse({ q, limit: Math.min(limit, 50), ...(level === undefined ? {} : { level }) })))
350
- .then((pages) => {
381
+ this.browse({ q, limit: Math.min(limit, 50) })
382
+ .then((page) => {
351
383
  if (epoch !== this.epoch || this.search.trim() !== q)
352
384
  return;
353
- const offers = pages.flatMap((p) => p.artifacts.map((r) => this.offer(r)));
354
- this.found = { q, offers, more: pages.some((p) => p.next !== null) };
385
+ this.found = { q, offers: this.offer(page.artifacts, layer), more: page.next !== null };
355
386
  this.refusal = null;
356
- const walkable = layer.hierarchy.kind === 'nested' || layer.hierarchy.kind === 'dag';
357
- for (const o of offers)
358
- void this.walk(o, walkable, epoch);
359
387
  })
360
388
  .catch((error) => {
361
389
  if (epoch !== this.epoch)
@@ -364,38 +392,6 @@ let MosaicaClusterFilter = (() => {
364
392
  this.found = { q, offers: [], more: false };
365
393
  });
366
394
  }
367
- /**
368
- * Fill in an offer's path, its first parent's name and so on up to {@link PATH_DEPTH} parents. On
369
- * a layer whose children form needs no level, a missing parent is asked for; on a levelled layer
370
- * only parents already met are named.
371
- */
372
- async walk(o, ask, epoch) {
373
- const names = [];
374
- let at = o.row;
375
- for (let i = 0; i < PATH_DEPTH && at.parentIds.length > 0; i++) {
376
- const up = at.parentIds[0];
377
- let parent = this.rows.get(up);
378
- if (!parent && ask) {
379
- let held = this.parentsOf.get(at.mosaicaId);
380
- if (!held) {
381
- held = this.browse({ parent: at.mosaicaId, limit: 1 }).then((p) => p.parents, () => []);
382
- this.parentsOf.set(at.mosaicaId, held);
383
- }
384
- for (const r of await held)
385
- this.rows.set(r.mosaicaId, r);
386
- parent = this.rows.get(up);
387
- }
388
- if (!parent)
389
- break;
390
- names.unshift(artifactName(parent) ?? UNNAMED);
391
- at = parent;
392
- }
393
- if (epoch !== this.epoch)
394
- return;
395
- o.path = names;
396
- o.beyond = at.parentIds.length > 0;
397
- this.requestUpdate();
398
- }
399
395
  /** The clauses this field holds: the layer's, in its position. */
400
396
  held() {
401
397
  return (this.resolvedStore?.get('filters').members ?? []).filter((m) => m.layer === this.layer && m.verb === 'filter');
@@ -409,15 +405,15 @@ let MosaicaClusterFilter = (() => {
409
405
  this.search = '';
410
406
  this.found = null;
411
407
  this.listOpen = false;
412
- this.choose({ id: idString(row.mosaicaId), name: artifactName(row), path: offer.path && offer.path.length > 0 ? pathText(offer.path, offer.beyond, offer.path.length) : '', rung: row.rung });
408
+ const path = this.pathOf(offer);
409
+ this.choose({ id: idString(row.mosaicaId), name: artifactName(row), path: path && path.steps.length > 0 ? pathText(path, path.steps.length) : '', rung: row.rung });
413
410
  return;
414
411
  }
415
412
  const members = s.get('filters').members;
416
413
  const on = this.held().some((m) => m.artifact === row.mosaicaId && !m.outside);
417
- const name = artifactName(row);
418
414
  s.setMembers(on
419
415
  ? withoutMember(members, this.layer, row.mosaicaId, 'filter')
420
- : withMember(members, { layer: this.layer, artifact: row.mosaicaId, outside: false, verb: 'filter', ...(name === null ? {} : { label: name }) }));
416
+ : withMember(members, { layer: this.layer, artifact: row.mosaicaId, outside: false, verb: 'filter', ...labelOf(called(row.name, row.key)) }));
421
417
  emit(this, 'mosaica-clausechange', { id: idString(row.mosaicaId), layer: this.layer, outside: false, verb: 'filter', on: !on });
422
418
  this.search = '';
423
419
  this.found = null;
@@ -459,30 +455,33 @@ let MosaicaClusterFilter = (() => {
459
455
  /** The rows and width {@link fitPaths} last measured. */
460
456
  pathsMeasured = '';
461
457
  /**
462
- * Name as many of each listed path's nearest parents as fit its line, dropping whole names from
463
- * the left. Measured with the line's own font, after the list is placed.
458
+ * Where a path is kept to one line, name as many of each listed path's nearest parents as fit
459
+ * it, dropping whole names from the left. Measured with the line's own font, after the list is
460
+ * placed. A path that wraps names them all.
464
461
  */
465
462
  fitPaths() {
466
463
  // Only while the list shows, and only when its rows or its width changed since last measured.
467
464
  const shown = this.listOpen ? Array.from(this.renderRoot.querySelectorAll('[part="path"][data-id]')) : [];
468
- const key = `${shown[0]?.clientWidth ?? 0}|${shown.map((el) => `${el.dataset.id}:${el.title}`).join(',')}`;
465
+ const oneLine = shown[0] ? getComputedStyle(shown[0]).whiteSpace === 'nowrap' : false;
466
+ const key = `${oneLine}|${shown[0]?.clientWidth ?? 0}|${shown.map((el) => `${el.dataset.id}:${el.title}`).join(',')}`;
469
467
  if (shown.length === 0 || key === this.pathsMeasured)
470
468
  return;
471
469
  this.pathsMeasured = key;
472
- const context = typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
473
- if (!context)
474
- return;
470
+ const context = !oneLine || typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
475
471
  let changed = false;
476
472
  for (const el of shown) {
477
473
  const id = BigInt(el.dataset.id);
478
474
  const o = this.listed()?.find((x) => x.row.mosaicaId === id);
479
- if (!o?.path || el.clientWidth === 0)
475
+ const path = o ? this.pathOf(o) : null;
476
+ if (!path)
480
477
  continue;
481
- context.font = getComputedStyle(el).font;
482
- let n = o.path.length;
483
- while (n > 1 && context.measureText(pathText(o.path, o.beyond, n)).width > el.clientWidth)
484
- n -= 1;
485
- if ((this.pathFits.get(id) ?? o.path.length) !== n) {
478
+ let n = path.steps.length;
479
+ if (context && el.clientWidth > 0) {
480
+ context.font = getComputedStyle(el).font;
481
+ while (n > 1 && context.measureText(pathText(path, n)).width > el.clientWidth)
482
+ n -= 1;
483
+ }
484
+ if ((this.pathFits.get(id) ?? path.steps.length) !== n) {
486
485
  this.pathFits.set(id, n);
487
486
  changed = true;
488
487
  }
@@ -494,6 +493,9 @@ let MosaicaClusterFilter = (() => {
494
493
  super.updated(changed);
495
494
  this.floating.update();
496
495
  this.fitPaths();
496
+ if (changed.has('activeId'))
497
+ this.renderRoot.querySelector('[part~="option"][data-active]')?.scrollIntoView({ block: 'nearest' });
498
+ this.awaited.track(this.listOpen && this.isConnected && this.refusal === null && this.listed() === null);
497
499
  // Counted only while the list is open: a closed list shows no counts, so asks for none.
498
500
  const layer = this.declared();
499
501
  const limits = this.resolvedStore?.get('meta')?.selection;
@@ -514,30 +516,33 @@ let MosaicaClusterFilter = (() => {
514
516
  const listed = this.listed();
515
517
  // Largest first once the counts land; the server's order until then.
516
518
  const offers = listed === null ? [] : counts === null ? listed : [...listed].sort((a, b) => (countOf(b) ?? -1) - (countOf(a) ?? -1));
517
- const active = offers.find((o) => o.row.mosaicaId === this.activeId) ?? offers[0] ?? null;
518
- const move = (by) => {
519
- if (offers.length === 0)
520
- return;
521
- const at = active ? offers.indexOf(active) : -1;
522
- this.activeId = offers[(at + by + offers.length) % offers.length].row.mosaicaId;
519
+ // The row Enter acts on: the one the arrow keys reached. Until they reach one there is none, so
520
+ // Enter does not take whichever row the counts put first as they land.
521
+ const active = offers.find((o) => o.row.mosaicaId === this.activeId) ?? null;
522
+ const at = active ? offers.indexOf(active) : -1;
523
+ const reach = (i) => {
524
+ if (offers.length > 0)
525
+ this.activeId = offers[(i + offers.length) % offers.length].row.mosaicaId;
523
526
  };
524
527
  const open = this.listOpen && offers.length > 0;
525
528
  const option = (o) => {
526
- const name = artifactName(o.row);
529
+ const name = called(o.row.name, o.row.key);
527
530
  const count = countOf(o);
528
- const full = o.path && o.path.length > 0 ? pathText(o.path, o.beyond, o.path.length) : '';
529
- const path = o.path && o.path.length > 0 ? pathText(o.path, o.beyond, this.pathFits.get(o.row.mosaicaId) ?? o.path.length) : '';
531
+ const walked = this.pathOf(o);
532
+ const full = walked && walked.steps.length > 0 ? pathText(walked, walked.steps.length) : '';
533
+ const path = walked && walked.steps.length > 0 ? pathText(walked, this.pathFits.get(o.row.mosaicaId) ?? walked.steps.length) : '';
530
534
  return html `<button type="button" part="option" role="option" id=${`c-${o.row.mosaicaId}`} tabindex="-1" data-id=${idString(o.row.mosaicaId)} ?data-active=${o === active}
531
535
  aria-selected=${on.has(o.row.mosaicaId) ? 'true' : 'false'}
532
536
  @mousedown=${(e) => e.preventDefault()} @click=${() => this.toggle(o)}>
533
- <span class="opt"><span part="name" ?data-unnamed=${name === null}>${name ?? UNNAMED}</span>${path ? html `<span part="path" data-id=${idString(o.row.mosaicaId)} title=${full}><bdi>${path}</bdi></span>` : nothing}</span>
537
+ <span class="opt"><span part="name" ?data-key-name=${name.from === 'key'} ?data-unnamed=${name.from === 'none'}>${name.text}</span>${path ? html `<span part="path" data-id=${idString(o.row.mosaicaId)} title=${full}><bdi>${path}</bdi></span>` : nothing}</span>
534
538
  ${count === undefined ? nothing : html `<span part="value-count">${count.toLocaleString('en-GB')}</span>`}
535
539
  </button>`;
536
540
  };
541
+ const shown = this.placeholder || 'Type a name';
537
542
  const box = html `<div class="combo">
538
- <div class="input">${icon('search', 12)}<input id="ctl" part="entry" type="search" autocomplete="off" placeholder=${this.placeholder || 'Type a name'}
539
- role="combobox" aria-expanded=${open ? 'true' : 'false'} aria-controls="values" aria-activedescendant=${open && active ? `c-${active.row.mosaicaId}` : nothing}
540
- aria-label=${`${title}: find a cluster`} .value=${this.search}
543
+ <div class="input">${icon('search', 12)}<input id="ctl" part="entry" type="search" autocomplete="off" placeholder=${shown}
544
+ role="combobox" aria-autocomplete="list" aria-expanded=${open ? 'true' : 'false'} aria-controls="values" aria-activedescendant=${open && active ? `c-${active.row.mosaicaId}` : nothing}
545
+ aria-label=${`${title}: ${shown}`} .value=${this.search}
541
546
  @focus=${() => {
542
547
  this.fetchTops();
543
548
  this.listOpen = true;
@@ -548,7 +553,12 @@ let MosaicaClusterFilter = (() => {
548
553
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
549
554
  e.preventDefault();
550
555
  this.listOpen = true;
551
- move(e.key === 'ArrowDown' ? 1 : -1);
556
+ reach(e.key === 'ArrowDown' ? at + 1 : Math.max(at, 0) - 1);
557
+ }
558
+ else if ((e.key === 'Home' || e.key === 'End') && open && active) {
559
+ // Before the arrow keys reach a row, Home and End move in the text.
560
+ e.preventDefault();
561
+ reach(e.key === 'Home' ? 0 : -1);
552
562
  }
553
563
  else if (e.key === 'Enter') {
554
564
  if (open && active)
@@ -559,19 +569,26 @@ let MosaicaClusterFilter = (() => {
559
569
  this.listOpen = false;
560
570
  }
561
571
  }} /></div>
562
- ${open ? html `<div part="values" id="values" popover="manual" role="listbox" aria-label=${title}>${repeat(offers, (o) => o.row.mosaicaId, option)}</div>` : nothing}
572
+ ${open ? html `<div part="values" id="values" popover="manual" role="listbox" aria-multiselectable=${this.choose ? nothing : 'true'} aria-label=${title}>${repeat(offers, (o) => o.row.mosaicaId, option)}</div>` : nothing}
563
573
  </div>`;
564
574
  const typed = this.search.trim() !== '';
565
- const note = this.refusal
575
+ // How many clusters are listed, or why none is, for a reader as the list changes under the box.
576
+ const said = this.refusal
566
577
  ? html `<span part="refusal" data-code=${this.refusal.code}>Clusters unavailable</span>`
567
- : typed && this.found?.q === this.search.trim()
568
- ? this.found.offers.length === 0
569
- ? html `<span part="more">No cluster by that name</span>`
570
- : this.found.more
571
- ? html `<span part="more">Type more to narrow the list</span>`
572
- : nothing
573
- : nothing;
574
- return html `${box}${note}`;
578
+ : listed === null
579
+ ? this.listOpen
580
+ ? html `<span class="skel" aria-hidden="true"></span>${this.awaited.named ? html `<span class="sr-only">Loading clusters</span>` : nothing}`
581
+ : nothing
582
+ : offers.length === 0
583
+ ? typed
584
+ ? html `<span part="more">No cluster by that name</span>`
585
+ : this.listOpen
586
+ ? html `<span part="more">No clusters</span>`
587
+ : nothing
588
+ : !open
589
+ ? nothing
590
+ : html `<span class="sr-only">${offers.length} ${offers.length === 1 ? 'cluster' : 'clusters'} listed. </span>${typed && this.found?.more ? html `<span part="more">Type more to narrow the list</span>` : nothing}`;
591
+ return html `${box}<div role="status">${said}</div>`;
575
592
  }
576
593
  };
577
594
  })();
@@ -64,8 +64,12 @@ export declare class MosaicaColourEditor extends MosaicaElement {
64
64
  static styles: import("lit").CSSResult[];
65
65
  /** What the dialog colours: a rendered category column, or `cluster:<layer>`. */
66
66
  accessor field: string;
67
- /** The level of a `stacked` or `tiered` layer whose clusters are listed. `null` is the coarsest level. */
68
- accessor level: number | null;
67
+ /**
68
+ * The level each `stacked` or `tiered` layer's clusters are listed at, keyed by layer name. A
69
+ * layer with no entry is listed at the level the map draws it at, which follows the zoom where its
70
+ * levels declare no zoom range. Set it as a property; it has no attribute.
71
+ */
72
+ accessor clusterLevels: ReadonlyMap<string, number>;
69
73
  /** Whether the dialog is open. @internal */
70
74
  accessor isOpen: boolean;
71
75
  /** The ticked rows, by key. @internal */
@@ -81,6 +85,8 @@ export declare class MosaicaColourEditor extends MosaicaElement {
81
85
  private opener;
82
86
  /** The frame the paths of the rows in sight are next asked for in, while one is waiting. */
83
87
  private pathFrame;
88
+ /** The level the layer was listed at in the last update, chosen or followed. */
89
+ private listedAt;
84
90
  /** Open the dialog. */
85
91
  show(): Promise<void>;
86
92
  /** Close the dialog and return focus to the element that had it before. */
@@ -89,11 +95,14 @@ export declare class MosaicaColourEditor extends MosaicaElement {
89
95
  protected resetServerData(): void;
90
96
  disconnectedCallback(): void;
91
97
  /**
92
- * A change of field or level drops what was found and ticked and closes the picker, since each
93
- * names the last field's values; the dialog closes where the field is now nothing it can edit.
98
+ * A change of field or of the level its layer is listed at, chosen or followed as the map zooms,
99
+ * drops what was found and ticked and closes the picker, since each names the last field's
100
+ * values; the dialog closes where the field is now nothing it can edit.
94
101
  */
95
102
  protected willUpdate(changed: PropertyValues<this>): void;
96
103
  private get layerName();
104
+ /** The level `layer` is listed at: its entry in `clusterLevels`, else the level the map draws it at. */
105
+ private levelOf;
97
106
  /** The layer listed, where `field` names one the points can be coloured by. */
98
107
  private layerOf;
99
108
  /** Whether `field` is a rendered category column. */
@@ -123,6 +132,8 @@ export declare class MosaicaColourEditor extends MosaicaElement {
123
132
  private palette;
124
133
  private pick;
125
134
  private tick;
135
+ /** Untick every row. The bar Deselect is on goes with them, so focus moves to the first row's checkbox. */
136
+ private deselect;
126
137
  /** A match the search box found: its row gets focus where the list holds it; else it goes at the top. */
127
138
  private onFound;
128
139
  private onKey;