@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
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,14 +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.
56
+ *
57
+ * A field named in `image-fields` whose value is an `http:` or `https:` address, or a path
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
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.
51
62
  *
52
63
  * The Open button appears only when you set `open-url` or `open`. `open-url` is a link template
53
64
  * such as `https://www.geonames.org/{geonameid}`, in which each `{name}` is replaced by the item's
54
65
  * value for that field. Because the values are URL-encoded, a template cannot open an address that
55
66
  * a field already holds, so set `open` to a function for that. An item gets no Open button when it
56
- * has no value for a field the template names. A function in `open` is called with the item's
57
- * `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`.
58
69
  *
59
70
  * @summary A card showing an item's fields, with an optional Open button.
60
71
  * @tagname mosaica-item-card
@@ -69,22 +80,26 @@ import { chrome, tokens } from './tokens.js';
69
80
  * @csspart title - The header row: the headline or the state, and the close button.
70
81
  * @csspart close - The close button.
71
82
  * @csspart state - The state line, with `data-state`: `shown`, `empty` (no item, or a click that
72
- * found nothing), `refused` or `detached`.
83
+ * found nothing), `refused` or `detached`. It is inside a `role="status"` region.
73
84
  * @csspart refusal - The words "Item unavailable", with `data-code` set to the refusal's code if
74
85
  * the server refused the record. For a broken pick, the details are in the map's `lastPick`.
75
86
  * @csspart reason - The server's reason for refusing the record, under "Item unavailable".
76
- * @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.
77
88
  * @csspart subtitle - The `subtitle-field` value under the headline, when `compact` is set.
78
89
  * @csspart show-all - The "Show all N fields" button, when `compact` is set, with `aria-expanded`.
79
- * @csspart view-chip - One view the item is in, with `data-view` and `aria-current` on the current
80
- * 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.
81
93
  * @csspart field - One field, with `data-name`, and `data-prose` on a value over 60 characters.
