@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/selection.js CHANGED
@@ -34,18 +34,19 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
34
34
  };
35
35
  import { css, html, nothing } from 'lit';
36
36
  import { property } from 'lit/decorators.js';
37
+ import { formatMasked } from '@mosaicajs/client';
37
38
  import { isBusy, isUnanswered } from '@mosaicajs/client/internal';
38
39
  import { MosaicaElement, emit, idString, shapeDetail } from './base.js';
39
40
  import { attachContextRoot, defineOnce } from './define.js';
40
41
  import { icon } from './icons.js';
41
- import { reasonOf, refusedState, renderState, stateOf } from './states.js';
42
+ import { ActionFocus, UNSAID, liveLine, reasonOf, refusedLine, stateLine, stateOf } from './states.js';
42
43
  import { chrome, tokens } from './tokens.js';
43
44
  import './count.js';
44
45
  /**
45
46
  * Use the selection card beside a map so that viewers can see what is inside the region they have
46
47
  * drawn. It reads the store's selected region, or the `region` you set. It counts the items inside
47
48
  * that the filters match, the items the viewer may see there and the marks drawn there. The drawn
48
- * marks are listed below the counts, and clicking one picks it.
49
+ * marks are listed below the counts, each a button that picks it.
49
50
  *
50
51
  * The region acts as a filter, which means the map and every other count narrow to it once it
51
52
  * settles. Outside flips it to everything outside the shape, and Clear removes it. The counts are
@@ -53,6 +54,12 @@ import './count.js';
53
54
  * says when that is so. Export and Save as artifact are shown disabled because they are not
54
55
  * available yet.
55
56
  *
57
+ * Screen readers announce the state line as it changes: that the selection is being counted, a
58
+ * refusal, and, once, when the query has finished loading, the matched and visible counts with
59
+ * whether they are exact for the shape. The counts and that verdict are read out and not drawn on
60
+ * the line, since the figures are below it. With `quiet`, the card shows the same and announces
61
+ * nothing.
62
+ *
56
63
  * @summary A card showing the counts and marks inside the selected region.
57
64
  * @tagname mosaica-selection
58
65
  * @category Elements
@@ -71,7 +78,7 @@ import './count.js';
71
78
  * @csspart count-visible - The `<mosaica-count>` of items the viewer may see inside (or outside).
72
79
  * @csspart label - The heading above the list.
73
80
  * @csspart items - The list of drawn marks inside.
74
- * @csspart item - One drawn mark, by `mosaica_id`.
81
+ * @csspart item - One drawn mark's row, holding a button named by its `mosaica_id`.
75
82
  * @csspart actions - The action buttons.
76
83
  * @csspart action - One action button.
77
84
  */
