@fusion.dev/helix-headless 0.116.0 → 0.118.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.
@@ -0,0 +1,342 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { customElement, fillTemplate, formatNumbers, HelixElement, HelixGraphLayoutEvent, HelixGraphPanEvent, HelixGraphPartChangeEvent, HelixGraphReadyEvent, HelixGraphZoomEvent, MixinControllerActive, MixinViewport, VirtualizeController, } from '@fusion.dev/helix-core';
8
+ import { magnifyingGlass } from '@fusion.dev/helix-icons';
9
+ import { html, nothing } from 'lit';
10
+ import { property, state } from 'lit/decorators.js';
11
+ import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
+ import { adjacencyOf, entriesOf, nameOf, sortedOf, within, } from './graph-model.js';
13
+ const PAGE = 10;
14
+ let HelixHeadlessGraph = class HelixHeadlessGraph extends MixinViewport(MixinControllerActive(HelixElement)) {
15
+ constructor() {
16
+ super(...arguments);
17
+ this.countLabel = '{count} notes';
18
+ this.label = '';
19
+ this.linkedBothLabel = 'Linked both ways with {note}';
20
+ this.linkedFromLabel = 'Linked from {note}';
21
+ this.links = [];
22
+ this.linksToLabel = 'Links to {note}';
23
+ this.nodes = [];
24
+ this.notesLabel = 'Notes';
25
+ this.searchLabel = 'Find a note';
26
+ this.selectedLabel = 'Selected';
27
+ this.query = '';
28
+ this.cursor = 0;
29
+ this.adjacency = new Map();
30
+ this.sorted = [];
31
+ this.entries = [];
32
+ this.whole = false;
33
+ this.said = '';
34
+ this.virtualize = new VirtualizeController(this, {
35
+ container: () => this.partNamed('list'),
36
+ count: () => this.entries.length,
37
+ estimate: () => 32,
38
+ rows: () => [...(this.renderRoot.querySelectorAll?.('[data-index]') ?? [])].map((element) => ({
39
+ element: element,
40
+ index: Number(element.dataset.index),
41
+ })),
42
+ });
43
+ this.handleListKeydown = (event) => {
44
+ const moves = {
45
+ ArrowDown: this.cursor + 1,
46
+ ArrowUp: this.cursor - 1,
47
+ End: this.entries.length - 1,
48
+ Home: 0,
49
+ PageDown: this.cursor + PAGE,
50
+ PageUp: this.cursor - PAGE,
51
+ };
52
+ if (event.key in moves) {
53
+ event.preventDefault();
54
+ event.stopPropagation();
55
+ this.moveTo(moves[event.key]);
56
+ return;
57
+ }
58
+ if (event.key === 'Enter' || event.key === ' ') {
59
+ event.preventDefault();
60
+ event.stopPropagation();
61
+ this.choose(this.cursor);
62
+ }
63
+ };
64
+ this.handleSearchKeydown = (event) => {
65
+ if (event.key === 'ArrowDown') {
66
+ event.preventDefault();
67
+ this.partNamed('list')?.focus();
68
+ }
69
+ if (event.key === 'Escape') {
70
+ if (this.query === '') {
71
+ return;
72
+ }
73
+ event.preventDefault();
74
+ this.query = '';
75
+ }
76
+ event.stopPropagation();
77
+ };
78
+ this.handleSearch = (event) => {
79
+ this.query = event.target.value;
80
+ };
81
+ }
82
+ get shown() {
83
+ return this.kept;
84
+ }
85
+ get ready() {
86
+ return this.whole;
87
+ }
88
+ drawn(whole = true) {
89
+ if (whole === this.whole) {
90
+ return;
91
+ }
92
+ this.whole = whole;
93
+ this.toggleState('ready', whole);
94
+ if (whole) {
95
+ this.dispatchEvent(new HelixGraphReadyEvent());
96
+ }
97
+ }
98
+ laidOut(positions) {
99
+ this.dispatchEvent(new HelixGraphLayoutEvent({ positions }));
100
+ }
101
+ willUpdate(changed) {
102
+ super.willUpdate(changed);
103
+ this.toggleState('fullscreen', this.filled);
104
+ this.toggleState('idle', this.resting);
105
+ if (changed.has('links')) {
106
+ this.adjacency = adjacencyOf(this.links);
107
+ }
108
+ if (changed.has('nodes')) {
109
+ this.sorted = sortedOf(this.nodes, this.closest?.('[lang]')?.getAttribute('lang') ?? '');
110
+ }
111
+ if (changed.has('active') || changed.has('depth') || changed.has('links')) {
112
+ this.kept =
113
+ typeof this.depth === 'number' && this.active !== ''
114
+ ? within(this.adjacency, this.active, Math.max(0, this.depth))
115
+ : undefined;
116
+ }
117
+ if (['nodes', 'links', 'active', 'depth', 'query'].some((name) => changed.has(name))) {
118
+ this.entries = entriesOf(this.sorted, this.adjacency, this.active, this.query, this.kept);
119
+ this.cursor = 0;
120
+ const list = this.partNamed('list');
121
+ if (list) {
122
+ list.scrollTop = 0;
123
+ }
124
+ }
125
+ }
126
+ updated(changed) {
127
+ super.updated(changed);
128
+ this.virtualize.measure();
129
+ if (changed.has('nodes') || changed.has('links')) {
130
+ this.dispatchEvent(new HelixGraphPartChangeEvent({ kind: 'data' }));
131
+ }
132
+ else if (changed.has('active') || changed.has('depth')) {
133
+ this.dispatchEvent(new HelixGraphPartChangeEvent({ kind: 'focus' }));
134
+ }
135
+ if (changed.has('query') && changed.get('query') !== undefined) {
136
+ this.said = this.countText();
137
+ }
138
+ }
139
+ panEvent(detail) {
140
+ return new HelixGraphPanEvent(detail);
141
+ }
142
+ zoomEvent(detail) {
143
+ return new HelixGraphZoomEvent(detail);
144
+ }
145
+ get restingControls() {
146
+ return 'zoom fullscreen';
147
+ }
148
+ get restingControlsLabel() {
149
+ return 'Graph controls';
150
+ }
151
+ gesturesChanged() {
152
+ this.dispatchEvent(new HelixGraphPartChangeEvent({ kind: 'gestures' }));
153
+ }
154
+ get restingGestureLabels() {
155
+ return {
156
+ activate: 'Press the graph to use it',
157
+ twoFinger: 'Use two fingers to move the graph',
158
+ using: 'Using the graph. Press Escape to scroll the page again.',
159
+ wheel: 'Use {key} and scroll to zoom the graph',
160
+ };
161
+ }
162
+ countText() {
163
+ const lang = this.closest?.('[lang]')?.getAttribute('lang') ?? '';
164
+ return fillTemplate(this.countLabel, { count: formatNumbers(lang)(this.entries.length) });
165
+ }
166
+ relationText(entry) {
167
+ const selected = this.nodes.find((node) => node.id === this.active);
168
+ const note = selected ? nameOf(selected) : '';
169
+ switch (entry.relation) {
170
+ case 'selected':
171
+ return this.selectedLabel;
172
+ case 'to':
173
+ return fillTemplate(this.linkedFromLabel, { note });
174
+ case 'from':
175
+ return fillTemplate(this.linksToLabel, { note });
176
+ case 'both':
177
+ return fillTemplate(this.linkedBothLabel, { note });
178
+ default:
179
+ return '';
180
+ }
181
+ }
182
+ choose(index) {
183
+ const entry = this.entries[index];
184
+ if (entry) {
185
+ this.requestActive(entry.node.id);
186
+ }
187
+ }
188
+ moveTo(index) {
189
+ const last = this.entries.length - 1;
190
+ this.cursor = Math.min(Math.max(index, 0), Math.max(last, 0));
191
+ const list = this.partNamed('list');
192
+ if (!list) {
193
+ return;
194
+ }
195
+ const row = this.renderRoot.querySelector?.(`[data-index="${this.cursor}"]`);
196
+ if (row) {
197
+ row.scrollIntoView?.({ block: 'nearest' });
198
+ }
199
+ else {
200
+ list.scrollTop = this.cursor * 32;
201
+ }
202
+ this.virtualize.measure();
203
+ }
204
+ renderFilterIcon() {
205
+ return html `<slot name="filter-icon">
206
+ <svg part="filter-mark" fill="currentColor" viewBox=${magnifyingGlass.viewBox}>
207
+ ${unsafeSVG(magnifyingGlass.content)}
208
+ </svg>
209
+ </slot>`;
210
+ }
211
+ renderRows() {
212
+ const { first, last } = this.virtualize.window;
213
+ const total = this.entries.length;
214
+ return this.entries.slice(first, last).map((entry, offset) => {
215
+ const index = first + offset;
216
+ const relation = this.relationText(entry);
217
+ const chosen = entry.relation === 'selected';
218
+ return html `<div
219
+ part="note${chosen ? ' note-selected' : entry.relation ? ' note-linked' : ''}${index === this.cursor ? ' note-cursor' : ''}"
220
+ role="option"
221
+ id=${`note-${index}`}
222
+ data-index=${index}
223
+ aria-selected=${chosen ? 'true' : 'false'}
224
+ aria-posinset=${index + 1}
225
+ aria-setsize=${total}
226
+ aria-description=${relation || nothing}
227
+ @click=${() => {
228
+ this.cursor = index;
229
+ this.choose(index);
230
+ }}
231
+ >
232
+ <span part="note-name">${nameOf(entry.node)}</span>
233
+ ${relation ? html `<span part="note-relation" aria-hidden="true">${relation}</span>` : nothing}
234
+ </div>`;
235
+ });
236
+ }
237
+ render() {
238
+ const { after, before } = this.virtualize.window;
239
+ const cursor = this.entries.length > 0 ? `note-${this.cursor}` : nothing;
240
+ return html `<div
241
+ part="region"
242
+ role="group"
243
+ aria-label=${this.label || this.getAttribute?.('aria-label') || 'Graph'}
244
+ tabindex="0"
245
+ @focusin=${this.idle.stir}
246
+ @keydown=${this.handleKeydown}
247
+ @pointerleave=${this.handlePointerLeave}
248
+ @pointermove=${this.idle.stir}
249
+ >
250
+ <div part="header">
251
+ <slot name="header"></slot>
252
+ </div>
253
+ <div part="body">
254
+ <div part="stage">
255
+ <div part="viewport">
256
+ <slot></slot>
257
+ ${this.renderGestures()}
258
+ </div>
259
+ ${this.renderControls()}
260
+ </div>
261
+ <div part="panel">
262
+ <div part="search">
263
+ <span part="filter-icon" aria-hidden="true">${this.renderFilterIcon()}</span>
264
+ <input
265
+ part="search-field"
266
+ type="text"
267
+ autocomplete="off"
268
+ spellcheck="false"
269
+ aria-label=${this.searchLabel}
270
+ placeholder=${this.searchLabel}
271
+ .value=${this.query}
272
+ @input=${this.handleSearch}
273
+ @keydown=${this.handleSearchKeydown}
274
+ />
275
+ <span part="count" aria-hidden="true">${this.countText()}</span>
276
+ </div>
277
+ <div
278
+ part="list"
279
+ role="listbox"
280
+ tabindex="0"
281
+ data-scroller
282
+ aria-label=${this.notesLabel}
283
+ aria-activedescendant=${cursor}
284
+ @keydown=${this.handleListKeydown}
285
+ @scroll=${() => this.virtualize.measure()}
286
+ >
287
+ <div part="before" style="block-size: ${before}px"></div>
288
+ ${this.renderRows()}
289
+ <div part="after" style="block-size: ${after}px"></div>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ <div part="footer">
294
+ <slot name="footer"></slot>
295
+ </div>
296
+ <span part="live" role="status">${this.said}</span>
297
+ </div>`;
298
+ }
299
+ };
300
+ __decorate([
301
+ property({ attribute: 'count-label' })
302
+ ], HelixHeadlessGraph.prototype, "countLabel", void 0);
303
+ __decorate([
304
+ property({ type: Number })
305
+ ], HelixHeadlessGraph.prototype, "depth", void 0);
306
+ __decorate([
307
+ property()
308
+ ], HelixHeadlessGraph.prototype, "label", void 0);
309
+ __decorate([
310
+ property({ attribute: 'linked-both-label' })
311
+ ], HelixHeadlessGraph.prototype, "linkedBothLabel", void 0);
312
+ __decorate([
313
+ property({ attribute: 'linked-from-label' })
314
+ ], HelixHeadlessGraph.prototype, "linkedFromLabel", void 0);
315
+ __decorate([
316
+ property({ attribute: false })
317
+ ], HelixHeadlessGraph.prototype, "links", void 0);
318
+ __decorate([
319
+ property({ attribute: 'links-to-label' })
320
+ ], HelixHeadlessGraph.prototype, "linksToLabel", void 0);
321
+ __decorate([
322
+ property({ attribute: false })
323
+ ], HelixHeadlessGraph.prototype, "nodes", void 0);
324
+ __decorate([
325
+ property({ attribute: 'notes-label' })
326
+ ], HelixHeadlessGraph.prototype, "notesLabel", void 0);
327
+ __decorate([
328
+ property({ attribute: 'search-label' })
329
+ ], HelixHeadlessGraph.prototype, "searchLabel", void 0);
330
+ __decorate([
331
+ property({ attribute: 'selected-label' })
332
+ ], HelixHeadlessGraph.prototype, "selectedLabel", void 0);
333
+ __decorate([
334
+ state()
335
+ ], HelixHeadlessGraph.prototype, "query", void 0);
336
+ __decorate([
337
+ state()
338
+ ], HelixHeadlessGraph.prototype, "cursor", void 0);
339
+ HelixHeadlessGraph = __decorate([
340
+ customElement('helix-headless-graph')
341
+ ], HelixHeadlessGraph);
342
+ export { HelixHeadlessGraph };
package/dist/index.d.ts CHANGED
@@ -47,6 +47,7 @@ export * from './empty/index.js';
47
47
  export * from './file-input/index.js';