82
- * @csspart label - A field's name, and the heading above the view chips.
83
- * @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`.
97
+ * @csspart image - A field's value drawn as an image, under `image-fields`.
84
98
  * @csspart scoped - The group-scoped values, grouped by key.
85
99
  * @csspart key - One key's heading among the group-scoped values, with `data-key`.
86
100
  * @csspart open - The Open button.
87
- * @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.
88
103
  */
89
104
  let MosaicaItemCard = (() => {
90
105
  let _classSuper = MosaicaElement;
@@ -112,6 +127,9 @@ let MosaicaItemCard = (() => {
112
127
  let _cardFields_decorators;
113
128
  let _cardFields_initializers = [];
114
129
  let _cardFields_extraInitializers = [];
130
+ let _imageFields_decorators;
131
+ let _imageFields_initializers = [];
132
+ let _imageFields_extraInitializers = [];
115
133
  let _openUrl_decorators;
116
134
  let _openUrl_initializers = [];
117
135
  let _openUrl_extraInitializers = [];
@@ -121,6 +139,18 @@ let MosaicaItemCard = (() => {
121
139
  let _expanded_decorators;
122
140
  let _expanded_initializers = [];
123
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 = [];
151
+ let _broken_decorators;
152
+ let _broken_initializers = [];
153
+ let _broken_extraInitializers = [];
124
154
  return class MosaicaItemCard extends _classSuper {
125
155
  static {
126
156
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
@@ -132,9 +162,14 @@ let MosaicaItemCard = (() => {
132
162
  _compact_decorators = [property({ type: Boolean, reflect: true })];
133
163
  _subtitleField_decorators = [property({ attribute: 'subtitle-field' })];
134
164
  _cardFields_decorators = [property({ attribute: 'card-fields' })];
165
+ _imageFields_decorators = [property({ attribute: 'image-fields' })];
135
166
  _openUrl_decorators = [property({ attribute: 'open-url' })];
136
167
  _open_decorators = [property({ attribute: false })];
137
168
  _expanded_decorators = [state()];
169
+ _opened_decorators = [state()];
170
+ _clipped_decorators = [state()];
171
+ _copied_decorators = [state()];
172
+ _broken_decorators = [state()];
138
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);
139
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);
140
175
  __esDecorate(this, null, _pick_decorators, { kind: "accessor", name: "pick", static: false, private: false, access: { has: obj => "pick" in obj, get: obj => obj.pick, set: (obj, value) => { obj.pick = value; } }, metadata: _metadata }, _pick_initializers, _pick_extraInitializers);
@@ -143,9 +178,14 @@ let MosaicaItemCard = (() => {
143
178
  __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);
144
179
  __esDecorate(this, null, _subtitleField_decorators, { kind: "accessor", name: "subtitleField", static: false, private: false, access: { has: obj => "subtitleField" in obj, get: obj => obj.subtitleField, set: (obj, value) => { obj.subtitleField = value; } }, metadata: _metadata }, _subtitleField_initializers, _subtitleField_extraInitializers);
145
180
  __esDecorate(this, null, _cardFields_decorators, { kind: "accessor", name: "cardFields", static: false, private: false, access: { has: obj => "cardFields" in obj, get: obj => obj.cardFields, set: (obj, value) => { obj.cardFields = value; } }, metadata: _metadata }, _cardFields_initializers, _cardFields_extraInitializers);
181
+ __esDecorate(this, null, _imageFields_decorators, { kind: "accessor", name: "imageFields", static: false, private: false, access: { has: obj => "imageFields" in obj, get: obj => obj.imageFields, set: (obj, value) => { obj.imageFields = value; } }, metadata: _metadata }, _imageFields_initializers, _imageFields_extraInitializers);
146
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);
147
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);
148
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);
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);
149
189
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
150
190
  }
151
191
  static styles = [
@@ -159,7 +199,7 @@ let MosaicaItemCard = (() => {
159
199
  display: flex;
160
200
  align-items: flex-start;
161
201
  justify-content: space-between;
162
- gap: 10px;
202
+ gap: 0;
163
203
  margin: 0 0 10px;
164
204
  font-size: 13px;
165
205
  font-weight: 400;
@@ -167,6 +207,11 @@ let MosaicaItemCard = (() => {
167
207
  text-transform: none;
168
208
  color: var(--_mosaica-ink);
169
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,
170
215
  .head [part='headline'],
171
216
  .head .headline {
172
217
  flex: 1 1 auto;
@@ -177,6 +222,13 @@ let MosaicaItemCard = (() => {
177
222
  flex-direction: column;
178
223
  gap: 3px;
179
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
+ }
180
232
  [part='subtitle'] {
181
233
  font-size: 12px;
182
234
  color: var(--_mosaica-ink-2);
@@ -187,20 +239,41 @@ let MosaicaItemCard = (() => {
187
239
  :host([compact]) .card-title {
188
240
  font-size: 14px;
189
241
  }
190
- /* The compact card keeps its headline to three lines and its subtitle to two; Open shows them whole. */
191
- :host([compact]) .card-title,
192
- :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 {
193
245
  display: -webkit-box;
194
- -webkit-line-clamp: 3;
195
- line-clamp: 3;
246
+ -webkit-line-clamp: 6;
247
+ line-clamp: 6;
196
248
  -webkit-box-orient: vertical;
197
249
  overflow: hidden;
198
250
  overflow-wrap: anywhere;
199
251
  }
200
- :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 {
201
258
  -webkit-line-clamp: 2;
202
259
  line-clamp: 2;
203
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
+ }
204
277
  :host([compact]) .foot {
205
278
  display: flex;
206
279
  align-items: center;
@@ -221,20 +294,22 @@ let MosaicaItemCard = (() => {
221
294
  }
222
295
  ::slotted([slot='actions']) {
223
296
  flex: none;
297
+ margin-left: 10px;
224
298
  }
225
299
  .head [part='state'] {
226
300
  min-height: 24px;
227
301
  }
228
- [part='field'][data-prose] {
302
+ [part='field'][data-prose],
303
+ [part='field'][data-image] {
229
304
  grid-column: 1 / -1;
230
305
  }
231
- [part='field'][data-prose] [part='value'] {
232
- display: -webkit-box;
233
- -webkit-line-clamp: 6;
234
- line-clamp: 6;
235
- -webkit-box-orient: vertical;
236
- overflow: hidden;
237
- overflow-wrap: anywhere;
306
+ [part='image'] {
307
+ display: block;
308
+ max-width: 100%;
309
+ max-height: 240px;
310
+ margin-top: 4px;
311
+ border-radius: 6px;
312
+ object-fit: contain;
238
313
  }
239
314
  .field .v.mono {
240
315
  font-size: 12px;
@@ -249,7 +324,7 @@ let MosaicaItemCard = (() => {
249
324
  flex: none;
250
325
  width: 24px;
251
326
  height: 24px;
252
- margin: -2px -4px 0 0;
327
+ margin: -2px -4px 0 10px;
253
328
  display: grid;
254
329
  place-items: center;
255
330
  border-radius: 5px;
@@ -264,9 +339,11 @@ let MosaicaItemCard = (() => {
264
339
  gap: 6px;
265
340
  margin: 4px 0 10px;
266
341
  }
342
+ /* The bar marks the current view by more than its colour, and takes no room. */
267
343
  [part='view-chip'][aria-current='true'] {
268
344
  background: var(--_mosaica-accent);
269
345
  color: var(--_mosaica-accent-ink);
346
+ box-shadow: inset 0 -2px 0 currentColor;
270
347
  }
271
348
  [part='scoped'] {
272
349
  margin-top: 12px;
@@ -317,7 +394,14 @@ let MosaicaItemCard = (() => {
317
394
  */
318
395
  get cardFields() { return this.#cardFields_accessor_storage; }
319
396
  set cardFields(value) { this.#cardFields_accessor_storage = value; }
320
- #openUrl_accessor_storage = (__runInitializers(this, _cardFields_extraInitializers), __runInitializers(this, _openUrl_initializers, ''));
397
+ #imageFields_accessor_storage = (__runInitializers(this, _cardFields_extraInitializers), __runInitializers(this, _imageFields_initializers, ''));
398
+ /**
399
+ * The fields whose value is the address of an image, separated by spaces or commas. Each is drawn
400
+ * as its image rather than as text.
401
+ */
402
+ get imageFields() { return this.#imageFields_accessor_storage; }
403
+ set imageFields(value) { this.#imageFields_accessor_storage = value; }
404
+ #openUrl_accessor_storage = (__runInitializers(this, _imageFields_extraInitializers), __runInitializers(this, _openUrl_initializers, ''));
321
405
  /**
322
406
  * The link template for Open, with `{name}` for the value of the field `name` and `{mosaica_id}`
323
407
  * for the item's id. Without it or `open`, there is no Open button.
@@ -333,6 +417,25 @@ let MosaicaItemCard = (() => {
333
417
  get expanded() { return this.#expanded_accessor_storage; }
334
418
  set expanded(value) { this.#expanded_accessor_storage = value; }
335
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);
433
+ #broken_accessor_storage = __runInitializers(this, _broken_initializers, new Set());
434
+ /** The image addresses that failed to load, shown as text. @internal */
435
+ get broken() { return this.#broken_accessor_storage; }
436
+ set broken(value) { this.#broken_accessor_storage = value; }
437
+ /** `image-fields` as a list, kept in step by `willUpdate`. */
438
+ imageNames = (__runInitializers(this, _broken_extraInitializers), []);
336
439
  /** A category value's title as the store holds it, else from the legend or a suggestion page, else its key. */
337
440
  titleOf(field, key) {
338
441
  return this.resolvedStore?.heldTitle(field, key) ?? keyTitle(this.resolvedStore, field, key);
@@ -347,11 +450,16 @@ let MosaicaItemCard = (() => {
347
450
  */
348
451
  willUpdate(changed) {
349
452
  super.willUpdate(changed);
453
+ if (changed.has('imageFields'))
454
+ this.imageNames = listed(this.imageFields);
350
455
  const { item, meta } = this.shown;
351
456
  const id = item?.id ?? null;
352
457
  if (id !== this.expandedFor) {
353
458
  this.expandedFor = id;
354
459
  this.expanded = false;
460
+ this.opened = new Set();
461
+ this.clipped = new Set();
462
+ this.copied = '';
355
463
  }
356
464
  const store = this.resolvedStore;
357
465
  if (!store || !store.get('meta') || !meta || !this.isConnected)
@@ -370,6 +478,55 @@ let MosaicaItemCard = (() => {
370
478
  });
371
479
  }
372
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
+ }
373
530
  get shown() {
374
531
  // A card fed by property still takes the schema from an adopted store: declaration order and
375
532
  // view names and frames belong to the bundle.
@@ -383,109 +540,123 @@ let MosaicaItemCard = (() => {
383
540
  }
384
541
  render() {
385
542
  const { item, refusal, meta } = this.shown;
386
- 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>`;
387
- // The header row: what the card shows, and the close button where there is something to close.
388
- const head = (content, closable) => html `<div part="title" class="head">${content}${closable ? close : nothing}</div>`;
389
- 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>`;
390
546
  if (refusal)
391
- return unavailable(refusal.code, reasonOf(refusal));
392
- if (!item) {
547
+ state = refusedState('Item unavailable', refusal.code, null, reasonOf(refusal));
548
+ else if (!item) {
393
549
  const pick = this.pick;
394
550
  // A broken pick is a fault in the layer; its details stay on the map's `lastPick`.
395
551
  if (pick?.kind === 'broken')
396
- return unavailable(null, null);
397
- if (pick?.kind === 'miss')
398
- return html `<div class="panel">${head(html `<span part="state" data-state="empty">No item here</span>`, true)}</div>`;
399
- const state = stateOf(this.resolvedStore?.get('status'));
400
- if (state === 'detached')
401
- return html `<div class="panel">${head(renderState('detached', null), false)}</div>`;
402
- 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
+ }
403
559
  }
404
- const declared = meta?.declaredScalars ?? [];
405
- const { fields } = item.detail;
406
- const names = Object.keys(fields);
407
- const ordered = [...declared.map((c) => c.name).filter((n) => n in fields), ...names.filter((n) => !declared.some((c) => c.name === n))];
408
- const id = idString(item.id);
409
- const titleName = this.titleField && fields[this.titleField] !== undefined && fields[this.titleField] !== null ? this.titleField : null;
410
- const rest = this.namedFirst(ordered.filter((n) => n !== titleName));
411
- const copy = () => void navigator.clipboard?.writeText(id);
412
- if (this.compact)
413
- 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.
414
562
  return html `<div class="panel">
