@mosaicajs/components 0.2.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 +118 -37
  17. package/dist/explorer.js +487 -212
  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 +62 -14
  27. package/dist/item-card.js +310 -110
  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 +1848 -832
  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
@@ -38,10 +38,11 @@ import { repeat } from 'lit/directives/repeat.js';
38
38
  import { CLUSTER_PREFIX, activeCount, artifactName, emptyDraft, isPopulated, withoutClause, withoutMember } from '@mosaicajs/client';
39
39
  import { OPERATOR_WORDS } from './filter.js';
40
40
  import { HeldAggregate } from './aggregate.js';
41
- import { MosaicaElement, UNNAMED, columnCaption, dateRangeText, countText, emit, keyTitle, shortCount } from './base.js';
41
+ import { BOOL_WORDS, MosaicaElement, boolKeys, called, columnCaption, dateRangeText, countText, emit, focusLost, keyTitle, shortCount } from './base.js';
42
42
  import { attachContextRoot, defineOnce } from './define.js';
43
43
  import { icon } from './icons.js';
44
44
  import { exportparts, forwarded } from './parts.js';
45
+ import { switchView } from './view-switch.js';
45
46
  import { FloatingList } from './float.js';
46
47
  import { renderState, stateOf } from './states.js';
47
48
  import { chrome, tokens } from './tokens.js';