48
48
  export * from './filter-input/index.js';
49
49
  export * from './format-date/index.js';
50
+ export * from './graph/index.js';
50
51
  export * from './heatmap/index.js';
51
52
  export * from './icon/index.js';
52
53
  export * from './icon-button/index.js';
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ export * from './empty/index.js';
38
38
  export * from './file-input/index.js';
39
39
  export * from './filter-input/index.js';
40
40
  export * from './format-date/index.js';
41
+ export * from './graph/index.js';
41
42
  export * from './heatmap/index.js';
42
43
  export * from './icon/index.js';
43
44
  export * from './icon-button/index.js';
@@ -1,4 +1,4 @@
1
- import { HelixElement, KeyboardNavigationController, type ViewportControlsPlacement, type ViewportPanDetail, type ViewportZoomDetail } from '@fusion.dev/helix-core';
1
+ import { HelixElement, KeyboardNavigationController, type ViewportControlsPlacement, type ViewportGestureLabels, type ViewportPanDetail, type ViewportZoomDetail } from '@fusion.dev/helix-core';
2
2
  import { nothing, type PropertyValues, type TemplateResult } from 'lit';
3
3
  /**
4
4
  * What `ViewportControlsPlacement` is called for a map.
@@ -115,14 +115,10 @@ declare const HelixHeadlessMap_base: (new (...args: any[]) => import("@fusion.de
115
115
  * @cssstate fullscreen - The map fills the screen.
116
116
  * @cssstate idle - The user has been still long enough for the header and footer to fade.
117
117
  * @cssstate ready - The map has been drawn whole, after `helix-map-ready`.
118
+ * @cssstate sharing - The page keeps the plain wheel and one finger, with `gestures="cooperative"` or while a map with `gestures="activate"` waits to be pressed.
118
119
  * @cssstate using - A map with `gestures="activate"` has been pressed and has the gestures.
119
120
  */