415
- ${head(titleName
416
- ? 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>`
417
- : html `<div part="headline" class="card-title mono" data-name="mosaica_id">${id}</div>`, true)}
418
- <span part="state" data-state="shown"></span>
419
- ${this.views(item.detail.views, meta)}
420
- <div class="field">
421
- ${rest.map((name) => this.field(name, fields[name], declared.find((c) => c.name === name)))}
422
- ${titleName
423
- ? 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>`
424
569
  : nothing}
425
570
  </div>
426
- ${this.scoped(item.detail.scoped)}
427
- <div class="row actions">
428
- ${this.openButton(id, fields, html `${icon('open', 14)}Open`)}
429
- <button part="copy" class="btn" type="button" @click=${copy}>Copy id</button>
430
- </div>
571
+ ${card?.body ?? nothing}
431
572
  </div>`;
432
573
  }
433
- /** The card under `compact`: headline and subtitle, three fields, and Show all beside Open. */
434
- 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) {
435
576
  const declared = meta?.declaredScalars ?? [];
577
+ const column = (name) => declared.find((c) => c.name === name);
436
578
  const { fields } = item.detail;
437
- 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;
438
609
  const others = rest.filter((n) => n !== subtitleName);
439
610
  const total = others.length + (titleName ? 1 : 0);
440
- const shown = this.expanded ? others : this.cardFields ? this.named(others) : others.slice(0, 3);
441
- const headline = titleName
442
- ? 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>`
443
- : html `<div part="headline" class="card-title mono" data-name="mosaica_id">${id}</div>`;
444
- const showAll = total > shown.length || this.expanded
445
- ? 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>`
446
616
  : null;
447
- const open = this.openButton(id, fields, 'Open');
448
617
  const subtitle = subtitleName
449
- ? 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>`
450
619
  : nothing;
451
- return html `<div class="panel">
452
- <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>
453
- <span part="state" data-state="shown"></span>
454
- <div class="body">
455
- ${this.expanded ? this.views(item.detail.views, meta) : nothing}
456
- <div class="field">
457
- ${shown.map((name) => this.field(name, fields[name], declared.find((c) => c.name === name)))}
458
- ${this.expanded && titleName
459
- ? 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>`
460
- : 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}
461
626
  </div>