@@ -80,11 +87,16 @@ let MosaicaSelection = (() => {
80
87
  let _region_decorators;
81
88
  let _region_initializers = [];
82
89
  let _region_extraInitializers = [];
90
+ let _quiet_decorators;
91
+ let _quiet_initializers = [];
92
+ let _quiet_extraInitializers = [];
83
93
  return class MosaicaSelection extends _classSuper {
84
94
  static {
85
95
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
86
96
  _region_decorators = [property({ attribute: false })];
97
+ _quiet_decorators = [property({ type: Boolean, reflect: true })];
87
98
  __esDecorate(this, null, _region_decorators, { kind: "accessor", name: "region", static: false, private: false, access: { has: obj => "region" in obj, get: obj => obj.region, set: (obj, value) => { obj.region = value; } }, metadata: _metadata }, _region_initializers, _region_extraInitializers);
99
+ __esDecorate(this, null, _quiet_decorators, { kind: "accessor", name: "quiet", static: false, private: false, access: { has: obj => "quiet" in obj, get: obj => obj.quiet, set: (obj, value) => { obj.quiet = value; } }, metadata: _metadata }, _quiet_initializers, _quiet_extraInitializers);
88
100
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
89
101
  }
90
102
  static styles = [
@@ -95,7 +107,10 @@ let MosaicaSelection = (() => {
95
107
  display: block;
96
108
  }
97
109
  [part='counts'] {
98
- margin-bottom: 4px;
110
+ margin: 0 0 4px;
111
+ }
112
+ [part='counts'] dd {
113
+ margin: 0;
99
114
  }
100
115
  [part='counts'] mosaica-count::part(count) {
101
116
  font-weight: 500;
@@ -115,8 +130,26 @@ let MosaicaSelection = (() => {
115
130
  }
116
131
  [part='item'] {
117
132
  min-height: 26px;
133
+ }
134
+ /* The row is its button, which fills it and takes the row's own padding. */
135
+ .list > [part='item'] {
136
+ padding: 0;
137
+ }
138
+ [part='item'] button {
139
+ flex: 1 1 0;
140
+ min-width: 0;
141
+ align-self: stretch;
142
+ padding: 0 6px;
143
+ border-radius: var(--_mosaica-radius-control);
118
144
  font-family: var(--_mosaica-font-mono);
119
145
  font-size: 12px;
146
+ text-align: left;
147
+ white-space: nowrap;
148
+ overflow: hidden;
149
+ text-overflow: ellipsis;
150
+ }
151
+ [part='item'] button:focus-visible {
152
+ outline-offset: -2px;
120
153
  }
121
154
  [part='actions'] {
122
155
  display: flex;
@@ -130,74 +163,107 @@ let MosaicaSelection = (() => {
130
163
  /** A region to show instead of the store's `region` projection. */
131
164
  get region() { return this.#region_accessor_storage; }
132
165
  set region(value) { this.#region_accessor_storage = value; }
166
+ #quiet_accessor_storage = (__runInitializers(this, _region_extraInitializers), __runInitializers(this, _quiet_initializers, false));
167
+ /**
168
+ * Announces nothing: the state line and the counts are still shown, and none is put in a live
169
+ * region. Set it when another element on the page says the same things.
170
+ */
171
+ get quiet() { return this.#quiet_accessor_storage; }
172
+ set quiet(value) { this.#quiet_accessor_storage = value; }
133
173
  get shown() {
134
174
  return this.region ?? this.resolvedStore?.get('region') ?? null;
135
175
  }
136
- render() {
137
- const r = this.shown;
138
- const heading = (shape = '') => html `<h2 part="title">Selection<span class="summary">${shape}</span></h2>`;
139
- if (!r)
140
- return html `<div class="panel">${heading()}<span part="state" data-state="detached"></span></div>`;
141
- const status = this.resolvedStore?.get('status') ?? null;
142
- const stale = status?.stale ?? false;
143
- const state = r.status === 'shown' ? (stale ? 'stale' : 'shown') : r.status;
144
- const kv = (label, count) => html `<div class="k">${label}</div><div class="v">${count}</div>`;
145
- const counts = r.status === 'shown'
146
- ? html `<div part="counts" class="kv">
147
- ${kv('Shown inside', html `<mosaica-count part="count-served" .count=${r.served} .stale=${stale} figure="shown"></mosaica-count>`)}
148
- ${kv(r.shape.outside ? 'Matched outside' : 'Matched inside', html `<mosaica-count part="count-matched" .masked=${r.matched} .stale=${stale}></mosaica-count>`)}
149
- ${r.visible ? kv(r.shape.outside ? 'Visible outside' : 'Visible inside', html `<mosaica-count part="count-visible" .masked=${r.visible} .stale=${stale}></mosaica-count>`) : nothing}
150
- </div>`
151
- : nothing;
176
+ actionFocus = (__runInitializers(this, _quiet_extraInitializers), new ActionFocus());
177
+ willUpdate(changed) {
178
+ super.willUpdate(changed);
179
+ this.actionFocus.note(this.renderRoot);
180
+ }
181
+ updated() {
182
+ this.actionFocus.keep(this.renderRoot.querySelector('[part="state"]'));
183
+ }
184
+ /**
185
+ * The state and its line for a region: what the server said of its count, or how the count
186
+ * stands. Once it is counted, `verdict` says whether the counts are exact for the shape.
187
+ */
188
+ stateOfRegion(r, status) {
152
189
  // A server that cannot be reached, did not answer or is too busy is named as the status strip
153
190
  // names it, since nothing about the selection was refused.
154
191
  const unreachable = stateOf(status) === 'retrying' && status?.unreachable === true;
155
192
  const unanswered = (r.status === 'refused' && isUnanswered(r.refusal)) || stateOf(status) === 'unanswered';
193
+ if (r.status !== 'shown' && (unreachable || unanswered)) {
194
+ const state = unreachable ? 'retrying' : 'unanswered';
195
+ return { state, line: stateLine(state, status) };
196
+ }
156
197
  // Busy only where the selection's own count was shed, so the code shown is its own.
157
- const busy = r.status === 'refused' && isBusy(r.refusal);
158
- const stateRegion = r.status !== 'shown' && (unreachable || unanswered)
159
- ? renderState(unreachable ? 'retrying' : 'unanswered', status)
160
- : busy && status
161
- ? renderState('busy', { ...status, refusal: r.refusal }, { onRetry: () => this.resolvedStore?.refresh() })
162
- : r.status === 'loading'
163
- ? html `<span part="state" data-state="loading"><span class="dot quiet"></span>Counting<span class="skel" aria-hidden="true"></span></span>`
164
- : r.status === 'refused'
165
- ? refusedState('Selection refused', r.refusal?.code, null, reasonOf(r.refusal))
166
- : html `<span part="state" data-state=${state} title=${r.verdict === null ? 'Not counted yet' : r.verdict.exact ? 'Exact for the shape' : 'Approximate: counted over cells around the shape'}>${stale ? html `<span class="dot warn"></span>Data updated` : nothing}</span>`;
167
- const ids = Array.from(r.held.ids, idString);
168
- const waiting = (label, reason) => html `<button part="action" class="btn off" type="button" disabled title=${reason}>${label}</button>`;
169
- return html `<div class="panel">${heading(r.shape.outside ? `outside ${r.shape.kind}` : r.shape.kind)}${stateRegion}${counts}
170
- ${ids.length > 0
171
- ? html `<div part="label" class="xs muted items-label">Shown items</div>
172
- <ul part="items" class="list" aria-label="held marks inside the selection">
173
- ${ids.map((id) => html `<li part="item" class="item" tabindex="0" role="button"
174
- @click=${() => void this.resolvedStore?.pick(BigInt(id))}
175
- @keydown=${(e) => {
176
- if (e.key === 'Enter' || e.key === ' ')
177
- void this.resolvedStore?.pick(BigInt(id));
178
- }}><span class="name">${id}</span></li>`)}
198
+ if (r.status === 'refused' && isBusy(r.refusal) && status)
199
+ return { state: 'busy', line: stateLine('busy', { ...status, refusal: r.refusal }, { onRetry: () => this.resolvedStore?.refresh() }) };
200
+ // Counting until the query has finished loading. The figures a frame gives first are an
201
+ // estimate, shown below as they stand with no line above them; the line goes on saying
202
+ // Counting, undrawn, from the same element, so the counts that follow are said once.
203
+ const estimate = r.status === 'shown' && status?.querying === true;
204
+ const counting = html `<span class=${estimate ? 'sr-only' : nothing}>Counting</span>`;
205
+ if (r.status === 'loading')
206
+ return { state: 'loading', line: { dot: 'dot quiet', words: counting, action: nothing } };
207
+ if (r.status === 'refused')
208
+ return { state: 'refused', line: refusedLine('Selection refused', r.refusal?.code, null, reasonOf(r.refusal)) };
209
+ const verdict = r.verdict === null ? 'Not counted yet' : r.verdict.exact ? 'Exact for the shape' : 'Approximate: counted over cells around the shape';
210
+ if (status?.stale)
211
+ return { state: 'stale', line: { dot: 'dot warn', words: 'Data updated', action: nothing }, verdict };
212
+ if (estimate)
213
+ return { state: 'shown', line: { dot: null, words: counting, action: nothing }, verdict };
214
+ // The counts in words and whether they are exact for the shape: heard as they arrive and not
215
+ // drawn, since the figures are below and the verdict is the line's tooltip.
216
+ const side = r.shape.outside ? 'outside' : 'inside';
217
+ const counted = `${formatMasked(r.matched)} matched ${side}${r.visible ? `, ${formatMasked(r.visible)} visible ${side}` : ''}.`;
218
+ return { state: 'shown', line: { dot: null, words: html `<span class="sr-only">${this.quiet ? nothing : counted} ${verdict}</span>`, action: nothing }, verdict };
219
+ }
220
+ render() {
221
+ const r = this.shown;
222
+ const status = this.resolvedStore?.get('status') ?? null;
223
+ const stale = status?.stale ?? false;
224
+ const { state, line, verdict } = r ? this.stateOfRegion(r, status) : { state: 'detached', line: stateLine('detached', null) };
225
+ const pair = (label, count) => html `<dt class="k">${label}</dt><dd class="v">${count}</dd>`;
226
+ const counts = r?.status === 'shown'
227
+ ? html `<dl part="counts" class="kv">
228
+ ${pair('Shown inside', html `<mosaica-count part="count-served" .count=${r.served} .stale=${stale} figure="shown"></mosaica-count>`)}
229
+ ${pair(r.shape.outside ? 'Matched outside' : 'Matched inside', html `<mosaica-count part="count-matched" .masked=${r.matched} .stale=${stale}></mosaica-count>`)}
230
+ ${r.visible ? pair(r.shape.outside ? 'Visible outside' : 'Visible inside', html `<mosaica-count part="count-visible" .masked=${r.visible} .stale=${stale}></mosaica-count>`) : nothing}
231
+ </dl>`
232
+ : nothing;
233
+ const ids = r ? Array.from(r.held.ids, idString) : [];
234
+ const unavailable = 'Not available yet';
235
+ const waiting = (label) => html `<button part="action" class="btn off" type="button" disabled title=${unavailable} aria-describedby="unavailable">${label}</button>`;
236
+ // One template whether or not there is a region, so the live element inside the state line is
237
+ // there before its words are. The state's button sits beside it, and the line takes the focus
238
+ // when the button goes.
239
+ return html `<div class="panel">
240
+ <h2 part="title">Selection <span class="summary">${r ? (r.shape.outside ? `outside ${r.shape.kind}` : r.shape.kind) : nothing}</span></h2>
241
+ <span part="state" data-state=${state} tabindex="-1" title=${verdict ?? nothing}>${liveLine(line, this.quiet ? UNSAID : null, state === 'loading' ? html `<span class="skel" aria-hidden="true"></span>` : nothing)}</span>
242
+ ${counts}
243
+ ${r && ids.length > 0
244
+ ? html `<div part="label" id="items-label" class="xs muted items-label">Shown items</div>
245
+ <ul part="items" class="list" aria-labelledby="items-label">
246
+ ${ids.map((id) => html `<li part="item" class="item"><button type="button" @click=${() => void this.resolvedStore?.pick(BigInt(id))}>${id}</button></li>`)}
179
247
  ${r.held.count > ids.length ? html `<li class="muted sm">${(r.held.count - ids.length).toLocaleString('en-GB')} more not listed</li>` : nothing}
180
248
  </ul>`
181
249
  : nothing}
182
- <div part="actions">
183
- <button part="action" class="btn" type="button" @click=${() => {
184
- this.resolvedStore?.select(null);
185
- emit(this, 'mosaica-selectchange', { shape: null });
186
- }}>${icon('close', 13)}Clear</button>
187
- <button part="action" class="btn" type="button" title=${r.shape.outside ? 'Filter to the inside of the shape' : 'Filter to the outside of the shape'} @click=${() => {
188
- const next = { ...r.shape, outside: !r.shape.outside };
189
- this.resolvedStore?.select(next);
190
- emit(this, 'mosaica-selectchange', { shape: shapeDetail(next), status: 'loading' });
191
- }}>${icon(r.shape.outside ? 'filter' : 'outside', 13)}${r.shape.outside ? 'Inside' : 'Outside'}</button>
192
- ${waiting('Export', 'Not available yet')}
193
- ${waiting('Save as artifact', 'Not available yet')}
194
- </div>
250
+ ${r
251
+ ? html `<div part="actions">
252
+ <button part="action" class="btn" type="button" @click=${() => {
253
+ this.resolvedStore?.select(null);
254
+ emit(this, 'mosaica-selectchange', { shape: null });
255
+ }}>${icon('close', 13)}Clear</button>
256
+ <button part="action" class="btn" type="button" title=${r.shape.outside ? 'Filter to the inside of the shape' : 'Filter to the outside of the shape'} @click=${() => {
257
+ const next = { ...r.shape, outside: !r.shape.outside };
258
+ this.resolvedStore?.select(next);
259
+ emit(this, 'mosaica-selectchange', { shape: shapeDetail(next), status: 'loading' });
260
+ }}>${icon(r.shape.outside ? 'filter' : 'outside', 13)}${r.shape.outside ? 'Inside' : 'Outside'}</button>
261
+ ${waiting('Export')}${waiting('Save as artifact')}
262
+ <span id="unavailable" class="sr-only">${unavailable}</span>
263
+ </div>`
264
+ : nothing}
195
265
  </div>`;
196
266
  }
197
- constructor() {
198
- super(...arguments);
199
- __runInitializers(this, _region_extraInitializers);
200
- }
201
267
  };
202
268
  })();
203
269
  export { MosaicaSelection };
package/dist/states.d.ts CHANGED
@@ -47,6 +47,51 @@ export declare function refusalText(words: string, code: string | null | undefin
47
47
  * server's, such as an error from a request that reached no server.
48
48
  */
49
49
  export declare function reasonOf(refusal: Refusal | null | undefined): string | null;
50
+ /**
51
+ * What a state's line is made of: the class of the dot before its words, the words, and the button
52
+ * the state offers. An element whose state is a live region puts the words inside it and the
53
+ * button beside it, so the button's name is not read out as part of the state.
54
+ */
55
+ export type StateLine = {
56
+ dot: string | null;
57
+ words: TemplateResult | string | typeof nothing;
58
+ action: TemplateResult | typeof nothing;
59
+ /** The dot's tooltip, for a line whose words are not drawn. */
60
+ title?: string;
61
+ };
62
+ /**
63
+ * The refused state's line: its words with the refusal's code, the server's reason on a line under
64
+ * them where `reason` is given, and Retry where `onRetry` is given.
65
+ */
66
+ export declare function refusedLine(words: string, code: string | null | undefined, onRetry?: (() => void) | null, reason?: string | null): StateLine;
67
+ /** The line each state needs; `detached` and `shown` need none. */
68
+ export declare function stateLine(state: PanelState, status: StatusProjection | null | undefined, actions?: StateActions): StateLine;
69
+ /**
70
+ * A line for an element that announces its state: the dot, the words in a `role="status"` element,
71
+ * `after`, and the line's button beside them. An element renders this in the same place in every
72
+ * state, so the live element stays and each change of its words is announced. With `unsaid`, the
73
+ * words sit beside the live element instead, read and not announced, and the live element holds
74
+ * `unsaid.heard`, undrawn.
75
+ */
76
+ export declare function liveLine(line: StateLine, unsaid?: {
77
+ heard: unknown;
78
+ } | null, after?: unknown): TemplateResult;
79
+ /** What {@link liveLine} takes for words that are shown and never announced. */
80
+ export declare const UNSAID: {
81
+ heard: symbol;
82
+ };
83
+ /**
84
+ * Whether the view's emptiness is a query's answer, which an element announces, or where a pan
85
+ * went, which it does not. An element calls {@link note} once for each update.
86
+ */
87
+ export declare class EmptyAnswer {
88
+ private wasQuerying;
89
+ private answered;
90
+ /** Whether `state` is `empty` and a query ended in it. It stays so until the view is no longer empty. */
91
+ note(state: PanelState, querying: boolean): boolean;
92
+ }
93
+ /** The element with the focus in `doc`, looked for through the shadow roots that hold it. */
94
+ export declare function focusedElement(doc: Document): Element | null;
50
95
  /**
51
96
  * The refused state: its dot, its words with the refusal's code, the server's reason on a line
52
97
  * under them where `reason` is given, and Retry where `onRetry` is given.
@@ -57,6 +102,33 @@ export declare function refusedState(words: string, code: string | null | undefi
57
102
  * state needs, such as a skeleton while loading or the refresh control when stale.
58
103
  */
59
104
  export declare function renderState(state: PanelState, status: StatusProjection | null | undefined, actions?: StateActions): TemplateResult | typeof nothing;
105
+ /**
106
+ * Where focus goes when a state's button leaves with its state. An element calls {@link note} from
107
+ * `willUpdate` and {@link keep} from `updated`, naming the element that takes the focus.
108
+ */
109
+ export declare class ActionFocus {
110
+ private held;
111
+ /** Remember the button with focus in a state line inside `root`, if there is one. */
112
+ note(root: DocumentFragment | HTMLElement | null): void;
113
+ /** Focus `to` if the remembered button has left the page. */
114
+ keep(to: HTMLElement | null): void;
115
+ }
116
+ /**
117
+ * Whether a query has run long enough to name. An element calls {@link track} from `updated` with
118
+ * whether its query is running, reads {@link named} when it renders, and is asked to update when
119
+ * the query has run for {@link QUERY_NAMED_AFTER_MS}.
120
+ */
121
+ export declare class NamedQuery {
122
+ private readonly host;
123
+ named: boolean;
124
+ private timer;
125
+ constructor(host: {
126
+ requestUpdate(): void;
127
+ });
128
+ track(running: boolean): void;
129
+ /** Forget the wait, as an element leaving the page does. */
130
+ stop(): void;
131
+ }
60
132
  /** The words for a viewer who can see no item in the view at all, where `empty` would read as "pan elsewhere". */
61
133
  export declare const NOTHING_VISIBLE = "Nothing visible to you";
62
134
  /**
package/dist/states.js CHANGED
@@ -73,45 +73,154 @@ function retryButton(onRetry) {
73
73
  return onRetry ? html `<button part="retry" class="btn small" type="button" @click=${onRetry}>Retry</button>` : nothing;
74
74
  }
75
75
  /**
76
- * The refused state: its dot, its words with the refusal's code, the server's reason on a line
77
- * under them where `reason` is given, and Retry where `onRetry` is given.
76
+ * The refused state's line: its words with the refusal's code, the server's reason on a line under
77
+ * them where `reason` is given, and Retry where `onRetry` is given.
78
78
  */
79
- export function refusedState(words, code, onRetry, reason) {
79
+ export function refusedLine(words, code, onRetry, reason) {
80
80
  const said = reason ? html `<span class="said">${refusalText(words, code)}<span part="reason">${reason}</span></span>` : refusalText(words, code);
81
- return html `<span part="state" data-state="refused"><span class="dot refuse"></span>${said}${retryButton(onRetry)}</span>`;
81
+ return { dot: 'dot refuse', words: said, action: retryButton(onRetry) };
82
82
  }
83
- /**
84
- * The state region every panel renders: a `part="state"` carrying `data-state`, and the line the
85
- * state needs, such as a skeleton while loading or the refresh control when stale.
86
- */
87
- export function renderState(state, status, actions = {}) {
83
+ /** The line each state needs; `detached` and `shown` need none. */
84
+ export function stateLine(state, status, actions = {}) {
88
85
  const refusal = status?.refusal ?? null;
86
+ const words = stateWord(state, status);
89
87
  switch (state) {
88
+ // Empty: "empty" and "refused" are answers, and a detached panel has none.
90
89
  case 'detached':
91
- // Empty: "empty" and "refused" are answers, and a detached panel has none.
92
- return html `<span part="state" data-state="detached"></span>`;
93
- case 'loading': {
94
- const words = actions.first && status?.sessionWarm ? 'Loading' : stateWord(state, status);
95
- return html `<span part="state" data-state="loading"><span class="dot quiet"></span>${words}${status && !status.sessionWarm ? nothing : html `<span class="skel" aria-hidden="true"></span>`}</span>`;
96
- }
90
+ case 'shown':
91
+ return { dot: null, words: nothing, action: nothing };
92
+ case 'loading':
93
+ return { dot: 'dot quiet', words: actions.first && status?.sessionWarm ? 'Loading' : words, action: nothing };
97
94
  case 'retrying':
98
- return html `<span part="state" data-state="retrying"><span class="dot warn"></span>${stateWord(state, status)}</span>`;
95
+ return { dot: 'dot warn', words, action: nothing };
99
96
  case 'unanswered':
100
- return html `<span part="state" data-state="unanswered"><span class="dot warn"></span>${stateWord(state, status)}${retryButton(actions.onRetry)}</span>`;
97
+ return { dot: 'dot warn', words, action: retryButton(actions.onRetry) };
101
98
  case 'busy':
102
- return html `<span part="state" data-state="busy"><span class="dot warn"></span>${refusalText(stateWord(state, status), refusal?.code)}${retryButton(actions.onRetry)}</span>`;
103
- case 'shown':
104
- return html `<span part="state" data-state="shown"></span>`;
99
+ return { dot: 'dot warn', words: refusalText(words, refusal?.code), action: retryButton(actions.onRetry) };
105
100
  case 'empty':
106
- return html `<span part="state" data-state="empty">${stateWord(state, status)}</span>`;
101
+ return { dot: null, words, action: nothing };
107
102
  case 'refused':
108
- return refusedState(stateWord(state, status), refusal?.code, actions.onRetry, reasonOf(refusal));
103
+ return refusedLine(words, refusal?.code, actions.onRetry, reasonOf(refusal));
109
104
  case 'expired':
110
- return html `<span part="state" data-state="expired"><span class="dot refuse"></span>${stateWord(state, status)}${actions.onReauthorise
111
- ? html `<button part="reauthorise" class="btn small" type="button" @click=${actions.onReauthorise}>Sign in</button>`
112
- : nothing}</span>`;
105
+ return {
106
+ dot: 'dot refuse',
107
+ words,
108
+ action: actions.onReauthorise ? html `<button part="reauthorise" class="btn small" type="button" @click=${actions.onReauthorise}>Sign in</button>` : nothing
109
+ };
113
110
  case 'stale':
114
- return html `<span part="state" data-state="stale"><span class="dot warn"></span>${stateWord(state, status)}<button part="refresh" class="btn small" type="button" @click=${actions.onRefresh ?? undefined}>Refresh</button></span>`;
111
+ return { dot: 'dot warn', words, action: html `<button part="refresh" class="btn small" type="button" @click=${actions.onRefresh ?? undefined}>Refresh</button>` };
112
+ }
113
+ }
114
+ /** The dot before a line's words. */
115
+ function stateDot(line) {
116
+ return line.dot ? html `<span class=${line.dot} title=${line.title ?? nothing}></span>` : nothing;
117
+ }
118
+ /**
119
+ * A line for an element that announces its state: the dot, the words in a `role="status"` element,
120
+ * `after`, and the line's button beside them. An element renders this in the same place in every
121
+ * state, so the live element stays and each change of its words is announced. With `unsaid`, the
122
+ * words sit beside the live element instead, read and not announced, and the live element holds
123
+ * `unsaid.heard`, undrawn.
124
+ */
125
+ export function liveLine(line, unsaid = null, after = nothing) {
126
+ return html `${stateDot(line)}${unsaid ? line.words : nothing}<span class=${unsaid ? 'sr-only' : nothing} role="status">${unsaid ? unsaid.heard : line.words}</span>${after}${line.action}`;
127
+ }
128
+ /** What {@link liveLine} takes for words that are shown and never announced. */
129
+ export const UNSAID = { heard: nothing };
130
+ /**
131
+ * Whether the view's emptiness is a query's answer, which an element announces, or where a pan
132
+ * went, which it does not. An element calls {@link note} once for each update.
133
+ */
134
+ export class EmptyAnswer {
135
+ wasQuerying = false;
136
+ answered = false;
137
+ /** Whether `state` is `empty` and a query ended in it. It stays so until the view is no longer empty. */
138
+ note(state, querying) {
139
+ if (state !== 'empty')
140
+ this.answered = false;
141
+ else if (this.wasQuerying && !querying)
142
+ this.answered = true;
143
+ this.wasQuerying = querying;
144
+ return this.answered;
145
+ }
146
+ }
147
+ /** The element with the focus in `doc`, looked for through the shadow roots that hold it. */
148
+ export function focusedElement(doc) {
149
+ let focused = doc.activeElement;
150
+ while (focused?.shadowRoot?.activeElement)
151
+ focused = focused.shadowRoot.activeElement;
152
+ return focused;
153
+ }
154
+ /** A line as a `part="state"` carrying `data-state`, with `after` between its words and its button. */
155
+ function stateRegion(state, line, after = nothing) {
156
+ return html `<span part="state" data-state=${state}>${stateDot(line)}${line.words}${after}${line.action}</span>`;
157
+ }
158
+ /**
159
+ * The refused state: its dot, its words with the refusal's code, the server's reason on a line
160
+ * under them where `reason` is given, and Retry where `onRetry` is given.
161
+ */
162
+ export function refusedState(words, code, onRetry, reason) {
163
+ return stateRegion('refused', refusedLine(words, code, onRetry, reason));
164
+ }
165
+ /**
166
+ * The state region every panel renders: a `part="state"` carrying `data-state`, and the line the
167
+ * state needs, such as a skeleton while loading or the refresh control when stale.
168
+ */
169
+ export function renderState(state, status, actions = {}) {
170
+ const skeleton = state === 'loading' && !(status && !status.sessionWarm) ? html `<span class="skel" aria-hidden="true"></span>` : nothing;
171
+ return stateRegion(state, stateLine(state, status, actions), skeleton);
172
+ }
173
+ /**
174
+ * Where focus goes when a state's button leaves with its state. An element calls {@link note} from
175
+ * `willUpdate` and {@link keep} from `updated`, naming the element that takes the focus.
176
+ */
177
+ export class ActionFocus {
178
+ held = null;
179
+ /** Remember the button with focus in a state line inside `root`, if there is one. */
180
+ note(root) {
181
+ const focused = root ? focusedElement(root.ownerDocument) : null;
182
+ this.held = focused instanceof HTMLButtonElement && root.contains(focused) && focused.closest('[part~="state"]') ? focused : null;
183
+ }
184
+ /** Focus `to` if the remembered button has left the page. */
185
+ keep(to) {
186
+ if (this.held && !this.held.isConnected)
187
+ to?.focus();
188
+ this.held = null;
189
+ }
190
+ }
191
+ /** How long a query has run before an element names it, so one answered at once does not flicker it. */
192
+ const QUERY_NAMED_AFTER_MS = 250;
193
+ /**
194
+ * Whether a query has run long enough to name. An element calls {@link track} from `updated` with
195
+ * whether its query is running, reads {@link named} when it renders, and is asked to update when
196
+ * the query has run for {@link QUERY_NAMED_AFTER_MS}.
197
+ */
198
+ export class NamedQuery {
199
+ host;
200
+ named = false;
201
+ timer = null;
202
+ constructor(host) {
203
+ this.host = host;
204
+ }
205
+ track(running) {
206
+ if (!running) {
207
+ this.stop();
208
+ this.named = false;
209
+ return;
210
+ }
211
+ if (this.named || this.timer !== null)
212
+ return;
213
+ this.timer = setTimeout(() => {
214
+ this.timer = null;
215
+ this.named = true;
216
+ this.host.requestUpdate();
217
+ }, QUERY_NAMED_AFTER_MS);
218
+ }
219
+ /** Forget the wait, as an element leaving the page does. */
220
+ stop() {
221
+ if (this.timer !== null)
222
+ clearTimeout(this.timer);
223
+ this.timer = null;
115
224
  }
116
225
  }
117
226
  /** The words for a viewer who can see no item in the view at all, where `empty` would read as "pan elsewhere". */