@kubex/zinc 1.1.94 → 1.1.96

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 (39) hide show
  1. package/dist/custom-elements.json +653 -89
  2. package/dist/vscode.html-custom-data.json +73 -18
  3. package/dist/web-types.json +145 -35
  4. package/dist/zn.d.ts +204 -39
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +374 -308
  7. package/docs/pages/components/flow-builder.md +17 -1
  8. package/docs/pages/components/page-builder.md +31 -0
  9. package/docs/pages/components/slash-menu.md +132 -6
  10. package/docs/pages/components/textarea.md +16 -0
  11. package/package.json +1 -1
  12. package/scss/_root.scss +7 -1
  13. package/src/components/button/button.scss +5 -2
  14. package/src/components/flow-builder/flow-builder.component.ts +16 -1
  15. package/src/components/flow-builder/flow-builder.test.ts +206 -1
  16. package/src/components/flow-builder/flow-geometry.test.ts +102 -0
  17. package/src/components/flow-builder/flow.types.ts +82 -15
  18. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +163 -108
  19. package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -0
  20. package/src/components/inline-edit/inline-edit.component.ts +6 -1
  21. package/src/components/input/input.component.ts +12 -2
  22. package/src/components/page/page.scss +7 -2
  23. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
  24. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
  25. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
  26. package/src/components/page-builder/page-builder.component.ts +62 -4
  27. package/src/components/page-builder/page-builder.test.ts +94 -0
  28. package/src/components/remarkd-editor/remarkd-editor.component.ts +227 -12
  29. package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
  30. package/src/components/remarkd-editor/remarkd-editor.test.ts +258 -0
  31. package/src/components/settings-container/settings-container.scss +2 -1
  32. package/src/components/slash-item/slash-item.component.ts +1 -1
  33. package/src/components/slash-menu/slash-menu-items.ts +48 -0
  34. package/src/components/slash-menu/slash-menu.component.ts +134 -27
  35. package/src/components/slash-menu/slash-menu.scss +90 -12
  36. package/src/components/slash-menu/slash-menu.test.ts +107 -0
  37. package/src/components/textarea/textarea.component.ts +12 -2
  38. package/src/components/textarea/textarea.test.ts +2 -2
  39. package/src/components/translations/translations.component.ts +5 -1
@@ -282,4 +282,262 @@ Second"></zn-remarkd-editor>`);
282
282
 
283
283
  expect(fired).to.be.true;
284
284
  });
