@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.
- package/dist/custom-elements.json +653 -89
- package/dist/vscode.html-custom-data.json +73 -18
- package/dist/web-types.json +145 -35
- package/dist/zn.d.ts +204 -39
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +374 -308
- package/docs/pages/components/flow-builder.md +17 -1
- package/docs/pages/components/page-builder.md +31 -0
- package/docs/pages/components/slash-menu.md +132 -6
- package/docs/pages/components/textarea.md +16 -0
- package/package.json +1 -1
- package/scss/_root.scss +7 -1
- package/src/components/button/button.scss +5 -2
- package/src/components/flow-builder/flow-builder.component.ts +16 -1
- package/src/components/flow-builder/flow-builder.test.ts +206 -1
- package/src/components/flow-builder/flow-geometry.test.ts +102 -0
- package/src/components/flow-builder/flow.types.ts +82 -15
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +163 -108
- package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -0
- package/src/components/inline-edit/inline-edit.component.ts +6 -1
- package/src/components/input/input.component.ts +12 -2
- package/src/components/page/page.scss +7 -2
- package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
- package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
- package/src/components/page-builder/page-builder.component.ts +62 -4
- package/src/components/page-builder/page-builder.test.ts +94 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +227 -12
- package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +258 -0
- package/src/components/settings-container/settings-container.scss +2 -1
- package/src/components/slash-item/slash-item.component.ts +1 -1
- package/src/components/slash-menu/slash-menu-items.ts +48 -0
- package/src/components/slash-menu/slash-menu.component.ts +134 -27
- package/src/components/slash-menu/slash-menu.scss +90 -12
- package/src/components/slash-menu/slash-menu.test.ts +107 -0
- package/src/components/textarea/textarea.component.ts +12 -2
- package/src/components/textarea/textarea.test.ts +2 -2
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
277
|
+
const list = this.list;
|
|
215
278
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
if (
|
|
219
|
-
|
|
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 (
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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.
|
|
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
|
}
|