@@ -55,12 +56,27 @@ export function clusterFieldLayers(meta, view) {
55
56
  /** A layer's field as the panel keys it, apart from any column's. */
56
57
  const layerField = (layer) => `${CLUSTER_PREFIX}${layer}`;
57
58
  const VERBS = ['filter', 'highlight'];
59
+ /** A view's name as the view picker shows it, else its id. */
60
+ const viewTitle = (meta, id) => meta.views.find((v) => v.id === id)?.displayName ?? id;
61
+ /** A held region's chip text: its shape and the view it was drawn in. */
62
+ function regionText(shape, view) {
63
+ const kind = shape.kind === 'box' ? 'Box' : shape.kind === 'lasso' ? 'Lasso' : 'Shape';
64
+ return `${shape.outside ? `Outside ${kind.toLowerCase()}` : kind} in ${view}`;
65
+ }
58
66
  /** A figure in the subject row: whole, or shortened in the compact layout. */
59
67
  const figure = (n, compact) => (compact ? shortCount(n) : countText(n));
60
68
  /**
61
69
  * Use the filter panel as the place where viewers narrow the map down, as `<mosaica-explorer>` does
62
- * in its sidebar. Each filter or highlight in force shows as a chip above the cards, and pressing a
63
- * chip opens its field's card.
70
+ * in its sidebar. Each filter or highlight in force shows as a chip above the cards, a highlight's
71
+ * with a pen on it, and pressing a chip opens its field's card. When a chip is removed, focus
72
+ * moves to the × of the chip after it, else of the one before, else to Add field. Under
73
+ * `chips-only` there is no Add field, so when the last chip is removed the panel shows the words
74
+ * No filters, which take focus and go as focus leaves them; a chip that arrives while they hold
75
+ * focus takes it on its ×.
76
+ *
77
+ * A region selected in another view keeps filtering after the viewer switches away from it, and
78
+ * shows as a chip naming its view, such as "Lasso in Topics". Pressing the chip switches back to
79
+ * that view, where the region is selected again, and its × removes it.
64
80
  *
65
81
  * The row at the top says what the cards' solid bars count. That is the items in view that the
66
82
  * filters match, or the highlighted items while there is a highlight. The pale bars count
@@ -70,6 +86,10 @@ const figure = (n, compact) => (compact ? shortCount(n) : countText(n));
70
86
  * highlight, and for the field the map is coloured by. Viewers add other fields with Add field.
71
87
  * Taking a card off clears that field's filters and highlights.
72
88
  *
89
+ * The panel sets `hide-chips` on the cards it shows, so that a layer's card does not repeat the
90
+ * chips the panel shows for its clauses. Under `controls-only` the panel shows no chips, and the
91
+ * cards keep theirs.
92
+ *
73
93
  * Set `chips-only` on one panel and `controls-only` on another when you want the chips apart from
74
94
  * the cards.
75
95
  *
@@ -80,6 +100,10 @@ const figure = (n, compact) => (compact ? shortCount(n) : countText(n));
80
100
  * column's chip was removed, Clear all was pressed, the highlight was cleared from the subject
81
101
  * row, or a card was taken off in Add field. Removing a `member_of` chip fires nothing. Each card
82
102
  * fires its own as well.
103
+ * @fires {CustomEvent<MosaicaEventDetails['mosaica-selectchange']>} mosaica-selectchange - The chip
104
+ * of a region held in another view was removed, or Clear all removed one, with that `view`.
105
+ * @fires {CustomEvent<MosaicaEventDetails['mosaica-viewswitch']>} mosaica-viewswitch - The chip of
106
+ * a region held in another view was pressed, and the panel switched to that view.
83
107
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-chipopen']>} mosaica-chipopen - A column's chip
84
108
  * was pressed while `chips-only` is set, so you can show its card.
85
109
  * @csspart state - The state line, with `data-state`.
@@ -92,15 +116,18 @@ const figure = (n, compact) => (compact ? shortCount(n) : countText(n));
92
116
  * @csspart clear-highlight - The × beside Highlighted, which clears the highlight.
93
117
  * @csspart all - All matching and its number.
94
118
  * @csspart all-count - The number matching.
95
- * @csspart chips - The line of chips, while any filter or highlight is in force.
96
- * @csspart chip - One chip, with `data-verb` (`filter` or `highlight`) and `data-column` or
97
- * `data-artifact`.
98
- * @csspart edit - A column chip's text, a button that opens its card.
119
+ * @csspart chips - The line of chips, while any filter or highlight is in force, and under
120
+ * `chips-only` the words that stand in it after the last chip is removed.
121
+ * @csspart chip - One chip, with `data-verb` (`filter` or `highlight`) and `data-column`,
122
+ * `data-artifact`, or `data-view` for a region held in another view.
123
+ * @csspart edit - A column chip's text, a button that opens its card; on a held region's chip, a
124
+ * button that switches to its view.
99
125
  * @csspart clear - The Clear all button.
100
126
  * @csspart card - One `<mosaica-field-card>`, with `data-field`.
101
127
  * @csspart add - The Add field button, with `aria-expanded`.
102
128
  * @csspart add-note - How many fields are not listed.
103
- * @csspart add-list - The list of fields to add, while it is open.
129
+ * @csspart add-list - The list of fields to add, while it is open. A press outside it, or focus
130
+ * leaving it, closes it.
104
131
  * @csspart add-search - The search box over that list.
105
132
  * @csspart add-option - One field in that list, with `data-column` or `data-layer`, `aria-checked`
106
133
  * while its card is shown, and `aria-disabled` if it is pinned and cannot be taken off.
@@ -122,9 +149,9 @@ let MosaicaFilterPanel = (() => {
122
149
  let _compact_decorators;
123
150
  let _compact_initializers = [];
124
151
  let _compact_extraInitializers = [];
125
- let _clusterLevel_decorators;
126
- let _clusterLevel_initializers = [];
127
- let _clusterLevel_extraInitializers = [];
152
+ let _clusterLevels_decorators;
153
+ let _clusterLevels_initializers = [];
154
+ let _clusterLevels_extraInitializers = [];
128
155
  let _opened_decorators;
129
156
  let _opened_initializers = [];
130
157
  let _opened_extraInitializers = [];
@@ -143,6 +170,9 @@ let MosaicaFilterPanel = (() => {
143
170
  let _addActive_decorators;
144
171
  let _addActive_initializers = [];
145
172
  let _addActive_extraInitializers = [];
173
+ let _emptied_decorators;
174
+ let _emptied_initializers = [];
175
+ let _emptied_extraInitializers = [];
146
176
  return class MosaicaFilterPanel extends _classSuper {
147
177
  static {
148
178
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
@@ -150,24 +180,26 @@ let MosaicaFilterPanel = (() => {
150
180
  _controlsOnly_decorators = [property({ type: Boolean, attribute: 'controls-only' })];
151
181
  _pinned_decorators = [property()];
152
182
  _compact_decorators = [property({ type: Boolean, reflect: true })];
153
- _clusterLevel_decorators = [property({ type: Number, attribute: 'cluster-level' })];
183
+ _clusterLevels_decorators = [property({ attribute: false })];
154
184
  _opened_decorators = [state()];
155
185
  _folded_decorators = [state()];
156
186
  _openCard_decorators = [state()];
157
187
  _adding_decorators = [state()];
158
188
  _addSearch_decorators = [state()];
159
189
  _addActive_decorators = [state()];
190
+ _emptied_decorators = [state()];
160
191
  __esDecorate(this, null, _chipsOnly_decorators, { kind: "accessor", name: "chipsOnly", static: false, private: false, access: { has: obj => "chipsOnly" in obj, get: obj => obj.chipsOnly, set: (obj, value) => { obj.chipsOnly = value; } }, metadata: _metadata }, _chipsOnly_initializers, _chipsOnly_extraInitializers);
161
192
  __esDecorate(this, null, _controlsOnly_decorators, { kind: "accessor", name: "controlsOnly", static: false, private: false, access: { has: obj => "controlsOnly" in obj, get: obj => obj.controlsOnly, set: (obj, value) => { obj.controlsOnly = value; } }, metadata: _metadata }, _controlsOnly_initializers, _controlsOnly_extraInitializers);
162
193
  __esDecorate(this, null, _pinned_decorators, { kind: "accessor", name: "pinned", static: false, private: false, access: { has: obj => "pinned" in obj, get: obj => obj.pinned, set: (obj, value) => { obj.pinned = value; } }, metadata: _metadata }, _pinned_initializers, _pinned_extraInitializers);
163
194
  __esDecorate(this, null, _compact_decorators, { kind: "accessor", name: "compact", static: false, private: false, access: { has: obj => "compact" in obj, get: obj => obj.compact, set: (obj, value) => { obj.compact = value; } }, metadata: _metadata }, _compact_initializers, _compact_extraInitializers);
164
- __esDecorate(this, null, _clusterLevel_decorators, { kind: "accessor", name: "clusterLevel", static: false, private: false, access: { has: obj => "clusterLevel" in obj, get: obj => obj.clusterLevel, set: (obj, value) => { obj.clusterLevel = value; } }, metadata: _metadata }, _clusterLevel_initializers, _clusterLevel_extraInitializers);
195
+ __esDecorate(this, null, _clusterLevels_decorators, { kind: "accessor", name: "clusterLevels", static: false, private: false, access: { has: obj => "clusterLevels" in obj, get: obj => obj.clusterLevels, set: (obj, value) => { obj.clusterLevels = value; } }, metadata: _metadata }, _clusterLevels_initializers, _clusterLevels_extraInitializers);
165
196
  __esDecorate(this, null, _opened_decorators, { kind: "accessor", name: "opened", static: false, private: false, access: { has: obj => "opened" in obj, get: obj => obj.opened, set: (obj, value) => { obj.opened = value; } }, metadata: _metadata }, _opened_initializers, _opened_extraInitializers);
166
197
  __esDecorate(this, null, _folded_decorators, { kind: "accessor", name: "folded", static: false, private: false, access: { has: obj => "folded" in obj, get: obj => obj.folded, set: (obj, value) => { obj.folded = value; } }, metadata: _metadata }, _folded_initializers, _folded_extraInitializers);
167
198
  __esDecorate(this, null, _openCard_decorators, { kind: "accessor", name: "openCard", static: false, private: false, access: { has: obj => "openCard" in obj, get: obj => obj.openCard, set: (obj, value) => { obj.openCard = value; } }, metadata: _metadata }, _openCard_initializers, _openCard_extraInitializers);
168
199
  __esDecorate(this, null, _adding_decorators, { kind: "accessor", name: "adding", static: false, private: false, access: { has: obj => "adding" in obj, get: obj => obj.adding, set: (obj, value) => { obj.adding = value; } }, metadata: _metadata }, _adding_initializers, _adding_extraInitializers);
169
200
  __esDecorate(this, null, _addSearch_decorators, { kind: "accessor", name: "addSearch", static: false, private: false, access: { has: obj => "addSearch" in obj, get: obj => obj.addSearch, set: (obj, value) => { obj.addSearch = value; } }, metadata: _metadata }, _addSearch_initializers, _addSearch_extraInitializers);
170
201
  __esDecorate(this, null, _addActive_decorators, { kind: "accessor", name: "addActive", static: false, private: false, access: { has: obj => "addActive" in obj, get: obj => obj.addActive, set: (obj, value) => { obj.addActive = value; } }, metadata: _metadata }, _addActive_initializers, _addActive_extraInitializers);
202
+ __esDecorate(this, null, _emptied_decorators, { kind: "accessor", name: "emptied", static: false, private: false, access: { has: obj => "emptied" in obj, get: obj => obj.emptied, set: (obj, value) => { obj.emptied = value; } }, metadata: _metadata }, _emptied_initializers, _emptied_extraInitializers);
171
203
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
172
204
  }
173
205
  #chipsOnly_accessor_storage = __runInitializers(this, _chipsOnly_initializers, false);
@@ -190,11 +222,15 @@ let MosaicaFilterPanel = (() => {
190
222
  /** Keeps one card open at a time, and shortens the subject row's figures. */
191
223
  get compact() { return this.#compact_accessor_storage; }
192
224
  set compact(value) { this.#compact_accessor_storage = value; }
193
- #clusterLevel_accessor_storage = (__runInitializers(this, _compact_extraInitializers), __runInitializers(this, _clusterLevel_initializers, null));
194
- /** The level that cards for layers with levels count at. `null` is the coarsest level. */
195
- get clusterLevel() { return this.#clusterLevel_accessor_storage; }
196
- set clusterLevel(value) { this.#clusterLevel_accessor_storage = value; }
197
- #opened_accessor_storage = (__runInitializers(this, _clusterLevel_extraInitializers), __runInitializers(this, _opened_initializers, new Set()));
225
+ #clusterLevels_accessor_storage = (__runInitializers(this, _compact_extraInitializers), __runInitializers(this, _clusterLevels_initializers, new Map()));
226
+ /**
227
+ * The level each card for a `stacked` or `tiered` layer counts at, keyed by layer name. A layer
228
+ * with no entry counts at the level the map draws it at, which follows the zoom where its levels
229
+ * declare no zoom range. Set it as a property; it has no attribute.
230
+ */
231
+ get clusterLevels() { return this.#clusterLevels_accessor_storage; }
232
+ set clusterLevels(value) { this.#clusterLevels_accessor_storage = value; }
233
+ #opened_accessor_storage = (__runInitializers(this, _clusterLevels_extraInitializers), __runInitializers(this, _opened_initializers, new Set()));
198
234
  /** The fields the user added, which stay listed. @internal */
199
235
  get opened() { return this.#opened_accessor_storage; }
200
236
  set opened(value) { this.#opened_accessor_storage = value; }
@@ -257,6 +293,7 @@ let MosaicaFilterPanel = (() => {
257
293
  height: 6px;
258
294
  border-radius: 1px;
259
295
  background: var(--_mosaica-bar);
296
+ forced-color-adjust: none;
260
297
  }
261
298
  .swatch.lit {
262
299
  background: var(--_mosaica-bar-highlight);
@@ -276,6 +313,7 @@ let MosaicaFilterPanel = (() => {
276
313
  color: var(--_mosaica-ink);
277
314
  }
278
315
  [part='clear-highlight'] {
316
+ position: relative;
279
317
  width: 18px;
280
318
  height: 18px;
281
319
  display: grid;
@@ -283,6 +321,18 @@ let MosaicaFilterPanel = (() => {
283
321
  border-radius: 4px;
284
322
  color: var(--_mosaica-ink-2);
285
323
  }
324
+ /* Each takes a press from 24 px around its middle, drawn no larger for it. */
325
+ [part='clear-highlight']::after,
326
+ .chip .edit::after,
327
+ [part='clear']::after {
328
+ content: '';
329
+ position: absolute;
330
+ inset: -3px;
331
+ }
332
+ .chip .edit::after,
333
+ [part='clear']::after {
334
+ inset: -4px 0;
335
+ }
286
336
  [part='clear-highlight']:hover {
287
337
  background: var(--_mosaica-surface-3);
288
338
  }
@@ -295,17 +345,30 @@ let MosaicaFilterPanel = (() => {
295
345
  border-bottom: 1px solid var(--_mosaica-line-2);
296
346
  }
297
347
  [part='chip'] {
348
+ position: relative;
298
349
  min-height: 0;
299
350
  padding: 2px 4px 2px 8px;
300
351
  gap: 4px;
301
352
  }
302
- [part='chip'] > button:last-child {
303
- width: 16px;
304
- height: 16px;
305
- display: grid;
306
- place-items: center;
353
+ /* A region held as the viewer leaves its view arrives here from the map's corner. */
354
+ [part='chip'][data-view] {
355
+ animation: arrive 1.2s ease-out 1;
356
+ }
357
+ @keyframes arrive {
358
+ from {
359
+ box-shadow: 0 0 0 3px var(--_mosaica-accent);
360
+ }
361
+ to {
362
+ box-shadow: 0 0 0 0 transparent;
363
+ }
364
+ }
365
+ @media (prefers-reduced-motion: reduce) {
366
+ [part='chip'][data-view] {
367
+ animation: none;
368
+ }
307
369
  }
308
370
  .chip .edit {
371
+ position: relative;
309
372
  display: inline-flex;
310
373
  flex: 0 1 auto;
311
374
  align-items: center;
@@ -313,7 +376,12 @@ let MosaicaFilterPanel = (() => {
313
376
  font: inherit;
314
377
  text-align: left;
315
378
  }
379
+ /* The pen on a highlight's chip, which says in a shape what the chip's colour says. */
380
+ [part='chip'] > svg {
381
+ flex: none;
382
+ }
316
383
  [part='clear'] {
384
+ position: relative;
317
385
  margin-left: auto;
318
386
  padding: 0;
319
387
  font-size: 12px;
@@ -389,12 +457,37 @@ let MosaicaFilterPanel = (() => {
389
457
  font-size: 12px;
390
458
  color: var(--_mosaica-ink-3);
391
459
  }
460
+ .emptied {
461
+ padding: 2px 4px;
462
+ border-radius: 4px;
463
+ font-size: 12px;
464
+ color: var(--_mosaica-ink-2);
465
+ }
466
+ @media (forced-colors: active) {
467
+ .swatch {
468
+ background: CanvasText;
469
+ }
470
+ .swatch.lit {
471
+ background: Highlight;
472
+ }
473
+ .swatch.pale {
474
+ background: GrayText;
475
+ }
476
+ }
392
477
  `
393
478
  ];
394
479
  /** Every item the filters admit, which All matching counts. */
395
480
  matching = (__runInitializers(this, _addActive_extraInitializers), new HeldAggregate('fields-match'));
396
481
  /** The card to open, scroll to and focus once the cards are drawn. */
397
482
  editing = null;
483
+ /** A pressed control that its own action removes, and the control that takes focus once it has gone. */
484
+ leaving = null;
485
+ #emptied_accessor_storage = __runInitializers(this, _emptied_initializers, false);
486
+ /** Whether the last chip was removed from a panel with nothing else to focus, which then says so. @internal */
487
+ get emptied() { return this.#emptied_accessor_storage; }
488
+ set emptied(value) { this.#emptied_accessor_storage = value; }
489
+ /** Whether those words held focus as a render began. */
490
+ wordsHeld = (__runInitializers(this, _emptied_extraInitializers), false);
398
491
  /** The keys of the fields shown, in the order they were first shown. */
399
492
  listOrder = [];
400
493
  floating = new FloatingList(() => {
@@ -424,8 +517,41 @@ let MosaicaFilterPanel = (() => {
424
517
  disconnectedCallback() {
425
518
  this.floating.stop();
426
519
  this.matching.set(null, null);
520
+ document.removeEventListener('pointerdown', this.onPressOutsideAdd, true);
427
521
  super.disconnectedCallback();
428
522
  }
523
+ /** A press outside the open Add field list closes it, as Escape does. */
524
+ onPressOutsideAdd = (e) => {
525
+ const adder = this.renderRoot.querySelector('.adder');
526
+ if (!adder || !e.composedPath().includes(adder))
527
+ this.adding = false;
528
+ };
529
+ /**
530
+ * Before an action that removes the pressed control `gone`, note where focus goes once it has.
531
+ * From a chip's ×, to the × of the chip after it, else of the chip before, as each stands then.
532
+ * From there, and from Clear all and the clear-highlight ×, to Add field: a × that took focus
533
+ * unasked would remove a filter on the next press. Under `chips-only`, which has no Add field, to
534
+ * the words that say no filter is left.
535
+ */
536
+ leave(gone) {
537
+ const closes = Array.from(this.renderRoot.querySelectorAll('[part="chip"] > button:last-child'));
538
+ const at = closes.indexOf(gone);
539
+ this.leaving = { gone, to: at < 0 ? null : (closes[at + 1] ?? closes[at - 1] ?? null) };
540
+ }
541
+ /** Move focus as {@link leave} noted, once the pressed control has gone and taken focus with it. */
542
+ settleFocus() {
543
+ const leaving = this.leaving;
544
+ if (!leaving || leaving.gone.isConnected)
545
+ return;
546
+ this.leaving = null;
547
+ if (!focusLost())
548
+ return;
549
+ const to = (leaving.to?.isConnected ? leaving.to : null) ?? this.renderRoot.querySelector('[part="add"]');
550
+ if (to)
551
+ return to.focus();
552
+ this.emptied = true;
553
+ void this.updateComplete.then(() => this.renderRoot.querySelector('.emptied')?.focus());
554
+ }
429
555
  /** Show `field`'s card, open it, and scroll to and focus it. Call it after `mosaica-chipopen` to show the chip's card. */
430
556
  show(field) {
431
557
  this.opened = new Set([...this.opened, field]);
@@ -478,6 +604,20 @@ let MosaicaFilterPanel = (() => {
478
604
  updated(changed) {
479
605
  super.updated(changed);
480
606
  this.floating.update();
607
+ this.settleFocus();
608
+ // A chip that arrives takes the words' place, and their focus where they held it.
609
+ const arrived = this.emptied ? this.renderRoot.querySelector('[part="chip"] > button:last-child') : null;
610
+ if (arrived) {
611
+ this.emptied = false;
612
+ if (this.wordsHeld && focusLost())
613
+ arrived.focus();
614
+ }
615
+ if (changed.has('adding')) {
616
+ if (this.adding)
617
+ document.addEventListener('pointerdown', this.onPressOutsideAdd, true);
618
+ else
619
+ document.removeEventListener('pointerdown', this.onPressOutsideAdd, true);
620
+ }
481
621
  const s = this.resolvedStore;
482
622
  this.matching.set(s, this.isConnected && !this.chipsOnly && !this.controlsOnly && s?.get('meta') ? { groupings: [{ count: true }] } : null);
483
623
  if (!this.editing || this.chipsOnly)
@@ -501,9 +641,12 @@ let MosaicaFilterPanel = (() => {
501
641
  case 'category':
502
642
  return `${caption}: ${draft.keys.map((k) => keyTitle(this.resolvedStore, column, k)).join(', ')}`;
503
643
  case 'numeric': {
504
- const meta = this.resolvedStore?.get('meta');
505
- if (meta?.declaredScalars.find((c) => c.name === column)?.arrowType === 'timestamp_us')
644
+ const type = this.resolvedStore?.get('meta')?.declaredScalars.find((c) => c.name === column)?.arrowType;
645
+ if (type === 'timestamp_us')
506
646
  return `${caption} ${dateRangeText(draft.gte, draft.lte)}`;
647
+ const kept = type === 'bool' ? boolKeys(draft) : [];
648
+ if (kept.length > 0)
649
+ return `${caption}: ${kept.map((k) => BOOL_WORDS[k]).join(', ')}`;
507
650
  const f = (v) => v.toLocaleString('en-GB');
508
651
  if (draft.lte === null)
509
652
  return `${caption} ≥ ${f(draft.gte)}`;
@@ -515,14 +658,27 @@ let MosaicaFilterPanel = (() => {
515
658
  }
516
659
  /**
517
660
  * A `member_of` chip's text: the clause's own label (the only name a filter layer's artifact has,
518
- * since it is not served), else the served name, else unnamed.
661
+ * since it is not served), else the served name or key, else unnamed.
519
662
  */
520
663
  memberText(clause) {
521
664
  const artifacts = this.resolvedStore?.get('artifacts');
522
665
  const served = artifacts?.served.find((a) => a.mosaicaId === clause.artifact && a.layer === clause.layer);
523
- const name = clause.label ?? (served ? artifactName(served, artifacts.attached) : null) ?? UNNAMED;
666
+ const name = clause.label ?? called(served ? artifactName(served, artifacts.attached) : null, served?.key).text;
524
667
  return clause.outside ? `Outside ${name}` : name;
525
668
  }
669
+ /** Switch to the view a held region is in, where the store selects it again. */
670
+ showRegion(meta, view) {
671
+ const s = this.resolvedStore;
672
+ if (s)
673
+ switchView(this, s, meta, view);
674
+ }
675
+ clearRegion(view) {
676
+ const s = this.resolvedStore;
677
+ if (!s)
678
+ return;
679
+ s.setRegions(s.get('filters').regions.filter((r) => r.view !== view));
680
+ emit(this, 'mosaica-selectchange', { shape: null, view, status: 'cleared' });
681
+ }
526
682
  clearMember(clause) {
527
683
  const s = this.resolvedStore;
528
684
  if (!s)
@@ -541,9 +697,13 @@ let MosaicaFilterPanel = (() => {
541
697
  const meta = s?.get('meta');
542
698
  if (!s || !meta)
543
699
  return;
700
+ const held = s.get('filters').regions;
544
701
  s.setFilters(emptyDraft(meta.filterOperands));
545
702
  s.setMembers([]);
703
+ s.setRegions([]);
546
704
  emit(this, 'mosaica-filterchange', { column: null, expr: null });
705
+ for (const { view } of held)
706
+ emit(this, 'mosaica-selectchange', { shape: null, view, status: 'cleared' });
547
707
  }
548
708
  /** Empty every clause in the highlight position, so the cards count what is in view again. */
549
709
  clearHighlight() {
@@ -616,12 +776,12 @@ let MosaicaFilterPanel = (() => {
616
776
  const layers = clusterFieldLayers(meta, s.get('view').id);
617
777
  if (operands.length === 0 && layers.length === 0)
618
778
  return html `<div class="panel"><span part="state" data-state="empty">Nothing to filter</span></div>`;
619
- const { draft, members } = s.get('filters');
779
+ const { draft, members, regions } = s.get('filters');
620
780
  const holds = (column, verb) => {
621
781
  const d = draft[verb][column];
622
782
  return d !== undefined && isPopulated(d);
623
783
  };
624
- const chips = this.chipLine(draft, members, holds);
784
+ const chips = this.chipLine(meta, draft, members, regions, holds);
625
785
  if (this.chipsOnly)
626
786
  return chips;
627
787
  const fields = [
@@ -638,7 +798,7 @@ let MosaicaFilterPanel = (() => {
638
798
  this.listOrder = [...this.listOrder.filter((k) => keys.has(k)), ...shown.map((f) => f.key).filter((k) => !this.listOrder.includes(k))];
639
799
  const listed = this.listOrder.map((k) => shown.find((f) => f.key === k));
640
800
  const open = this.compact ? (listed.some((f) => f.key === this.openCard) ? this.openCard : null) : null;
641
- const card = (f) => html `<mosaica-field-card part="card" exportparts=${CARD_PARTS} data-field=${f.key} field=${f.key} .store=${s} .level=${this.clusterLevel}
801
+ const card = (f) => html `<mosaica-field-card part="card" exportparts=${CARD_PARTS} data-field=${f.key} field=${f.key} ?hide-chips=${!this.controlsOnly} .store=${s} .level=${f.layer ? (this.clusterLevels.get(f.layer.name) ?? null) : null}
642
802
  ?compact=${this.compact} .folded=${this.compact ? f.key !== open : this.folded.has(f.key)}
643
803
  @mosaica-fold=${(e) => this.onToggle(f.key, e.currentTarget)}
644
804
  @mosaica-filterchange=${() => this.keepListed(f.key)} @mosaica-clausechange=${() => this.keepListed(f.key)}></mosaica-field-card>`;
@@ -655,26 +815,54 @@ let MosaicaFilterPanel = (() => {
655
815
  const skel = html `<span class="skel" aria-hidden="true"></span>`;
656
816
  return html `<div part="subject">
657
817
  <span class="key"><span part="subject-key" class=${`swatch${lit ? ' lit' : ''}`}></span><span part="subject-name" class=${lit ? 'lit' : ''}>${lit ? 'Highlighted' : 'In view'}</span><span part="subject-count">${n === null ? skel : figure(n, this.compact)}</span>${lit
658
- ? html `<button part="clear-highlight" type="button" aria-label="Clear highlight" title="Clear highlight" @click=${() => this.clearHighlight()}>${icon('close', 11, 2.4)}</button>`
818
+ ? html `<button part="clear-highlight" type="button" aria-label="Clear highlight" title="Clear highlight" @click=${(e) => {
819
+ this.leave(e.currentTarget);
820
+ this.clearHighlight();
821
+ }}>${icon('close', 11, 2.4)}</button>`
659
822
  : nothing}</span>
660
823
  <span part="all" class="key"><span class="swatch pale"></span><span>${this.compact ? 'All' : 'All matching'}</span><span part="all-count">${total === null ? skel : figure(total, this.compact)}</span></span>
661
824
  </div>`;
662
825
  }
663
826
  /** Every clause as a chip, then Clear all; nothing while none is applied. */
664
- chipLine(draft, members, holds) {
827
+ chipLine(meta, draft, members, regions, holds) {
665
828
  const columns = [...new Set([...Object.keys(draft.filter), ...Object.keys(draft.highlight)])];
666
829
  const chips = VERBS.flatMap((verb) => columns.flatMap((c) => (holds(c, verb) ? [{ c, verb, d: draft[verb][c] }] : [])));
667
- if (chips.length === 0 && members.length === 0)
668
- return nothing;
830
+ if (chips.length === 0 && members.length === 0 && regions.length === 0) {
831
+ return this.emptied && this.chipsOnly ? html `<div part="chips"><span class="emptied" tabindex="-1" @blur=${() => (this.emptied = false)}>No filters</span></div>` : nothing;
832
+ }
833
+ // The pen on a highlight's chip, with the word for a reader; a filter's has neither.
834
+ const word = (verb) => (verb === 'highlight' ? html `${icon('highlight', 12, 1.6)}<span class="sr-only">highlight </span>` : nothing);
835
+ const pressed = (e) => this.leave(e.currentTarget);
669
836
  return html `<div part="chips">
670
- ${chips.map(({ c, verb, d }) => html `<span part="chip" class="chip" data-verb=${verb} data-column=${c}
671
- ><button part="edit" class="edit" type="button" title="Show its card" @click=${() => this.edit(c, verb)}>${this.chipText(c, d)}</button
672
- ><button type="button" aria-label=${`Remove the ${columnCaption(c)} ${verb}`} @click=${() => this.clearColumn(c, verb)}>${icon('close', 10, 2.4)}</button></span
837
+ ${repeat(chips, ({ c, verb }) => `${verb} ${c}`, ({ c, verb, d }) => html `<span part="chip" class="chip" data-verb=${verb} data-column=${c}
838
+ >${word(verb)}<button part="edit" class="edit" type="button" title="Show its card" @click=${() => this.edit(c, verb)}>${this.chipText(c, d)}</button
839
+ ><button type="button" aria-label=${`Remove the ${columnCaption(c)} ${verb}`} @click=${(e) => {
840
+ pressed(e);
841
+ this.clearColumn(c, verb);
842
+ }}>${icon('close', 10, 2.4)}</button></span
673
843
  >`)}
674
- ${[...members].sort((a, b) => VERBS.indexOf(a.verb) - VERBS.indexOf(b.verb)).map((m) => html `<span part="chip" class="chip" data-verb=${m.verb} data-artifact=${String(m.artifact)}
675
- >${this.memberText(m)}<button type="button" aria-label=${`Remove ${this.memberText(m)}`} @click=${() => this.clearMember(m)}>${icon('close', 10, 2.4)}</button></span
844
+ ${repeat([...members].sort((a, b) => VERBS.indexOf(a.verb) - VERBS.indexOf(b.verb)), (m) => `${m.verb} ${m.layer} ${m.artifact}`, (m) => html `<span part="chip" class="chip" data-verb=${m.verb} data-artifact=${String(m.artifact)}
845
+ >${word(m.verb)}${this.memberText(m)}<button type="button" aria-label=${`Remove the ${this.memberText(m)} ${m.verb}`} @click=${(e) => {
846
+ pressed(e);
847
+ this.clearMember(m);
848
+ }}>${icon('close', 10, 2.4)}</button></span
676
849
  >`)}
677
- <button part="clear" type="button" @click=${() => this.clearAll()}>Clear all</button>
850
+ ${repeat(regions, (r) => r.view, (r) => {
851
+ const view = viewTitle(meta, r.view);
852
+ const text = regionText(r.shape, view);
853
+ return html `<span part="chip" class="chip" data-verb="filter" data-view=${r.view}
854
+ ><button part="edit" class="edit" type="button" title=${`Show in ${view}`} @click=${() => this.showRegion(meta, r.view)}
855
+ >${icon(r.shape.kind === 'lasso' ? 'lasso' : 'box', 12)}${text}</button
856
+ ><button type="button" aria-label=${`Remove the ${text} filter`} @click=${(e) => {
857
+ pressed(e);
858
+ this.clearRegion(r.view);
859
+ }}>${icon('close', 10, 2.4)}</button></span
860
+ >`;
861
+ })}
862
+ <button part="clear" type="button" @click=${(e) => {
863
+ pressed(e);
864
+ this.clearAll();
865
+ }}>Clear all</button>
678
866
  </div>`;
679
867
  }
680
868
  /** Add field, its list of every field, and how many are not listed. */
@@ -690,7 +878,12 @@ let MosaicaFilterPanel = (() => {
690
878
  this.takeOff(key);
691
879
  };
692
880
  const rest = fields.length - listed.length;
693
- return html `<div class="adder">
881
+ return html `<div class="adder" @focusout=${(e) => {
882
+ // Focus that goes to nothing, as on a press on the list's own padding or scrollbar, has not left.
883
+ const to = e.relatedTarget;
884
+ if (to && !e.currentTarget.contains(to))
885
+ this.adding = false;
886
+ }}>
694
887
  <button part="add" class="btn" type="button" aria-expanded=${this.adding ? 'true' : 'false'} aria-controls="add-list"
695
888
  @click=${() => {
696
889
  this.adding = !this.adding;
@@ -720,6 +913,9 @@ let MosaicaFilterPanel = (() => {
720
913
  if (unlisted)
721
914
  this.add(unlisted.key);
722
915
  }} /></div>
916
+ <div role="status">${offered.length > 0
917
+ ? html `<span class="sr-only">${offered.length} ${offered.length === 1 ? 'field' : 'fields'} listed</span>`
918
+ : html `<span class="none">No field by that name</span>`}</div>
723
919
  ${offered.length > 0
724
920
  ? html `<div role="menu" aria-label="Fields">${offered.map((o, i) => {
725
921
  const added = isListed(o.key);
@@ -729,13 +925,17 @@ let MosaicaFilterPanel = (() => {
729
925
  aria-disabled=${kept ? 'true' : nothing} title=${kept ? 'Always shown here' : added ? 'Remove from the column' : nothing} tabindex=${i === tabbed ? '0' : '-1'}
730
926
  @click=${() => choose(o.key)} @keydown=${(e) => this.addKeys(e, i)}><span>${o.title}</span>${added ? html `<span class="kind">Shown</span>` : o.layer ? html `<span class="kind">Clusters</span>` : nothing}</button>`;
731
927
  })}</div>`
732
- : html `<span class="none">No field by that name</span>`}
928
+ : nothing}
733
929
  </div>`
734
930
  : nothing}
735
931
  </div>`;
736
932
  }
737
933
  willUpdate(changed) {
738
934
  super.willUpdate(changed);
935
+ let at = this.emptied && this.isConnected ? document.activeElement : null;
936
+ while (at && at.getRootNode() !== this.shadowRoot)
937
+ at = at.shadowRoot?.activeElement ?? null;
938
+ this.wordsHeld = at?.classList.contains('emptied') ?? false;
739
939
  // The search box takes focus as the list opens.
740
940
  if (changed.has('adding') && this.adding)
741
941
  void this.updateComplete.then(() => this.renderRoot.querySelector('[part="add-search"]')?.focus());
package/dist/filter.d.ts CHANGED
@@ -14,11 +14,16 @@ export declare const OPERATOR_WORDS: Record<KeywordOperator, string>;
14
14
  * - A text column takes words that an item must all contain. Double quotes match a phrase where the
15
15
  * column supports phrases, and `OR` between terms matches either.
16
16
  * - A category column suggests values as the viewer types. The arrow keys move through the
17
- * suggestions, Enter chooses or removes one, and Escape clears what was typed.
17
+ * suggestions, as do Home and End once one is reached. Enter chooses or removes the suggestion
18
+ * they reached, and Escape clears what was typed. A line under the box says how many values are
19
+ * listed, or why none is, and that they are loading once that has taken a quarter of a second.
18
20
  * - A keyword column has a menu for choosing how to match, by `contains`, `prefix` or `eq`.
19
21
  *
20
22
  * The box sets the filter 350 ms after the last keystroke, so that the map does not redraw for
21
- * every letter. If your code sets a text filter the box cannot express, the box shows it read-only.
23
+ * every letter. While the query that change started is still loading, and once it has run for a
24
+ * quarter of a second, a spinner takes the search icon's place. The spinner is not read out:
25
+ * `<mosaica-status>` is what announces a running query. If your code sets a text filter the box
26
+ * cannot express, the box shows it read-only.
22
27
  *
23
28
  * @summary A search box that filters the map by one column.
24
29
  * @tagname mosaica-filter
@@ -26,11 +31,13 @@ export declare const OPERATOR_WORDS: Record<KeywordOperator, string>;
26
31
  * @fires {CustomEvent<MosaicaEventDetails['mosaica-filterchange']>} mosaica-filterchange - The box
27
32
  * changed the column's filter.
28
33
  * @csspart entry - The box.
34
+ * @csspart spinner - The spinner in the search icon's place while the box's own query runs.
29
35
  * @csspart mode - The keyword column's operator button, which opens its menu.
30
36
  * @csspart operators - The menu of a keyword column's operators.
31
37
  * @csspart operator - One operator in the menu, with `aria-checked`.
32
38
  * @csspart values - The typeahead's suggestions.
33
- * @csspart tick - One suggested value, with `aria-selected`.
39
+ * @csspart tick - One suggested value, with `aria-selected`, and `data-active` on the one the arrow
40
+ * keys reached.
34
41
  * @csspart bar - A bar for a suggested value's share of the items counted.
35
42
  * @csspart value-count - A suggested value's count.
36
43
  * @csspart more - The note under the box when nothing matches, or when more values match than are
@@ -56,13 +63,18 @@ export declare class MosaicaFilter extends MosaicaElement {
56
63
  accessor draft: ColumnDraft | null;
57
64
  /** @internal */
58
65
  accessor search: string;
59
- /** The suggestion the arrow keys moved to, by code; `null` is the first. @internal */
66
+ /** The suggestion the arrow keys reached, by code; `null` before they reach one. @internal */
60
67
  accessor activeCode: number | null;
61
68
  /** Whether the category box has focus, which its suggestions show only while it does. @internal */
62
69
  accessor focused: boolean;
63
70
  /** Whether a keyword column's operator menu is open. @internal */
64
71
  accessor operatorsOpen: boolean;
65
72
  private sent;
73
+ /** Whether the query the store is running was started by this box. */
74
+ private asked;
75
+ private readonly query;
76
+ /** Whether the suggestions have been awaited long enough to say so. */
77
+ private readonly awaited;
66
78
  private readonly floating;
67
79
  private readonly operatorMenu;
68
80
  private typing;
@@ -92,10 +104,16 @@ export declare class MosaicaFilter extends MosaicaElement {
92
104
  /** Moves focus to the box. */
93
105
  focus(options?: FocusOptions): void;
94
106
  protected updated(changed: PropertyValues<this>): void;
107
+ private get querying();
108
+ /** Whether a category box holds text whose suggestions have neither landed nor been refused. */
109
+ private get awaiting();
110
+ /** The search icon, or a spinner while the query this box started has run long enough to name. */
111
+ private lead;
95
112
  /** The box's draft: the one being edited, else the store's, else the client's empty one, which also sets a keyword's starting operator. */
96
113
  private currentDraft;
97
114
  private change;
98
115
  render(): TemplateResult | typeof nothing;
116
+ /** The box's name: the column, and the placeholder the box shows. */
99
117
  private label;
100
118
  private text;
101
119
  /** A keyword box, offering the operators the column publishes in a menu. */