462
- ${this.expanded ? this.scoped(item.detail.scoped) : nothing}
463
- </div>
464
- ${showAll || open !== nothing ? html `<div class="foot">${showAll || html `<span></span>`}${open}</div>` : nothing}
465
- </div>`;
627
+ ${showAll || open !== nothing ? html `<div class="foot">${showAll || html `<span></span>`}${open}</div>` : nothing}`
628
+ };
466
629
  }
467
630
  /**
468
631
  * Open, where the host says what it does: a button that calls `open`, else a link to `open-url`
469
- * 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.
470
634
  */
471
- openButton(id, fields, label) {
635
+ openButton(id, fields, mark) {
472
636
  const pressed = () => emit(this, 'mosaica-open', { id, fields });
473
637
  const open = this.open;
474
638
  if (open)
475
639
  return html `<button part="open" class="btn" type="button" @click=${() => {
476
640
  pressed();
477
641
  open({ id, fields });
478
- }}>${label}</button>`;
642
+ }}>${mark}Open</button>`;
479
643
  const href = this.openUrl ? openAddress(this.openUrl, { mosaica_id: id, ...fields }) : null;
480
644
  if (!href)
481
645
  return nothing;
482
646
  // A middle-click opens the link too, and is a press.
483
- 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>`;
484
648
  }