285
+ it('should keep an include directive in a block of its own', async () => {
286
+ const el = await fixture<ZnRemarkdEditor>(html`
287
+ <zn-remarkd-editor value="Intro
288
+ include::inc-1[Payment Terms]
289
+ Outro"></zn-remarkd-editor>`);
290
+
291
+ const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block');
292
+ expect(blocks.length).to.equal(3);
293
+ expect(blocks[1].querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms');
294
+ expect(blocks[0].querySelector('.remarkd-editor__include')).to.not.exist;
295
+ });
296
+
297
+ it('should leave an include directive inside a fence alone', async () => {
298
+ const fenced = '```\ninclude::inc-1[Payment Terms]\n```';
299
+ const el = await fixture<ZnRemarkdEditor>(html`
300
+ <zn-remarkd-editor value=${fenced}></zn-remarkd-editor>`);
301
+
302
+ expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1);
303
+ expect(el.shadowRoot!.querySelector('.remarkd-editor__include')).to.not.exist;
304
+ });
305
+
306
+ it('should label an include chip from the marker when no list is configured', async () => {
307
+ const el = await fixture<ZnRemarkdEditor>(html`
308
+ <zn-remarkd-editor value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
309
+
310
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!;
311
+ expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms');
312
+ expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false;
313
+ });
314
+
315
+ // Ordering an embed within the content is the whole point of the chip: the
316
+ // block chrome's drag handle has to move it like any other block.
317
+ it('should reorder an include block by dragging its handle', async () => {
318
+ const el = await fixture<ZnRemarkdEditor>(html`
319
+ <zn-remarkd-editor value="Intro
320
+
321
+ include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
322
+
323
+ const first = el.shadowRoot!.querySelectorAll('.remarkd-editor__block')[0].getBoundingClientRect();
324
+ const handle = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__drag-handle')[1];
325
+ const target = first.top + 1;
326
+
327
+ handle.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, button: 0, buttons: 1, clientX: 0, clientY: 0}));
328
+ document.dispatchEvent(new PointerEvent('pointermove', {bubbles: true, buttons: 1, clientX: 0, clientY: target}));
329
+ document.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, buttons: 0, clientX: 0, clientY: target}));
330
+ await el.updateComplete;
331
+
332
+ expect(el.value).to.equal('include::inc-1[Payment Terms]\n\nIntro');
333
+ });
334
+ /** Serves one include list to the editor, and restores fetch when the test ends. */
335
+ function stubIncludeList(items: unknown[]): void {
336
+ const original = window.fetch;
337
+ window.fetch = () => Promise.resolve(new Response(JSON.stringify({items}),
338
+ {headers: {'Content-Type': 'application/json'}}));
339
+ afterEach(() => {
340
+ window.fetch = original;
341
+ });
342
+ }
343
+
344
+ it('should label an include chip from the fetched list', async () => {
345
+ stubIncludeList([{id: 'inc-1', title: 'Refund Policy', scope: 'Global',
346
+ languages: 'English', url: '/kb/kb1/includes/inc-1'}]);
347
+ const el = await fixture<ZnRemarkdEditor>(html`
348
+ <zn-remarkd-editor include-url="/options"
349
+ value="include::inc-1[Stale Title]"></zn-remarkd-editor>`);
350
+
351
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'),
352
+ 'the include list never resolved');
353
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!;
354
+ expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Refund Policy');
355
+ expect(chip.querySelector('.remarkd-editor__include-scope')!.textContent).to.contain('Global');
356
+ expect(chip.querySelector<HTMLAnchorElement>('.remarkd-editor__include-link')!.getAttribute('href'))
357
+ .to.equal('/kb/kb1/includes/inc-1');
358
+ expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false;
359
+ });
360
+
361
+ it('should flag an include the list does not know', async () => {
362
+ stubIncludeList([{id: 'inc-1', title: 'Refund Policy'}]);
363
+ const el = await fixture<ZnRemarkdEditor>(html`
364
+ <zn-remarkd-editor include-url="/options"
365
+ value="include::inc-9[Archived]"></zn-remarkd-editor>`);
366
+
367
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include--missing'),
368
+ 'the missing state never rendered');
369
+ expect(el.shadowRoot!.querySelector('.remarkd-editor__include-meta')!.textContent).to.contain('inc-9');
370
+ });
371
+
372
+ // remarkd's file include shares the syntax, so an unknown path is not broken.
373
+ it('should not flag a file include the list does not know', async () => {
374
+ stubIncludeList([{id: 'inc-1', title: 'Refund Policy'}]);
375
+ const el = await fixture<ZnRemarkdEditor>(html`
376
+ <zn-remarkd-editor include-url="/options"
377
+ value="include::partials/legal.adoc[Legal]"></zn-remarkd-editor>`);
378
+
379
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include'),
380
+ 'the chip never rendered');
381
+ expect(el.shadowRoot!.querySelector('.remarkd-editor__include--missing')).to.not.exist;
382
+ });
383
+
384
+ it('should not request the include list for a document with no embeds', async () => {
385
+ const original = window.fetch;
386
+ let requests = 0;
387
+ window.fetch = () => {
388
+ requests++;
389
+ return Promise.resolve(new Response('{"items":[]}'));
390
+ };
391
+ try {
392
+ const el = await fixture<ZnRemarkdEditor>(html`
393
+ <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
394
+ await el.updateComplete;
395
+ expect(requests).to.equal(0);
396
+ } finally {
397
+ window.fetch = original;
398
+ }
399
+ });
400
+ it('should insert an include marker from the toolbar picker', async () => {
401
+ stubIncludeList([{id: 'inc-1', title: 'Refund Policy', scope: 'Global', languages: 'English'}]);
402
+ const el = await fixture<ZnRemarkdEditor>(html`
403
+ <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
404
+
405
+ const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
406
+ const includeButton = buttons[buttons.length - 1];
407
+ includeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
408
+
409
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-option'),
410
+ 'the include picker never listed anything');
411
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__include-option')!.click();
412
+ await el.updateComplete;
413
+
414
+ expect(el.value).to.equal('# Title\n\ninclude::inc-1[Refund Policy]');
415
+ });
416
+
417
+ it('should swap the slash command for the include picker when Include is chosen', async () => {
418
+ stubIncludeList([{id: 'inc-1', title: 'Refund Policy', languages: 'English'}]);
419
+ const el = await fixture<ZnRemarkdEditor>(html`
420
+ <zn-remarkd-editor include-url="/options" value="Hello"></zn-remarkd-editor>`);
421
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
422
+ await el.updateComplete;
423
+
424
+ const input = typeInBlock(el, '/include');
425
+ await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
426
+
427
+ input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
428
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-picker'),
429
+ 'the include picker never opened');
430
+
431
+ // The "/include" draft is dropped rather than committed as a block.
432
+ expect(el.value).to.equal('');
433
+ });
434
+
435
+ it('should filter the include picker', async () => {
436
+ stubIncludeList([
437
+ {id: 'inc-1', title: 'Refund Policy', keywords: ['money'], languages: 'English'},
438
+ {id: 'inc-2', title: 'Shipping', languages: 'English'},
439
+ ]);
440
+ const el = await fixture<ZnRemarkdEditor>(html`
441
+ <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
442
+ const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
443
+ buttons[buttons.length - 1].dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
444
+ await waitUntil(() => el.shadowRoot!.querySelectorAll('.remarkd-editor__include-option').length === 2,
445
+ 'the include picker never listed both');
446
+
447
+ const filter = el.shadowRoot!.querySelector<HTMLInputElement>('.remarkd-editor__include-filter')!;
448
+ filter.value = 'money';
449
+ filter.dispatchEvent(new Event('input', {bubbles: true}));
450
+ await el.updateComplete;
451
+
452
+ const options = el.shadowRoot!.querySelectorAll('.remarkd-editor__include-option');
453
+ expect(options.length).to.equal(1);
454
+ expect(options[0].textContent).to.contain('Refund Policy');
455
+ });
456
+
457
+ it('should offer no include entry points without an include-url', async () => {
458
+ const el = await fixture<ZnRemarkdEditor>(html`
459
+ <zn-remarkd-editor value="# Title"></zn-remarkd-editor>`);
460
+ const tooltips = Array.from(el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button'))
461
+ .map(button => button.getAttribute('tooltip'));
462
+ expect(tooltips).to.not.contain('Include');
463
+ });
464
+ // A list that never arrived is not evidence an embed is broken: flagging every
465
+ // chip "not found" on a failed request sends people hunting for the wrong bug.
466
+ it('should not flag includes when the list request fails', async () => {
467
+ const original = window.fetch;
468
+ window.fetch = () => Promise.resolve(new Response('nope', {status: 404}));
469
+ try {
470
+ const el = await fixture<ZnRemarkdEditor>(html`
471
+ <zn-remarkd-editor include-url="/options"
472
+ value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
473
+ await el.updateComplete;
474
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include'),
475
+ 'the chip never rendered');
476
+
477
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!;
478
+ expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false;
479
+ expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms');
480
+ } finally {
481
+ window.fetch = original;
482
+ }
483
+ });
484
+
485
+ it('should say so in the picker when the list request fails', async () => {
486
+ const original = window.fetch;
487
+ window.fetch = () => Promise.resolve(new Response('nope', {status: 500}));
488
+ try {
489
+ const el = await fixture<ZnRemarkdEditor>(html`
490
+ <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
491
+ const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
492
+ buttons[buttons.length - 1].dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
493
+
494
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-picker-empty')?.textContent
495
+ ?.includes('Could not load'), 'the picker never reported the failure');
496
+ } finally {
497
+ window.fetch = original;
498
+ }
499
+ });
500
+ // An app fragment's URLs are relative to its app base, and the console puts the
501
+ // app's gaid on the host element. A link built client-side from JSON is inside
502
+ // the shadow root, where a click retargets to the host, so the pagelet handler
503
+ // never sees it — the href has to carry the base itself.
504
+ it('should resolve an include link against the app base from gaid', async () => {
505
+ stubIncludeList([{id: 'inc-1', title: 'Payment Terms', scope: 'Global',
506
+ languages: 'English', url: '/global/includes/inc-1'}]);
507
+ const el = await fixture<ZnRemarkdEditor>(html`
508
+ <zn-remarkd-editor include-url="/ch/kb/kb/kb1/documents/doc1/includes/options"
509
+ gaid="ch/kb"
510
+ value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
511
+
512
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'),
513
+ 'the include list never resolved');
514
+ expect(el.shadowRoot!.querySelector<HTMLAnchorElement>('.remarkd-editor__include-link')!
515
+ .getAttribute('href')).to.equal('/ch/kb/global/includes/inc-1');
516
+ });
517
+
518
+ it('should leave an include link alone when it already carries the app base', async () => {
519
+ stubIncludeList([{id: 'inc-1', title: 'Payment Terms', languages: 'English',
520
+ url: '/ch/kb/global/includes/inc-1'}]);
521
+ const el = await fixture<ZnRemarkdEditor>(html`
522
+ <zn-remarkd-editor include-url="/options" gaid="ch/kb"
523
+ value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
524
+
525
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'),
526
+ 'the include list never resolved');
527
+ expect(el.shadowRoot!.querySelector<HTMLAnchorElement>('.remarkd-editor__include-link')!
528
+ .getAttribute('href')).to.equal('/ch/kb/global/includes/inc-1');
529
+ });
530
+
531
+ it('should leave an include link alone without a gaid', async () => {
532
+ stubIncludeList([{id: 'inc-1', title: 'Payment Terms', languages: 'English',
533
+ url: '/global/includes/inc-1'}]);
534
+ const el = await fixture<ZnRemarkdEditor>(html`
535
+ <zn-remarkd-editor include-url="/options"
536
+ value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
537
+
538
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'),
539
+ 'the include list never resolved');
540
+ expect(el.shadowRoot!.querySelector<HTMLAnchorElement>('.remarkd-editor__include-link')!
541
+ .getAttribute('href')).to.equal('/global/includes/inc-1');
542
+ });
285
543
  });
