@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
package/dist/item-card.js CHANGED
@@ -35,7 +35,7 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
35
35
  import { css, html, nothing } from 'lit';
36
36
  import { property, state } from 'lit/decorators.js';
37
37
  import { GRID32 } from '@mosaicajs/client';
38
- import { MosaicaElement, columnCaption, emit, idString, keyTitle, timestampText } from './base.js';
38
+ import { BOOL_WORDS, MosaicaElement, columnCaption, emit, idString, keyTitle, timestampText } from './base.js';
39
39
  import { attachContextRoot, defineOnce } from './define.js';
40
40
  import { icon } from './icons.js';
41
41
  import { reasonOf, refusedState, renderState, stateOf } from './states.js';
@@ -47,19 +47,25 @@ import { chrome, tokens } from './tokens.js';
47
47
  *
48
48
  * The headline is the `title-field` field, or the item's `mosaica_id` when that field is unset or
49
49
  * empty. To render a field your own way, as a link for example, put an element in its
50
- * `field-<name>` slot.
50
+ * `field-<name>` slot. The card is headed "Item" for a screen reader, with the headline a heading
51
+ * under it.
52
+ *
53
+ * A value over 60 characters is kept to six lines, with a Show more button under it where that
54
+ * cuts it short. A `compact` card also keeps its headline to three lines and its subtitle to two,
55
+ * and its Show all button shows every field and all of each.
51
56
  *
52
57
  * A field named in `image-fields` whose value is an `http:` or `https:` address, or a path
53
58
  * resolved against the page, is drawn as the image at that address, across the card's width. The viewer's browser loads it from the host
54
- * that serves it, without a referrer. A value that is not such an address, and an image that does
55
- * not load, are shown as text.
59
+ * that serves it, without a referrer. Its alternative text is the item's `title-field` value, or
60
+ * the field's name where the item has none. A value that is not such an address, and an image that
61
+ * does not load, are shown as text.
56
62
  *
57
63
  * The Open button appears only when you set `open-url` or `open`. `open-url` is a link template
58
64
  * such as `https://www.geonames.org/{geonameid}`, in which each `{name}` is replaced by the item's
59
65
  * value for that field. Because the values are URL-encoded, a template cannot open an address that
60
66
  * a field already holds, so set `open` to a function for that. An item gets no Open button when it
61
- * has no value for a field the template names. A function in `open` is called with the item's
62
- * `mosaica_id` and fields, and it takes precedence over `open-url`.
67
+ * has no value for a field the template names. The link opens in a new tab. A function in `open`
68
+ * is called with the item's `mosaica_id` and fields, and it takes precedence over `open-url`.
63
69
  *
64
70
  * @summary A card showing an item's fields, with an optional Open button.
65
71
  * @tagname mosaica-item-card
@@ -74,23 +80,26 @@ import { chrome, tokens } from './tokens.js';
74
80
  * @csspart title - The header row: the headline or the state, and the close button.
75
81
  * @csspart close - The close button.
76
82
  * @csspart state - The state line, with `data-state`: `shown`, `empty` (no item, or a click that
77
- * found nothing), `refused` or `detached`.
83
+ * found nothing), `refused` or `detached`. It is inside a `role="status"` region.
78
84
  * @csspart refusal - The words "Item unavailable", with `data-code` set to the refusal's code if
79
85
  * the server refused the record. For a broken pick, the details are in the map's `lastPick`.
80
86
  * @csspart reason - The server's reason for refusing the record, under "Item unavailable".
81
- * @csspart headline - The headline, with `data-name` set to the field it shows.
87
+ * @csspart headline - The headline, an `<h3>`, with `data-name` set to the field it shows.
82
88
  * @csspart subtitle - The `subtitle-field` value under the headline, when `compact` is set.
83
89
  * @csspart show-all - The "Show all N fields" button, when `compact` is set, with `aria-expanded`.
84
- * @csspart view-chip - One view the item is in, with `data-view` and `aria-current` on the current
85
- * view.
90
+ * It reads "Show all" where every field is shown and only text is cut short.
91
+ * @csspart view-chip - One view the item is in, with `data-view`, and `aria-current` and a bar
92
+ * under the current view.
86
93
  * @csspart field - One field, with `data-name`, and `data-prose` on a value over 60 characters.
87
- * @csspart label - A field's name, and the heading above the view chips.
88
- * @csspart value - A field's value.
94
+ * @csspart label - A field's name, a `<dt>`, and the heading above the view chips.
95
+ * @csspart value - A field's value, a `<dd>`.
96
+ * @csspart more - The Show more button under a long value that is cut short, with `aria-expanded`.
89
97
  * @csspart image - A field's value drawn as an image, under `image-fields`.
90
98
  * @csspart scoped - The group-scoped values, grouped by key.
91
99
  * @csspart key - One key's heading among the group-scoped values, with `data-key`.
92
100
  * @csspart open - The Open button.