485
649
  /** The fields of `fields` that `card-fields` names, in its order. */
486
650
  named(fields) {
487
- const names = this.cardFields.split(/[\s,]+/);
488
- return names.filter((n, i) => n && fields.includes(n) && names.indexOf(n) === i);
651
+ const names = listed(this.cardFields);
652
+ return names.filter((n, i) => fields.includes(n) && names.indexOf(n) === i);
653
+ }
654
+ /** The address of the image a field's value names, where `image-fields` lists the field. */
655
+ imageOf(name, value) {
656
+ if (typeof value !== 'string' || !this.imageNames.includes(name))
657
+ return null;
658
+ const address = webAddress(value);
659
+ return address && !this.broken.has(address) ? address : null;
489
660
  }
490
661
  /** `fields` with those `card-fields` names first, in its order. */
491
662
  namedFirst(fields) {
@@ -502,8 +673,8 @@ let MosaicaItemCard = (() => {
502
673
  if (positions.length === 0)
503
674
  return nothing;
504
675
  const current = this.resolvedStore?.get('view').id ?? '';
505
- return html `<span part="label" class="xs muted">In views</span>
506
- <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">
507
678
  ${positions.map((p) => {
508
679
  const view = meta?.views.find((v) => v.id === p.id) ?? null;
509
680
  const here = p.id === current;
@@ -539,21 +710,39 @@ let MosaicaItemCard = (() => {
539
710
  if (keys.length === 0)
540
711
  return nothing;
541
712
  return html `<div part="scoped">
542
- ${keys.map((key) => html `<div part="key" class="hd" data-key=${key}>${key}</div>
543
- <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}>
544
715
  ${Object.keys(scoped)
545
716
  .filter((family) => key in (scoped[family] ?? {}))
546
- .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>`)}
547
- </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>`)}
548
719
  </div>`;
549
720
  }
550
- /** One field, presented by its declared type; prose spans the grid. */
551
- 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) {
727
+ const caption = column ? columnCaption(name) : name;
728
+ const image = this.imageOf(name, value);
729
+ if (image) {
730
+ const failed = () => (this.broken = new Set([...this.broken, image]));
731
+ return html `<div part="field" data-name=${name} data-image>
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>
734
+ </div>`;
735
+ }
552
736
  const text = this.text(name, value, column);
553
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]));
554
740
  return html `<div part="field" data-name=${name} ?data-prose=${prose} style=${prose ? nothing : 'display:contents'}>
555
- <span part="label" class="k">${column ? columnCaption(name) : name}</span>
556
- <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}
557
746
  </div>`;
558
747
  }
559
748
  };
@@ -573,15 +762,26 @@ function openAddress(template, values) {
573
762
  missing = true;
574
763
  return encodeURIComponent(String(value));
575
764
  });
576
- if (missing)
577
- return null;
578
- const url = new URL(filled, document.baseURI);
765
+ return missing ? null : webAddress(filled);
766
+ }
767
+ catch {
768
+ return null;
769
+ }
770
+ }
771
+ /** `text` resolved against the page, where it is an `http:` or `https:` address. */
772
+ function webAddress(text) {
773
+ try {
774
+ const url = new URL(text, document.baseURI);
579
775
  return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
580
776
  }
581
777
  catch {
582
778
  return null;
583
779
  }
584
780
  }
781
+ /** The names in a list separated by spaces or commas. */
782
+ function listed(names) {
783
+ return names.split(/[\s,]+/).filter(Boolean);
784
+ }
585
785
  /** A value as text, by the column's declared type. */
586
786
  function present(value, column) {
587
787
  if (value === null || value === undefined)
@@ -591,7 +791,7 @@ function present(value, column) {
591
791
  if (typeof value === 'number')
592
792
  return Number.isInteger(value) ? value.toLocaleString('en-GB') : String(value);
593
793
  if (typeof value === 'boolean')
594
- return value ? 'yes' : 'no';
794
+ return BOOL_WORDS[value ? 'true' : 'false'];
595
795
  return String(value);
596
796
  }
597
797
  attachContextRoot();