@@ -105,7 +105,8 @@ zn-button {
105
105
  border-bottom-width: 0;
106
106
  border-radius: 0 0 0 var(--zn-border-radius);
107
107
  transition: grid-template-rows 0.22s ease, border-bottom-width 0s linear 0.22s;
108
- will-change: grid-template-rows;
108
+ // PERF: grid-template-rows is not a compositable property, so will-change
109
+ // cannot help it — it only promoted a layer per settings container for free.
109
110
  }
110
111
 
111
112
  &--open &__panel {
@@ -2,7 +2,7 @@ import {html, unsafeCSS} from 'lit';
2
2
  import {property} from 'lit/decorators.js';
3
3
  import ZincElement from '../../internal/zinc-element';
4
4
  import type {CSSResultGroup} from 'lit';
5
- import type {SlashMenuItem} from '../slash-menu/slash-menu-items';
5
+ import type {SlashMenuItem} from '../slash-menu';
6
6
 
7
7
  import styles from './slash-item.scss';
8
8
 
@@ -120,3 +120,51 @@ export function filterSlashItems(items: SlashMenuItem[], query: string): SlashMe
120
120
  a.index - b.index)
121
121
  .map(entry => entry.item);
122
122
  }
123
+
124
+ const RECENT_PREFIX = 'zn-slash-recent:';
125
+ /** Kept deeper than any menu shows, so history survives items that aren't in the current list. */
126
+ const RECENT_LIMIT = 10;
127
+
128
+ /** The identity an item is remembered by in a menu's recently used list. */
129
+ export function slashItemKey(item: SlashMenuItem): string {
130
+ return item.action ? `action:${item.action}` : `value:${item.value || item.label}`;
131
+ }
132
+
133
+ /** The keys of the items most recently chosen from the menu stored under `key`, newest first. */
134
+ export function readRecentSlashItems(key: string): string[] {
135
+ if (!key) return [];
136
+
137
+ try {
138
+ const parsed: unknown = JSON.parse(localStorage.getItem(RECENT_PREFIX + key) ?? '[]');
139
+ return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+
145
+ /** Moves an item to the front of the recently used list stored under `key`, and returns the list. */
146
+ export function recordRecentSlashItem(key: string, item: SlashMenuItem): string[] {
147
+ if (!key) return [];
148
+
149
+ const itemKey = slashItemKey(item);
150
+ const keys = [itemKey, ...readRecentSlashItems(key).filter(entry => entry !== itemKey)].slice(0, RECENT_LIMIT);
151
+
152
+ try {
153
+ localStorage.setItem(RECENT_PREFIX + key, JSON.stringify(keys));
154
+ } catch {
155
+ // No storage (private browsing, quota) — the list just doesn't outlive the page
156
+ }
157
+
158
+ return keys;
159
+ }
160
+
161
+ /** Forgets the recently used items stored under `key`. */
162
+ export function clearRecentSlashItems(key: string) {
163
+ if (!key) return;
164
+
165
+ try {
166
+ localStorage.removeItem(RECENT_PREFIX + key);
167
+ } catch {
168
+ // As above
169
+ }
170
+ }
@@ -1,10 +1,11 @@
1
1
  import {autoUpdate, computePosition, flip, offset, shift, size} from '@floating-ui/dom';
2
2
  import {classMap} from 'lit/directives/class-map.js';
3
+ import {clearRecentSlashItems, readRecentSlashItems, recordRecentSlashItem, slashItemKey} from './slash-menu-items';
3
4
  import {html, unsafeCSS} from 'lit';
4
5
  import {property, query, state} from 'lit/decorators.js';
5
6
  import ZincElement from '../../internal/zinc-element';
6
7
  import ZnIcon from '../icon';
7
- import type {CSSResultGroup, PropertyValues} from 'lit';
8
+ import type {CSSResultGroup, PropertyValues, TemplateResult} from 'lit';
8
9
  import type {Placement, VirtualElement} from '@floating-ui/dom';
9
10
  import type {SlashMenuItem} from './slash-menu-items';
10
11
 
@@ -29,12 +30,19 @@ function sameItems(a: SlashMenuItem[] | undefined, b: SlashMenuItem[]): boolean
29
30
  * component driving the menu (e.g. `zn-textarea`) re-emits it as `zn-slash-select`.
30
31
  *
31
32
  * @csspart panel - The floating panel that holds the list.
32
- * @csspart heading - The panel's heading.
33
+ * @csspart list - The scrolling list of items.
33
34
  * @csspart item - An item in the list.
35
+ * @csspart icon - The chip holding an item's icon.
34
36
  * @csspart group-heading - A group heading between items.
37
+ * @csspart divider - The rule closing the recently used section, when the items below it have no heading of their own.
35
38
  * @csspart footer - The truncation footer, shown when not every match fits.
39
+ * @csspart hints - The pinned footer of keyboard hints.
40
+ * @csspart hint - A single keyboard hint within the footer.
41
+ * @csspart hint-key - The key shown against a hint.
36
42
  *
37
43
  * @cssproperty --slash-menu-width - The width of the panel.
44
+ * @cssproperty --slash-menu-border-radius - The corner radius of the panel.
45
+ * @cssproperty --slash-menu-item-border-radius - The corner radius of the items and their icon chips.
38
46
  * @cssproperty --slash-menu-max-height - The maximum height of the panel before it scrolls.
39
47
  */
40
48
  export default class ZnSlashMenu extends ZincElement {
@@ -44,6 +52,7 @@ export default class ZnSlashMenu extends ZincElement {
44
52
  };
45
53
 
46
54
  @query('.slash-menu__panel') private panel: HTMLElement;
55
+ @query('.slash-menu__list') private list: HTMLElement;
47
56
 
48
57
  private stopAutoUpdate?: () => void;
49
58
 
@@ -56,7 +65,7 @@ export default class ZnSlashMenu extends ZincElement {
56
65
  /** The query the items were matched against, shown in the heading. */
57
66
  @property() query = '';
58
67
 
59
- /** The heading shown when there is no query. */
68
+ /** The name the list is announced by when there is no query. */
60
69
  @property() heading = 'Insert';
61
70
 
62
71
  /** Shown in place of the list when there are no items. */
@@ -68,6 +77,22 @@ export default class ZnSlashMenu extends ZincElement {
68
77
  /** Hides the insertion key (the item's value) normally shown against each item. */
69
78
  @property({attribute: 'hide-keys', type: Boolean}) hideKeys = false;
70
79
 
80
+ /** Hides the pinned footer of keyboard hints. */
81
+ @property({attribute: 'hide-hints', type: Boolean}) hideHints = false;
82
+
83
+ /**
84
+ * Remembers the items chosen here and lists the most recent of them first, under their own heading.
85
+ * The key scopes the list to where the menu is used, so each place keeps its own history in
86
+ * `localStorage`. Leave unset to offer no recently used section.
87
+ */
88
+ @property({attribute: 'recent-key'}) recentKey = '';
89
+
90
+ /** The most recently used items to list. */
91
+ @property({attribute: 'max-recent', type: Number}) maxRecent = 3;
92
+
93
+ /** The heading shown above the recently used items. */
94
+ @property({attribute: 'recent-heading'}) recentHeading = 'Recently used';
95
+
71
96
  /** The element or caret rect the panel is positioned against. */
72
97
  @property({attribute: false}) anchor: Element | VirtualElement | null = null;
73
98
 
@@ -78,11 +103,41 @@ export default class ZnSlashMenu extends ZincElement {
78
103
  @property({type: Number}) distance = 4;
79
104
 
80
105
  @state() private activeIndex = 0;
106
+ @state() private recentKeys: string[] = [];
81
107
 
82
- private get visibleItems(): SlashMenuItem[] {
108
+ /** How many recently used items the last update listed, to spot the list appearing or reordering. */
109
+ private recentCount = 0;
110
+
111
+ private get listItems(): SlashMenuItem[] {
83
112
  return this.maxItems > 0 ? this.items.slice(0, this.maxItems) : this.items;
84
113
  }
85
114
 
115
+ /**
116
+ * The remembered items that are in the current list, newest first. Only offered without a query —
117
+ * once the user is searching, the ranked matches are the better answer.
118
+ */
119
+ private get recentItems(): SlashMenuItem[] {
120
+ if (!this.recentKey || this.maxRecent < 1 || this.query.trim() !== '') return [];
121
+
122
+ const available = new Map<string, SlashMenuItem>();
123
+ for (const item of this.items) {
124
+ const key = slashItemKey(item);
125
+ if (!item.disabled && !available.has(key)) available.set(key, item);
126
+ }
127
+
128
+ const recent = this.recentKeys
129
+ .map(key => available.get(key))
130
+ .filter((item): item is SlashMenuItem => item !== undefined)
131
+ .slice(0, this.maxRecent);
132
+
133
+ // A section holding everything on offer is nothing but a second copy of the list
134
+ return recent.length < this.items.length ? recent : [];
135
+ }
136
+
137
+ private get visibleItems(): SlashMenuItem[] {
138
+ return [...this.recentItems, ...this.listItems];
139
+ }
140
+
86
141
  /** The item that Enter would insert. */
87
142
  get activeItem(): SlashMenuItem | undefined {
88
143
  return this.visibleItems[this.activeIndex];
@@ -96,6 +151,12 @@ export default class ZnSlashMenu extends ZincElement {
96
151
  this.open = false;
97
152
  }
98
153
 
154
+ /** Forgets the items remembered under `recent-key`. */
155
+ clearRecent() {
156
+ clearRecentSlashItems(this.recentKey);
157
+ this.recentKeys = [];
158
+ }
159
+
99
160
  /** Sets the active item by index, wrapping at both ends and skipping disabled items. */
100
161
  setActiveIndex(index: number) {
101
162
  const items = this.visibleItems;
@@ -198,6 +259,8 @@ export default class ZnSlashMenu extends ZincElement {
198
259
  private selectItem(item: SlashMenuItem) {
199
260
  if (item.disabled) return;
200
261
 
262
+ if (this.recentKey) this.recentKeys = recordRecentSlashItem(this.recentKey, item);
263
+
201
264
  this.dispatchEvent(new CustomEvent(SLASH_ITEM_SELECT, {
202
265
  bubbles: true,
203
266
  cancelable: true,
@@ -211,17 +274,17 @@ export default class ZnSlashMenu extends ZincElement {
211
274
  if (!active) return;
212
275
 
213
276
  const items = this.visibleItems;
214
- const panel = this.panel;
277
+ const list = this.list;
215
278
 
216
- // The heading and footer scroll with the list, and `nearest` stops at the item's own box — so
217
- // landing on the first or last item goes all the way to the panel's edge to bring them back
218
- if (panel && items.slice(0, this.activeIndex).every(item => item.disabled)) {
219
- panel.scrollTop = 0;
279
+ // Group headings scroll with the items, and `nearest` stops at the item's own box — so landing
280
+ // on the first or last item goes all the way to the list's edge to bring them back
281
+ if (list && items.slice(0, this.activeIndex).every(item => item.disabled)) {
282
+ list.scrollTop = 0;
220
283
  return;
221
284
  }
222
285
 
223
- if (panel && items.slice(this.activeIndex + 1).every(item => item.disabled)) {
224
- panel.scrollTop = panel.scrollHeight;
286
+ if (list && items.slice(this.activeIndex + 1).every(item => item.disabled)) {
287
+ list.scrollTop = list.scrollHeight;
225
288
  return;
226
289
  }
227
290
 
@@ -244,9 +307,20 @@ export default class ZnSlashMenu extends ZincElement {
244
307
  protected willUpdate(changed: PropertyValues) {
245
308
  super.willUpdate(changed);
246
309
 
310
+ // Another field may share the key, so the list is re-read each time the menu is shown
311
+ if (changed.has('recentKey') || (changed.has('open') && this.open)) {
312
+ this.recentKeys = readRecentSlashItems(this.recentKey);
313
+ }
314
+
247
315
  // A genuinely new result set starts on its first selectable item. Re-resolving the same query
248
316
  // hands over an equal-but-new array, which must not move the user's place in the list.
249
- if (changed.has('items') && !sameItems(changed.get('items') as SlashMenuItem[] | undefined, this.items)) {
317
+ const isNewList = changed.has('items')
318
+ && !sameItems(changed.get('items') as SlashMenuItem[] | undefined, this.items);
319
+
320
+ // Items above the list shift every index below them, so the place is given up either way
321
+ const recentCount = this.recentItems.length;
322
+ if (isNewList || recentCount !== this.recentCount) {
323
+ this.recentCount = recentCount;
250
324
  this.activeIndex = this.visibleItems.findIndex(item => !item.disabled);
251
325
  }
252
326
  }
@@ -292,7 +366,9 @@ export default class ZnSlashMenu extends ZincElement {
292
366
  @mousedown=${this.handleItemMouseDown}>
293
367
  ${showIcons
294
368
  ? html`
295
- <span class="slash-menu__icon">
369
+ <span
370
+ part="icon"
371
+ class=${classMap({'slash-menu__icon': true, 'slash-menu__icon--empty': !item.icon})}>
296
372
  ${item.icon ? html`
297
373
  <zn-icon src=${item.icon} size="16"></zn-icon>` : ''}
298
374
  </span>`
@@ -309,41 +385,72 @@ export default class ZnSlashMenu extends ZincElement {
309
385
 
310
386
  private renderItems() {
311
387
  const items = this.visibleItems;
388
+ const recentCount = this.recentItems.length;
312
389
  const showIcons = items.some(item => item.icon);
313
390
  let lastGroup: string | undefined;
314
391
 
315
392
  return items.map((item, index) => {
316
- const group = item.group;
393
+ const group = index < recentCount ? this.recentHeading : item.group;
317
394
  const heading = group && group !== lastGroup
318
395
  ? html`
319
396
  <div part="group-heading" class="slash-menu__group-heading">${group}</div>`
320
397
  : '';
321
398
  lastGroup = group;
322
399
 
323
- return html`${heading}${this.renderItem(item, index, showIcons)}`;
400
+ // The recently used section needs closing off; a heading of its own does that for the items
401
+ // below it, and where they have none, a rule does it instead
402
+ const divider = index === recentCount && recentCount > 0 && !heading
403
+ ? html`
404
+ <div part="divider" class="slash-menu__divider"></div>`
405
+ : '';
406
+
407
+ return html`${divider}${heading}${this.renderItem(item, index, showIcons)}`;
324
408
  });
325
409
  }
326
410
 
411
+ private renderHint(keys: (TemplateResult | string)[], label: string) {
412
+ return html`
413
+ <span part="hint" class="slash-menu__hint">
414
+ ${keys.map(key => html`
415
+ <kbd part="hint-key" class="slash-menu__key">${key}</kbd>`)}
416
+ ${label}
417
+ </span>`;
418
+ }
419
+
420
+ private renderHints() {
421
+ const key = (icon: string) => html`
422
+ <zn-icon src="${icon}@lu" size="12"></zn-icon>`;
423
+
424
+ return html`
425
+ <div part="hints" class="slash-menu__hints">
426
+ ${this.renderHint([key('arrow-up'), key('arrow-down')], 'navigate')}
427
+ ${this.renderHint([key('corner-down-left')], 'select')}
428
+ ${this.renderHint(['esc'], 'dismiss')}
429
+ </div>`;
430
+ }
431
+
327
432
  render() {
328
- const hidden = this.items.length - this.visibleItems.length;
433
+ const hidden = this.items.length - this.listItems.length;
329
434
 
330
435
  return html`
331
436
  <div
332
437
  part="panel"
333
438
  class="slash-menu__panel"
334
439
  popover="manual"
335
- role="listbox"
336
- aria-hidden=${this.open ? 'false' : 'true'}
337
- aria-label=${this.query ? `Matches for ${this.query}` : this.heading}>
338
- <div part="heading" class="slash-menu__heading">
339
- ${this.query ? html`Matching <strong>${this.query}</strong>` : this.heading}
440
+ aria-hidden=${this.open ? 'false' : 'true'}>
441
+ <div
442
+ part="list"
443
+ class="slash-menu__list"
444
+ role="listbox"
445
+ aria-label=${this.query ? `Matches for ${this.query}` : this.heading}>
446
+ ${this.items.length
447
+ ? this.renderItems()
448
+ : html`
449
+ <div class="slash-menu__empty">${this.emptyText}</div>`}
450
+ ${hidden > 0 ? html`
451
+ <div part="footer" class="slash-menu__footer">${hidden} more — keep typing to narrow</div>` : ''}
340
452
  </div>
341
- ${this.items.length
342
- ? this.renderItems()
343
- : html`
344
- <div class="slash-menu__empty">${this.emptyText}</div>`}
345
- ${hidden > 0 ? html`
346
- <div part="footer" class="slash-menu__footer">${hidden} more — keep typing to narrow</div>` : ''}
453
+ ${this.hideHints ? '' : this.renderHints()}
347
454
  </div>`;
348
455
  }
349
456
  }