120
121
  export declare class HelixHeadlessMap extends HelixHeadlessMap_base {
121
- /**
122
- * The hint shown when a map waiting to be pressed is scrolled or swiped
123
- * over, with `gestures="activate"`.
124
- */
125
- activateHintLabel: string;
126
122
  /**
127
123
  * The map's rotation, in degrees clockwise from north. `connectMap` keeps it
128
124
  * current, and the Face north button's needle turns with it.
@@ -137,21 +133,6 @@ export declare class HelixHeadlessMap extends HelixHeadlessMap_base {
137
133
  faceNorthLabel: string;
138
134
  /** What screen readers hear when the map is tilted back to looking straight down. */
139
135
  flatLabel: string;
140
- /**
141
- * Which gestures the map takes from the page.
142
- *
143
- * - `always`, which unset is: the wheel zooms the map and one finger pans
144
- * it, for a map that is the page, such as a full-screen view.
145
- * - `cooperative`: the wheel and one finger scroll the page, and the map
146
- * takes Ctrl or ⌘ with the wheel, and two fingers. The frame says so
147
- * when a gesture passes to the page. Best for a map embedded in a page.
148
- * - `activate`: the map takes nothing until it is pressed, and gives it
149
- * back on Escape, when the pointer leaves it, or on a press elsewhere.
150
- *
151
- * The keyboard and the frame's buttons work in every mode. The renderer is
152
- * told through the connector, so MapLibre and Leaflet behave alike.
153
- */
154
- gestures?: 'activate' | 'always' | 'cooperative';
155
136
  /** The Hybrid map type's name. */
156
137
  hybridLabel: string;
157
138
  /**
@@ -213,16 +194,6 @@ export declare class HelixHeadlessMap extends HelixHeadlessMap_base {
213
194
  * top of the map faces, clockwise from north.
214
195
  */
215
196
  turnedLabel: string;
216
- /** The hint shown when one finger swipes over a map with `gestures="cooperative"`. */
217
- twoFingerHintLabel: string;
218
- /** The line shown while a map with `gestures="activate"` has the gestures. */
219
- usingLabel: string;
220
- /**
221
- * The hint shown when the wheel scrolls past a map with
222
- * `gestures="cooperative"`, with `{key}`, which is ⌘ on a Mac and Ctrl
223
- * elsewhere, as the renderer reads it.
224
- */
225
- wheelHintLabel: string;
226
197
  /**
227
198
  * What screen readers hear after a zoom. `{level}` is the zoom level.
228
199
  */
@@ -348,51 +319,10 @@ export declare class HelixHeadlessMap extends HelixHeadlessMap_base {
348
319
  */
349
320
  private readonly handleMarkerKeydown;
350
321
  private readonly handleMarkerKeyup;
351
- /** The hint on the map while one shows, put there by a gesture the map let pass. */
352
- private hint;
353
- /** When the hint goes. */
354
- private hinting;
355
- /**
356
- * Where the gesture was, in the map's box, which the hint is shown at:
357
- * where the reader is looking, and on the map wherever a page laid its own
358
- * content over the rest of it.
359
- */
360
- private hintAt;
361
- /** Whether a map with `gestures="activate"` has been pressed, and has the gestures. */
362
- private taken;
363
- /**
364
- * @internal Which gestures the renderer takes, which `connectMap` tells it:
365
- * all of them, the cooperative set, or none, while an `activate` map waits
366
- * to be pressed.
367
- */
368
- get gestureMode(): 'cooperative' | 'free' | 'off';
369
- /** Shows a hint over the map for a moment, where the gesture was. */
370
- private showHint;
371
- /**
372
- * A wheel the map let pass to the page, which is said: a reader scrolling
373
- * the page past a map that did not zoom is told why, and how to zoom it.
374
- * **Ctrl or ⌘ with the wheel is the map's**, and is what a trackpad's pinch
375
- * arrives as.
376
- */
377
- private readonly handleWheel;
378
- /** One finger moving over the map, which scrolls the page, and is said. */
379
- private readonly handleTouchMove;
380
- /** A press on the map itself, which hands a waiting map the gestures. */
381
- private readonly handleTake;
382
- /** Escape, which gives the page its scroll back. */
383
- private readonly handleGiveBackKey;
384
- /** A press anywhere else on the page, which gives it its scroll back. */
385
- private readonly handleGiveBackOutside;
386
- /** The window losing focus, as a reader switching away does. */
387
- private readonly handleGiveBackBlur;
388
- /**
389
- * The pointer leaving the frame. **Heard on the frame and not the map's
390
- * canvas**, which a pointer resting on a marker has left, since markers
391
- * stand outside it. **A mouse or a pen and never a finger**, whose
392
- * `pointerleave` is the finger lifting: a tap took the map and gave it back
393
- * in one.
394
- */
395
- private readonly handleLeave;
322
+ /** What the map says about its gestures where the page does not say it. */
323
+ protected get restingGestureLabels(): ViewportGestureLabels;
324
+ /** Which gestures the renderer takes, which `connectMap` hears from here. */
325
+ protected gesturesChanged(): void;
396
326
  /** A marker added or taken away is a walk of a different length. */
397
327
  private readonly handleSlotChange;
398
328
  connectedCallback(): void;