93
- * @csspart copy - The Copy id button, which copies the `mosaica_id` to the clipboard.
101
+ * @csspart copy - The Copy id button, which copies the `mosaica_id` to the clipboard and says to a
102
+ * screen reader whether it did.
94
103
  */
95
104
  let MosaicaItemCard = (() => {
96
105
  let _classSuper = MosaicaElement;
@@ -130,6 +139,15 @@ let MosaicaItemCard = (() => {
130
139
  let _expanded_decorators;
131
140
  let _expanded_initializers = [];
132
141
  let _expanded_extraInitializers = [];
142
+ let _opened_decorators;
143
+ let _opened_initializers = [];
144
+ let _opened_extraInitializers = [];
145
+ let _clipped_decorators;
146
+ let _clipped_initializers = [];
147
+ let _clipped_extraInitializers = [];
148
+ let _copied_decorators;
149
+ let _copied_initializers = [];
150
+ let _copied_extraInitializers = [];
133
151
  let _broken_decorators;
134
152
  let _broken_initializers = [];
135
153
  let _broken_extraInitializers = [];
@@ -148,6 +166,9 @@ let MosaicaItemCard = (() => {
148
166
  _openUrl_decorators = [property({ attribute: 'open-url' })];
149
167
  _open_decorators = [property({ attribute: false })];
150
168
  _expanded_decorators = [state()];
169
+ _opened_decorators = [state()];
170
+ _clipped_decorators = [state()];
171
+ _copied_decorators = [state()];
151
172
  _broken_decorators = [state()];
152
173
  __esDecorate(this, null, _item_decorators, { kind: "accessor", name: "item", static: false, private: false, access: { has: obj => "item" in obj, get: obj => obj.item, set: (obj, value) => { obj.item = value; } }, metadata: _metadata }, _item_initializers, _item_extraInitializers);
153
174
  __esDecorate(this, null, _refusal_decorators, { kind: "accessor", name: "refusal", static: false, private: false, access: { has: obj => "refusal" in obj, get: obj => obj.refusal, set: (obj, value) => { obj.refusal = value; } }, metadata: _metadata }, _refusal_initializers, _refusal_extraInitializers);
@@ -161,6 +182,9 @@ let MosaicaItemCard = (() => {
161
182
  __esDecorate(this, null, _openUrl_decorators, { kind: "accessor", name: "openUrl", static: false, private: false, access: { has: obj => "openUrl" in obj, get: obj => obj.openUrl, set: (obj, value) => { obj.openUrl = value; } }, metadata: _metadata }, _openUrl_initializers, _openUrl_extraInitializers);
162
183
  __esDecorate(this, null, _open_decorators, { kind: "accessor", name: "open", static: false, private: false, access: { has: obj => "open" in obj, get: obj => obj.open, set: (obj, value) => { obj.open = value; } }, metadata: _metadata }, _open_initializers, _open_extraInitializers);
163
184
  __esDecorate(this, null, _expanded_decorators, { kind: "accessor", name: "expanded", static: false, private: false, access: { has: obj => "expanded" in obj, get: obj => obj.expanded, set: (obj, value) => { obj.expanded = value; } }, metadata: _metadata }, _expanded_initializers, _expanded_extraInitializers);
185
+ __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);
186
+ __esDecorate(this, null, _clipped_decorators, { kind: "accessor", name: "clipped", static: false, private: false, access: { has: obj => "clipped" in obj, get: obj => obj.clipped, set: (obj, value) => { obj.clipped = value; } }, metadata: _metadata }, _clipped_initializers, _clipped_extraInitializers);
187
+ __esDecorate(this, null, _copied_decorators, { kind: "accessor", name: "copied", static: false, private: false, access: { has: obj => "copied" in obj, get: obj => obj.copied, set: (obj, value) => { obj.copied = value; } }, metadata: _metadata }, _copied_initializers, _copied_extraInitializers);
164
188
  __esDecorate(this, null, _broken_decorators, { kind: "accessor", name: "broken", static: false, private: false, access: { has: obj => "broken" in obj, get: obj => obj.broken, set: (obj, value) => { obj.broken = value; } }, metadata: _metadata }, _broken_initializers, _broken_extraInitializers);
165
189
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
166
190
  }
@@ -175,7 +199,7 @@ let MosaicaItemCard = (() => {
175
199
  display: flex;
176
200
  align-items: flex-start;
177
201
  justify-content: space-between;
178
- gap: 10px;
202
+ gap: 0;
179
203
  margin: 0 0 10px;
180
204
  font-size: 13px;
181
205
  font-weight: 400;
@@ -183,6 +207,11 @@ let MosaicaItemCard = (() => {
183
207
  text-transform: none;
184
208
  color: var(--_mosaica-ink);
185
209
  }
210
+ /* The state's region is in the row whether or not it says anything, and takes room only when it does. */
211
+ .said {
212
+ min-width: 0;
213
+ }
214
+ .said.alone,
186
215
  .head [part='headline'],
187
216
  .head .headline {
188
217
  flex: 1 1 auto;
@@ -193,6 +222,13 @@ let MosaicaItemCard = (() => {
193
222
  flex-direction: column;
194
223
  gap: 3px;
195
224
  }
225
+ h3[part='headline'] {
226
+ display: block;
227
+ margin: 0;
228
+ letter-spacing: 0;
229
+ text-transform: none;
230
+ color: var(--_mosaica-ink);
231
+ }
196
232
  [part='subtitle'] {
197
233
  font-size: 12px;
198
234
  color: var(--_mosaica-ink-2);
@@ -203,20 +239,41 @@ let MosaicaItemCard = (() => {
203
239
  :host([compact]) .card-title {
204
240
  font-size: 14px;
205
241
  }
206
- /* The compact card keeps its headline to three lines and its subtitle to two; Open shows them whole. */
207
- :host([compact]) .card-title,
208
- :host([compact]) [part='subtitle'] {
242
+ /* Text kept to a few lines until Show all, or its own Show more, shows it whole: a compact
243
+ card's headline to three and its subtitle to two, a long value to six. */
244
+ .clamp {
209
245
  display: -webkit-box;
210
- -webkit-line-clamp: 3;
211
- line-clamp: 3;
246
+ -webkit-line-clamp: 6;
247
+ line-clamp: 6;
212
248
  -webkit-box-orient: vertical;
213
249
  overflow: hidden;
214
250
  overflow-wrap: anywhere;
215
251
  }
216
- :host([compact]) [part='subtitle'] {
252
+ h3[part='headline'].clamp {
253
+ display: -webkit-box;
254
+ -webkit-line-clamp: 3;
255
+ line-clamp: 3;
256
+ }
257
+ [part='subtitle'].clamp {
217
258
  -webkit-line-clamp: 2;
218
259
  line-clamp: 2;
219
260
  }
261
+ /* Each is drawn as a line of text, with a target 24 px high and at least as wide around it. */
262
+ [part='show-all'],
263
+ [part='more'] {
264
+ position: relative;
265
+ }
266
+ [part='show-all']::before,
267
+ [part='more']::before {
268
+ content: '';
269
+ position: absolute;
270
+ inset: -4px 0;
271
+ min-width: 24px;
272
+ }
273
+ dl,
274
+ dd {
275
+ margin: 0;
276
+ }
220
277
  :host([compact]) .foot {
221
278
  display: flex;
222
279
  align-items: center;
@@ -237,6 +294,7 @@ let MosaicaItemCard = (() => {
237
294
  }
238
295
  ::slotted([slot='actions']) {
239
296
  flex: none;
297
+ margin-left: 10px;
240
298
  }
241
299
  .head [part='state'] {
242
300
  min-height: 24px;
@@ -253,14 +311,6 @@ let MosaicaItemCard = (() => {
253
311
  border-radius: 6px;
254
312
  object-fit: contain;
255
313
  }
256
- [part='field'][data-prose] [part='value'] {
257
- display: -webkit-box;
258
- -webkit-line-clamp: 6;
259
- line-clamp: 6;
260
- -webkit-box-orient: vertical;
261
- overflow: hidden;
262
- overflow-wrap: anywhere;
263
- }
264
314
  .field .v.mono {
265
315
  font-size: 12px;
266
316
  }
@@ -274,7 +324,7 @@ let MosaicaItemCard = (() => {
274
324
  flex: none;
275
325
  width: 24px;
276
326
  height: 24px;
277
- margin: -2px -4px 0 0;
327
+ margin: -2px -4px 0 10px;
278
328
  display: grid;
279
329
  place-items: center;
280
330
  border-radius: 5px;
@@ -289,9 +339,11 @@ let MosaicaItemCard = (() => {
289
339
  gap: 6px;
290
340
  margin: 4px 0 10px;
291
341
  }
342
+ /* The bar marks the current view by more than its colour, and takes no room. */
292
343
  [part='view-chip'][aria-current='true'] {
293
344
  background: var(--_mosaica-accent);
294
345
  color: var(--_mosaica-accent-ink);
346
+ box-shadow: inset 0 -2px 0 currentColor;
295
347
  }
296
348
  [part='scoped'] {
297
349
  margin-top: 12px;
@@ -365,6 +417,19 @@ let MosaicaItemCard = (() => {
365
417
  get expanded() { return this.#expanded_accessor_storage; }
366
418
  set expanded(value) { this.#expanded_accessor_storage = value; }
367
419
  expandedFor = (__runInitializers(this, _expanded_extraInitializers), null);
420
+ #opened_accessor_storage = __runInitializers(this, _opened_initializers, new Set());
421
+ /** The long values shown whole on the full card, by field, for the item shown. @internal */
422
+ get opened() { return this.#opened_accessor_storage; }
423
+ set opened(value) { this.#opened_accessor_storage = value; }
424
+ #clipped_accessor_storage = (__runInitializers(this, _opened_extraInitializers), __runInitializers(this, _clipped_initializers, new Set()));
425
+ /** The fields whose headline, subtitle or value is cut short where it is drawn. @internal */
426
+ get clipped() { return this.#clipped_accessor_storage; }
427
+ set clipped(value) { this.#clipped_accessor_storage = value; }
428
+ #copied_accessor_storage = (__runInitializers(this, _clipped_extraInitializers), __runInitializers(this, _copied_initializers, ''));
429
+ /** What the last press of Copy id came to, for the item shown. @internal */
430
+ get copied() { return this.#copied_accessor_storage; }
431
+ set copied(value) { this.#copied_accessor_storage = value; }
432
+ resized = (__runInitializers(this, _copied_extraInitializers), null);
368
433
  #broken_accessor_storage = __runInitializers(this, _broken_initializers, new Set());
369
434
  /** The image addresses that failed to load, shown as text. @internal */
370
435
  get broken() { return this.#broken_accessor_storage; }
@@ -392,6 +457,9 @@ let MosaicaItemCard = (() => {
392
457
  if (id !== this.expandedFor) {
393
458
  this.expandedFor = id;
394
459
  this.expanded = false;
460
+ this.opened = new Set();
461
+ this.clipped = new Set();
462
+ this.copied = '';
395
463
  }
396
464
  const store = this.resolvedStore;
397
465
  if (!store || !store.get('meta') || !meta || !this.isConnected)
@@ -410,6 +478,55 @@ let MosaicaItemCard = (() => {
410
478
  });
411
479
  }
412
480
  }
481
+ connectedCallback() {
482
+ super.connectedCallback();
483
+ if (typeof ResizeObserver === 'undefined')
484
+ return;
485
+ this.resized ??= new ResizeObserver(() => this.measure());
486
+ this.resized.observe(this);
487
+ }
488
+ disconnectedCallback() {
489
+ this.resized?.disconnect();
490
+ super.disconnectedCallback();
491
+ }
492
+ updated(changed) {
493
+ super.updated(changed);
494
+ this.measure();
495
+ }
496
+ /**
497
+ * Note which clamped texts are cut short at the card's width, so each is offered whole. A text
498
+ * shown whole keeps what was noted while it was clamped, so the button that shows it stays.
499
+ */
500
+ measure() {
501
+ const clipped = new Set(this.clipped);
502
+ for (const el of Array.from(this.renderRoot.querySelectorAll('.clamp'))) {
503
+ const name = el.closest('[data-name]')?.dataset.name;
504
+ if (name === undefined)
505
+ continue;
506
+ if (el.scrollHeight > el.clientHeight + 1)
507
+ clipped.add(name);
508
+ else
509
+ clipped.delete(name);
510
+ }
511
+ if (clipped.size !== this.clipped.size || [...clipped].some((name) => !this.clipped.has(name)))
512
+ this.clipped = clipped;
513
+ }
514
+ /** Copy the id to the clipboard and say in the status region how that went. */
515
+ async copy(id) {
516
+ const shown = this.expandedFor;
517
+ this.copied = '';
518
+ await this.updateComplete;
519
+ let copied = false;
520
+ try {
521
+ await navigator.clipboard.writeText(id);
522
+ copied = true;
523
+ }
524
+ catch {
525
+ // No clipboard on a page that is not secure, or the browser refused.
526
+ }
527
+ if (shown === this.expandedFor)
528
+ this.copied = copied ? 'Copied' : 'Could not copy';
529
+ }
413
530
  get shown() {
414
531
  // A card fed by property still takes the schema from an adopted store: declaration order and
415
532
  // view names and frames belong to the bundle.
@@ -423,104 +540,111 @@ let MosaicaItemCard = (() => {
423
540
  }
424
541
  render() {
425
542
  const { item, refusal, meta } = this.shown;
426
- const close = html `<slot name="actions"></slot><button part="close" type="button" aria-label="Close" @click=${() => emit(this, 'mosaica-close', { what: 'item' })}>${icon('close', 14)}</button>`;
427
- // The header row: what the card shows, and the close button where there is something to close.
428
- const head = (content, closable) => html `<div part="title" class="head">${content}${closable ? close : nothing}</div>`;
429
- const unavailable = (code, reason) => html `<div class="panel">${head(refusedState('Item unavailable', code, null, reason), true)}</div>`;
543
+ // The close button appears only where there is something to close.
544
+ let closable = true;
545
+ let state = html `<span part="state" data-state="shown"></span>`;
430
546
  if (refusal)
431
- return unavailable(refusal.code, reasonOf(refusal));
432
- if (!item) {
547
+ state = refusedState('Item unavailable', refusal.code, null, reasonOf(refusal));
548
+ else if (!item) {
433
549
  const pick = this.pick;
434
550
  // A broken pick is a fault in the layer; its details stay on the map's `lastPick`.
435
551
  if (pick?.kind === 'broken')
436
- return unavailable(null, null);
437
- if (pick?.kind === 'miss')
438
- return html `<div class="panel">${head(html `<span part="state" data-state="empty">No item here</span>`, true)}</div>`;
439
- const state = stateOf(this.resolvedStore?.get('status'));
440
- if (state === 'detached')
441
- return html `<div class="panel">${head(renderState('detached', null), false)}</div>`;
442
- return html `<div class="panel">${head(html `<span part="state" data-state="empty">No item selected</span>`, false)}</div>`;
552
+ state = refusedState('Item unavailable', null, null, null);
553
+ else if (pick?.kind === 'miss')
554
+ state = html `<span part="state" data-state="empty">No item here</span>`;
555
+ else {
556
+ closable = false;
557
+ state = stateOf(this.resolvedStore?.get('status')) === 'detached' ? renderState('detached', null) : html `<span part="state" data-state="empty">No item selected</span>`;
558
+ }
443
559
  }
444
- const declared = meta?.declaredScalars ?? [];
445
- const { fields } = item.detail;
446
- const names = Object.keys(fields);
447
- const ordered = [...declared.map((c) => c.name).filter((n) => n in fields), ...names.filter((n) => !declared.some((c) => c.name === n))];
448
- const id = idString(item.id);
449
- const titleName = this.titleField && fields[this.titleField] !== undefined && fields[this.titleField] !== null ? this.titleField : null;
450
- const rest = this.namedFirst(ordered.filter((n) => n !== titleName));
451
- const copy = () => void navigator.clipboard?.writeText(id);
452
- if (this.compact)
453
- return this.compactBody(item, meta, titleName, rest, id);
560
+ const card = item && !refusal ? this.card(item, meta) : null;
561
+ // One template whatever the card shows, so the state's region is in the page before its words change.
454
562
  return html `<div class="panel">
455
- ${head(titleName
456
- ? html `<div part="headline" class="card-title" data-name=${titleName}><slot name=${`field-${titleName}`}><span part="value">${this.text(titleName, fields[titleName], declared.find((c) => c.name === titleName))}</span></slot></div>`
457
- : html `<div part="headline" class="card-title mono" data-name="mosaica_id">${id}</div>`, true)}
458
- <span part="state" data-state="shown"></span>
459
- ${this.views(item.detail.views, meta)}
460
- <div class="field">
461
- ${rest.map((name) => this.field(name, fields[name], declared.find((c) => c.name === name)))}
462
- ${titleName
463
- ? html `<div part="field" data-name="mosaica_id" style="display:contents"><span part="label" class="k">mosaica_id</span><span part="value" class="v mono">${id}</span></div>`
563
+ <div part="title" class="head">
564
+ <h2 class="sr-only">Item</h2>
565
+ <div class=${card ? 'said' : 'said alone'} role="status">${state}</div>
566
+ ${card?.headline ?? nothing}
567
+ ${closable
568
+ ? html `<slot name="actions"></slot><button part="close" type="button" aria-label="Close" @click=${() => emit(this, 'mosaica-close', { what: 'item' })}>${icon('close', 14)}</button>`
464
569
  : nothing}
465
570
  </div>
466
- ${this.scoped(item.detail.scoped)}
467
- <div class="row actions">
468
- ${this.openButton(id, fields, html `${icon('open', 14)}Open`)}
469
- <button part="copy" class="btn" type="button" @click=${copy}>Copy id</button>
470
- </div>
571
+ ${card?.body ?? nothing}
471
572
  </div>`;
472
573
  }
473
- /** The card under `compact`: headline and subtitle, three fields, and Show all beside Open. */
474
- compactBody(item, meta, titleName, rest, id) {
574
+ /** An item's headline, for the header row, and what the card shows under it: the full card's, or under `compact` the shorter one's. */
575
+ card(item, meta) {
475
576
  const declared = meta?.declaredScalars ?? [];
577
+ const column = (name) => declared.find((c) => c.name === name);
476
578
  const { fields } = item.detail;
477
- const subtitleName = this.subtitleField && this.subtitleField !== titleName && fields[this.subtitleField] !== undefined && fields[this.subtitleField] !== null ? this.subtitleField : null;
579
+ const names = Object.keys(fields);
580
+ const ordered = [...declared.map((c) => c.name).filter((n) => n in fields), ...names.filter((n) => !declared.some((c) => c.name === n))];
581
+ const id = idString(item.id);
582
+ const has = (name) => fields[name] !== undefined && fields[name] !== null;
583
+ const titleName = this.titleField && has(this.titleField) ? this.titleField : null;
584
+ const title = titleName ? this.text(titleName, fields[titleName], column(titleName)) : null;
585
+ const rest = this.namedFirst(ordered.filter((n) => n !== titleName));
586
+ const whole = !this.compact || this.expanded;
587
+ const h3 = titleName
588
+ ? html `<h3 part="headline" class=${whole ? 'card-title' : 'card-title clamp'} data-name=${titleName}><slot name=${`field-${titleName}`}><span part="value">${title}</span></slot></h3>`
589
+ : html `<h3 part="headline" class="card-title mono" data-name="mosaica_id">${id}</h3>`;
590
+ const idField = titleName
591
+ ? html `<div part="field" data-name="mosaica_id" style="display:contents"><dt part="label" class="k">mosaica_id</dt><dd part="value" class="v mono">${id}</dd></div>`
592
+ : nothing;
593
+ const field = (name) => this.field(name, fields[name], column(name), title);
594
+ const open = this.openButton(id, fields, this.compact ? nothing : icon('open', 14));
595
+ if (!this.compact) {
596
+ return {
597
+ headline: h3,
598
+ body: html `${this.views(item.detail.views, meta)}
599
+ <dl class="field">${rest.map(field)}${idField}</dl>
600
+ ${this.scoped(item.detail.scoped)}
601
+ <div class="row actions">
602
+ ${open}
603
+ <button part="copy" class="btn" type="button" @click=${() => void this.copy(id)}>Copy id</button>
604
+ <span class="sr-only" role="status">${this.copied}</span>
605
+ </div>`
606
+ };
607
+ }
608
+ const subtitleName = this.subtitleField && this.subtitleField !== titleName && has(this.subtitleField) ? this.subtitleField : null;
478
609
  const others = rest.filter((n) => n !== subtitleName);
479
610
  const total = others.length + (titleName ? 1 : 0);
480
- const shown = this.expanded ? others : this.cardFields ? this.named(others) : others.slice(0, 3);
481
- const headline = titleName
482
- ? html `<div part="headline" class="card-title" data-name=${titleName}><slot name=${`field-${titleName}`}><span part="value">${this.text(titleName, fields[titleName], declared.find((c) => c.name === titleName))}</span></slot></div>`
483
- : html `<div part="headline" class="card-title mono" data-name="mosaica_id">${id}</div>`;
484
- const showAll = total > shown.length || this.expanded
485
- ? html `<button part="show-all" class="more-link" type="button" aria-expanded=${this.expanded ? 'true' : 'false'} @click=${() => (this.expanded = !this.expanded)}>${this.expanded ? 'Show fewer' : `Show all ${total.toLocaleString('en-GB')} fields`}</button>`
611
+ const few = this.cardFields ? this.named(others) : others.slice(0, 3);
612
+ const more = total > few.length;
613
+ // Show all is there for the fields left out, and for any text the card cuts short.
614
+ const showAll = more || this.expanded || this.clipped.size > 0
615
+ ? html `<button part="show-all" class="more-link" type="button" aria-expanded=${this.expanded ? 'true' : 'false'} @click=${() => (this.expanded = !this.expanded)}>${this.expanded ? (more ? 'Show fewer' : 'Show less') : more ? `Show all ${total.toLocaleString('en-GB')} fields` : 'Show all'}</button>`
486
616
  : null;
487
- const open = this.openButton(id, fields, 'Open');
488
617
  const subtitle = subtitleName
489
- ? html `<div part="subtitle" data-name=${subtitleName}><slot name=${`field-${subtitleName}`}>${this.text(subtitleName, fields[subtitleName], declared.find((c) => c.name === subtitleName))}</slot></div>`
618
+ ? html `<div part="subtitle" class=${whole ? '' : 'clamp'} data-name=${subtitleName}><slot name=${`field-${subtitleName}`}>${this.text(subtitleName, fields[subtitleName], column(subtitleName))}</slot></div>`
490
619
  : nothing;
491
- return html `<div class="panel">
492
- <div part="title" class="head"><div class="headline">${headline}${subtitle}</div><slot name="actions"></slot><button part="close" type="button" aria-label="Close" @click=${() => emit(this, 'mosaica-close', { what: 'item' })}>${icon('close', 14)}</button></div>
493
- <span part="state" data-state="shown"></span>
494
- <div class="body">
495
- ${this.expanded ? this.views(item.detail.views, meta) : nothing}
496
- <div class="field">
497
- ${shown.map((name) => this.field(name, fields[name], declared.find((c) => c.name === name)))}
498
- ${this.expanded && titleName
499
- ? html `<div part="field" data-name="mosaica_id" style="display:contents"><span part="label" class="k">mosaica_id</span><span part="value" class="v mono">${id}</span></div>`
500
- : nothing}
620
+ return {
621
+ headline: html `<div class="headline">${h3}${subtitle}</div>`,
622
+ body: html `<div class="body">
623
+ ${this.expanded ? this.views(item.detail.views, meta) : nothing}
624
+ <dl class="field">${(this.expanded ? others : few).map(field)}${this.expanded ? idField : nothing}</dl>
625
+ ${this.expanded ? this.scoped(item.detail.scoped) : nothing}
501
626
  </div>
502
- ${this.expanded ? this.scoped(item.detail.scoped) : nothing}
503
- </div>
504
- ${showAll || open !== nothing ? html `<div class="foot">${showAll || html `<span></span>`}${open}</div>` : nothing}
505
- </div>`;
627
+ ${showAll || open !== nothing ? html `<div class="foot">${showAll || html `<span></span>`}${open}</div>` : nothing}`
628
+ };
506
629
  }
507
630
  /**
508
631
  * Open, where the host says what it does: a button that calls `open`, else a link to `open-url`
509
- * filled from the item, else nothing. Each fires `mosaica-open`.
632
+ * filled from the item, which opens in a new tab and says so to a screen reader, else nothing.
633
+ * Each fires `mosaica-open`. `mark` is drawn before the word.
510
634
  */
511
- openButton(id, fields, label) {
635
+ openButton(id, fields, mark) {
512
636
  const pressed = () => emit(this, 'mosaica-open', { id, fields });
513
637
  const open = this.open;
514
638
  if (open)
515
639
  return html `<button part="open" class="btn" type="button" @click=${() => {
516
640
  pressed();
517
641
  open({ id, fields });
518
- }}>${label}</button>`;
642
+ }}>${mark}Open</button>`;
519
643
  const href = this.openUrl ? openAddress(this.openUrl, { mosaica_id: id, ...fields }) : null;
520
644
  if (!href)
521
645
  return nothing;
522
646
  // A middle-click opens the link too, and is a press.
523
- return html `<a part="open" class="btn" href=${href} target="_blank" rel="noopener noreferrer" @click=${pressed} @auxclick=${(e) => e.button === 1 && pressed()}>${label}</a>`;
647
+ return html `<a part="open" class="btn" href=${href} target="_blank" rel="noopener noreferrer" @click=${pressed} @auxclick=${(e) => e.button === 1 && pressed()}>${mark}Open<span class="sr-only"> (opens in a new tab)</span></a>`;
524
648
  }
525
649
  /** The fields of `fields` that `card-fields` names, in its order. */
526
650
  named(fields) {
@@ -549,8 +673,8 @@ let MosaicaItemCard = (() => {
549
673
  if (positions.length === 0)
550
674
  return nothing;
551
675
  const current = this.resolvedStore?.get('view').id ?? '';
552
- return html `<span part="label" class="xs muted">In views</span>
553
- <div class="chips">
676
+ return html `<span part="label" class="xs muted" id="views">In views</span>
677
+ <div class="chips" role="group" aria-labelledby="views">
554
678
  ${positions.map((p) => {
555
679
  const view = meta?.views.find((v) => v.id === p.id) ?? null;
556
680
  const here = p.id === current;
@@ -586,30 +710,39 @@ let MosaicaItemCard = (() => {
586
710
  if (keys.length === 0)
587
711
  return nothing;
588
712
  return html `<div part="scoped">
589
- ${keys.map((key) => html `<div part="key" class="hd" data-key=${key}>${key}</div>
590
- <div class="field" data-key=${key}>
713
+ ${keys.map((key) => html `<h4 part="key" class="hd" data-key=${key}>${key}</h4>
714
+ <dl class="field" data-key=${key}>
591
715
  ${Object.keys(scoped)
592
716
  .filter((family) => key in (scoped[family] ?? {}))
593
- .map((family) => html `<div part="field" data-name=${family} data-key=${key} style="display:contents"><span part="label" class="k">${columnCaption(family)}</span><span part="value" class="v">${present(scoped[family][key], undefined)}</span></div>`)}
594
- </div>`)}
717
+ .map((family) => html `<div part="field" data-name=${family} data-key=${key} style="display:contents"><dt part="label" class="k">${columnCaption(family)}</dt><dd part="value" class="v">${present(scoped[family][key], undefined)}</dd></div>`)}
718
+ </dl>`)}
595
719
  </div>`;
596
720
  }
597
- /** One field, presented by its declared type; prose and images span the grid. */
598
- field(name, value, column) {
721
+ /**
722
+ * One field, presented by its declared type; prose and images span the grid. An image's
723
+ * alternative text is the item's `headline` where it has one, since its caption is the label
724
+ * above it. On the full card, prose a clamp cuts short has a button that shows it whole.
725
+ */
726
+ field(name, value, column, headline) {
599
727
  const caption = column ? columnCaption(name) : name;
600
728
  const image = this.imageOf(name, value);
601
729
  if (image) {
602
730
  const failed = () => (this.broken = new Set([...this.broken, image]));
603
731
  return html `<div part="field" data-name=${name} data-image>
604
- <span part="label" class="k">${caption}</span>
605
- <slot name=${`field-${name}`}><img part="image" src=${image} alt=${caption} loading="lazy" referrerpolicy="no-referrer" @error=${failed} /></slot>
732
+ <dt part="label" class="k">${caption}</dt>
733
+ <dd part="value" class="v"><slot name=${`field-${name}`}><img part="image" src=${image} alt=${headline ?? caption} loading="lazy" referrerpolicy="no-referrer" @error=${failed} /></slot></dd>
606
734
  </div>`;
607
735
  }
608
736
  const text = this.text(name, value, column);
609
737
  const prose = text.length > 60;
738
+ const whole = this.compact ? this.expanded : this.opened.has(name);
739
+ const toggle = () => (this.opened = new Set(whole ? [...this.opened].filter((n) => n !== name) : [...this.opened, name]));
610
740
  return html `<div part="field" data-name=${name} ?data-prose=${prose} style=${prose ? nothing : 'display:contents'}>
611
- <span part="label" class="k">${caption}</span>
612
- <slot name=${`field-${name}`}><span part="value" class="v" title=${prose ? text : nothing}>${text}</span></slot>
741
+ <dt part="label" class="k">${caption}</dt>
742
+ <dd part="value" class="v" title=${prose ? text : nothing}><slot name=${`field-${name}`}>${prose && !whole ? html `<span class="clamp">${text}</span>` : text}</slot></dd>
743
+ ${prose && !this.compact && (whole || this.clipped.has(name))
744
+ ? html `<dd><button part="more" class="more-link" type="button" aria-expanded=${whole ? 'true' : 'false'} aria-label=${`${whole ? 'Show less' : 'Show more'} of ${caption}`} @click=${toggle}>${whole ? 'Show less' : 'Show more'}</button></dd>`
745
+ : nothing}
613
746
  </div>`;
614
747
  }
615
748
  };
@@ -658,7 +791,7 @@ function present(value, column) {
658
791
  if (typeof value === 'number')
659
792
  return Number.isInteger(value) ? value.toLocaleString('en-GB') : String(value);
660
793
  if (typeof value === 'boolean')
661
- return value ? 'yes' : 'no';
794
+ return BOOL_WORDS[value ? 'true' : 'false'];
662
795
  return String(value);
663
796
  }
664
797
  attachContextRoot();
@@ -14,7 +14,8 @@ import { MosaicaElement } from './base.js';
14
14
  * @csspart label - The caption, the group's name.
15
15
  * @csspart entry - The row of buttons and select.
16
16
  * @csspart select - The select.
17
- * @csspart step - A previous or next button, with `data-direction` set to `prev` or `next`.
17
+ * @csspart step - A previous or next button, named with the group, with `data-direction` set to
18
+ * `prev` or `next`.
18
19
  */
19
20
  export declare class MosaicaKeyPicker extends MosaicaElement {
20
21
  static styles: import("lit").CSSResult[];
@@ -19,7 +19,8 @@ import { chrome, tokens } from './tokens.js';
19
19
  * @csspart label - The caption, the group's name.
20
20
  * @csspart entry - The row of buttons and select.
21
21
  * @csspart select - The select.
22
- * @csspart step - A previous or next button, with `data-direction` set to `prev` or `next`.
22
+ * @csspart step - A previous or next button, named with the group, with `data-direction` set to
23
+ * `prev` or `next`.
23
24
  */
24
25
  export class MosaicaKeyPicker extends MosaicaElement {
25
26
  static styles = [
@@ -107,11 +108,11 @@ export class MosaicaKeyPicker extends MosaicaElement {
107
108
  return html `<div part="field">
108
109
  <span part="label" class="muted">${heading}</span>
109
110
  <div part="entry" class="row">
110
- ${step('prev', previous, -1, 'Previous')}
111
+ ${step('prev', previous, -1, `Previous ${heading}`)}
111
112
  <select part="select" aria-label=${heading} @change=${(e) => switchView(this, s, meta, e.target.value)}>
112
113
  ${views.map((v) => html `<option value=${v.id} ?selected=${v.id === current.id}>${this.option(meta, v)}</option>`)}
113
114
  </select>
114
- ${step('next', next, 1, 'Next')}
115
+ ${step('next', next, 1, `Next ${heading}`)}
115
116
  </div>
116
117
  </div>`;
117
118
  }
@@ -3,7 +3,8 @@ import { MosaicaElement } from './base.js';
3
3
  /**
4
4
  * Put the layer picker beside a map when the bundle has annotation layers that viewers should be
5
5
  * able to turn on and off. A layer that others depend on, such as a clustering and its labels, has
6
- * one checkbox, so that the whole group is turned on together.
6
+ * one checkbox, so that the whole group is turned on together. The row's `title` names the layers
7
+ * it turns on, and a screen reader hears them as the checkbox's description.
7
8
  *
8
9
  * Filter layers draw nothing, so the picker names them in a note instead of giving them checkboxes.
9
10
  * Viewers filter by a filter layer from its card in `<mosaica-filter-panel>`, or browse it in
@@ -19,7 +20,8 @@ import { MosaicaElement } from './base.js';
19
20
  * @csspart refusal - The words "View refused", with `data-code`, in the refused state.
20
21
  * @csspart reason - The server's reason for a refusal, under "View refused".
21
22
  * @csspart entry - One layer that draws, with `data-layer`.
22
- * @csspart name - A layer's title, or its name if it has no title.
23
+ * @csspart name - A layer's title, or its name if it has no title, on one line unless
24
+ * `--mosaica-long-text` is `normal`.
23
25
  * @csspart note - The note naming the filter layers, such as "Venues can be used as a filter
24
26
  * only.", with `data-layers` listing their names.
25
27
  */