@kubex/zinc 1.1.117 → 1.1.119
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 +1733 -1567
- package/dist/vscode.html-custom-data.json +146 -136
- package/dist/web-types.json +329 -309
- package/dist/zn.d.ts +74 -37
- package/dist/zn.min.js +278 -258
- package/docs/pages/components/form-group.md +2 -0
- package/docs/pages/components/translation-group.md +20 -18
- package/docs/pages/components/translations.md +3 -2
- package/package.json +1 -1
- package/src/components/form-group/form-group.component.ts +51 -77
- package/src/components/form-group/form-group.test.ts +36 -0
- package/src/components/remarkd-editor/actions.ts +2 -1
- package/src/components/remarkd-editor/remarkd-editor.component.ts +251 -9
- package/src/components/remarkd-editor/remarkd-editor.scss +49 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +329 -5
- package/src/components/translation-group/translation-group.component.ts +55 -51
- package/src/components/translation-group/translation-group.scss +10 -74
- package/src/components/translation-group/translation-group.test.ts +76 -38
- package/src/components/translations/translations.component.ts +3 -7
|
@@ -331,7 +331,7 @@ Second"></zn-remarkd-editor>`);
|
|
|
331
331
|
|
|
332
332
|
it('should group the toolbar buttons', async () => {
|
|
333
333
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
334
|
-
<zn-remarkd-editor include-url="/includes"></zn-remarkd-editor>`);
|
|
334
|
+
<zn-remarkd-editor include-url="/includes" link-url="/links"></zn-remarkd-editor>`);
|
|
335
335
|
const groups = el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar .toolbar__group');
|
|
336
336
|
|
|
337
337
|
expect(groups.length).to.be.greaterThan(5);
|
|
@@ -367,7 +367,7 @@ Second"></zn-remarkd-editor>`);
|
|
|
367
367
|
const reachableButtons = [...el.shadowRoot!.querySelectorAll<HTMLElement>('.toolbar__group')]
|
|
368
368
|
.filter(group => getComputedStyle(group).display !== 'none')
|
|
369
369
|
.flatMap(group => [...group.querySelectorAll('zn-button')]);
|
|
370
|
-
const expectedCount = EDITOR_ACTIONS.filter(action => action.opens !== 'include').length;
|
|
370
|
+
const expectedCount = EDITOR_ACTIONS.filter(action => action.opens !== 'include' && action.opens !== 'link').length;
|
|
371
371
|
expect(reachableButtons.length + menuItems.length, 'an action fell through the cracks')
|
|
372
372
|
.to.equal(expectedCount);
|
|
373
373
|
});
|
|
@@ -547,10 +547,14 @@ Second"></zn-remarkd-editor>`);
|
|
|
547
547
|
|
|
548
548
|
it('should disable inline actions when no block is being edited', async () => {
|
|
549
549
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
550
|
-
<zn-remarkd-editor value="hello"></zn-remarkd-editor>`);
|
|
550
|
+
<zn-remarkd-editor value="hello" link-url="/links"></zn-remarkd-editor>`);
|
|
551
551
|
const strong = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')]
|
|
552
552
|
.find(b => b.getAttribute('tooltip') === 'Strong')!;
|
|
553
553
|
expect(strong.hasAttribute('disabled')).to.be.true;
|
|
554
|
+
|
|
555
|
+
const link = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')]
|
|
556
|
+
.find(b => b.getAttribute('tooltip') === 'Link to article')!;
|
|
557
|
+
expect(link.hasAttribute('disabled')).to.be.true;
|
|
554
558
|
});
|
|
555
559
|
|
|
556
560
|
it('should toggle off an asymmetric mark instead of double-wrapping it', async () => {
|
|
@@ -744,7 +748,7 @@ Second"></zn-remarkd-editor>`);
|
|
|
744
748
|
|
|
745
749
|
it('should list every action in the slash menu under its group', async () => {
|
|
746
750
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
747
|
-
<zn-remarkd-editor value="Hello" include-url="/includes"></zn-remarkd-editor>`);
|
|
751
|
+
<zn-remarkd-editor value="Hello" include-url="/includes" link-url="/links"></zn-remarkd-editor>`);
|
|
748
752
|
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
749
753
|
await el.updateComplete;
|
|
750
754
|
typeInBlock(el, '/');
|
|
@@ -764,10 +768,36 @@ Second"></zn-remarkd-editor>`);
|
|
|
764
768
|
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
765
769
|
|
|
766
770
|
const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
|
|
767
|
-
expect(menu.items.length).to.equal(EDITOR_ACTIONS.length -
|
|
771
|
+
expect(menu.items.length).to.equal(EDITOR_ACTIONS.length - 2);
|
|
768
772
|
expect(menu.items.every(item => item.action !== 'include')).to.be.true;
|
|
769
773
|
});
|
|
770
774
|
|
|
775
|
+
it('should omit the article link action without a link-url', async () => {
|
|
776
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
777
|
+
<zn-remarkd-editor value="Hello" include-url="/includes"></zn-remarkd-editor>`);
|
|
778
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
779
|
+
await el.updateComplete;
|
|
780
|
+
typeInBlock(el, '/');
|
|
781
|
+
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
782
|
+
|
|
783
|
+
const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
|
|
784
|
+
expect(menu.items.length).to.equal(EDITOR_ACTIONS.length - 1);
|
|
785
|
+
expect(menu.items.every(item => item.action !== 'link')).to.be.true;
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('should offer the article link action with a link-url', async () => {
|
|
789
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
790
|
+
<zn-remarkd-editor value="Hello" include-url="/includes" link-url="/links"></zn-remarkd-editor>`);
|
|
791
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
792
|
+
await el.updateComplete;
|
|
793
|
+
typeInBlock(el, '/');
|
|
794
|
+
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
795
|
+
|
|
796
|
+
const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
|
|
797
|
+
expect(menu.items.length).to.equal(EDITOR_ACTIONS.length);
|
|
798
|
+
expect(menu.items.some(item => item.action === 'link' && item.label === 'Link to article')).to.be.true;
|
|
799
|
+
});
|
|
800
|
+
|
|
771
801
|
it('should not open the slash menu part-way through a block', async () => {
|
|
772
802
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
773
803
|
<zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
|
|
@@ -1343,4 +1373,298 @@ const x = {product};
|
|
|
1343
1373
|
await el.updateComplete;
|
|
1344
1374
|
expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__var').length).to.equal(0);
|
|
1345
1375
|
});
|
|
1376
|
+
|
|
1377
|
+
/** Stubs the link options endpoint, capturing every URL it is called with. */
|
|
1378
|
+
function stubLinkSearch(items: {ref: string; kind: string; title: string; context?: string; status?: string}[]) {
|
|
1379
|
+
const original = window.fetch;
|
|
1380
|
+
const calls: string[] = [];
|
|
1381
|
+
window.fetch = (input: RequestInfo | URL) => {
|
|
1382
|
+
calls.push(String(input));
|
|
1383
|
+
return Promise.resolve(new Response(JSON.stringify({items}),
|
|
1384
|
+
{headers: {'Content-Type': 'application/json'}}));
|
|
1385
|
+
};
|
|
1386
|
+
afterEach(() => {
|
|
1387
|
+
window.fetch = original;
|
|
1388
|
+
});
|
|
1389
|
+
return calls;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
it('should search the link endpoint from the picker', async () => {
|
|
1393
|
+
const calls = stubLinkSearch([
|
|
1394
|
+
{ref: 'kb:document/doc1', kind: 'document', title: 'Refunds', context: 'Billing', status: 'published'},
|
|
1395
|
+
]);
|
|
1396
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1397
|
+
<zn-remarkd-editor value="Hello" link-url="/links"></zn-remarkd-editor>`);
|
|
1398
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
1399
|
+
await el.updateComplete;
|
|
1400
|
+
// "article" matches only this action's label — "/link" would leave the plain
|
|
1401
|
+
// Link action active, since its label sorts into the same band first.
|
|
1402
|
+
const input = typeInBlock(el, '/article');
|
|
1403
|
+
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
1404
|
+
input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
|
|
1405
|
+
|
|
1406
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker'),
|
|
1407
|
+
'the link picker never opened');
|
|
1408
|
+
const filter = el.shadowRoot!.querySelector<HTMLInputElement>('.remarkd-editor__link-filter')!;
|
|
1409
|
+
filter.value = 'refund';
|
|
1410
|
+
filter.dispatchEvent(new Event('input', {bubbles: true}));
|
|
1411
|
+
|
|
1412
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'),
|
|
1413
|
+
'no results rendered');
|
|
1414
|
+
expect(calls.some(url => url.includes('/links?q=refund'))).to.be.true;
|
|
1415
|
+
const option = el.shadowRoot!.querySelector('.remarkd-editor__link-option')!;
|
|
1416
|
+
expect(option.querySelector('.remarkd-editor__link-option-title')!.textContent).to.contain('Refunds');
|
|
1417
|
+
expect(option.querySelector('.remarkd-editor__link-option-meta')!.textContent).to.contain('Billing');
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
it('should keep the link picker open when opened from the overflow menu', async () => {
|
|
1421
|
+
stubLinkSearch([]);
|
|
1422
|
+
// Narrow enough that every action, including "Link to article", collapses into the
|
|
1423
|
+
// overflow menu — the toolbar-bar buttons guard against blur with @mousedown
|
|
1424
|
+
// preventDefault, but a menu item does not, so this is the path that can race.
|
|
1425
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1426
|
+
<zn-remarkd-editor value="Hello" link-url="/links" style="width: 240px"></zn-remarkd-editor>`);
|
|
1427
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
1428
|
+
await el.updateComplete;
|
|
1429
|
+
|
|
1430
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more'),
|
|
1431
|
+
'the overflow trigger never appeared');
|
|
1432
|
+
const dropdown = el.shadowRoot!.querySelector<ZnDropdown>('.remarkd-editor__toolbar-more')!;
|
|
1433
|
+
await dropdown.show();
|
|
1434
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
1435
|
+
|
|
1436
|
+
const linkItem = [...dropdown.querySelectorAll('zn-menu-item')]
|
|
1437
|
+
.find(item => item.textContent?.includes('Link to article'));
|
|
1438
|
+
expect(linkItem, 'the article-link action should be reachable through the overflow menu').to.exist;
|
|
1439
|
+
// ZnButton overrides click() without dispatching a DOM event, so the menu item's
|
|
1440
|
+
// own click listener (and zn-menu's selection handling) needs a real event.
|
|
1441
|
+
linkItem!.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
1442
|
+
await el.updateComplete;
|
|
1443
|
+
|
|
1444
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker'),
|
|
1445
|
+
'the link picker never opened');
|
|
1446
|
+
// The dropdown's own @zn-hide fires as the menu closes after the item is chosen; it must
|
|
1447
|
+
// not clear suppressBlurCommit out from under the picker it just opened.
|
|
1448
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__link-picker'),
|
|
1449
|
+
'the picker unmounted, so the deferred blur committed the edit and closed the block')
|
|
1450
|
+
.to.exist;
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
it('should apply only the newest search response when an older one resolves later', async () => {
|
|
1454
|
+
const resolvers: Record<string, (response: Response) => void> = {};
|
|
1455
|
+
const original = window.fetch;
|
|
1456
|
+
window.fetch = (input: RequestInfo | URL) => {
|
|
1457
|
+
const q = new URL(String(input), window.location.href).searchParams.get('q') ?? '';
|
|
1458
|
+
return new Promise<Response>(resolve => {
|
|
1459
|
+
resolvers[q] = resolve;
|
|
1460
|
+
});
|
|
1461
|
+
};
|
|
1462
|
+
afterEach(() => {
|
|
1463
|
+
window.fetch = original;
|
|
1464
|
+
});
|
|
1465
|
+
const respond = (items: {ref: string; kind: string; title: string}[]) =>
|
|
1466
|
+
new Response(JSON.stringify({items}), {headers: {'Content-Type': 'application/json'}});
|
|
1467
|
+
|
|
1468
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1469
|
+
<zn-remarkd-editor value="Hello" link-url="/links"></zn-remarkd-editor>`);
|
|
1470
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
1471
|
+
await el.updateComplete;
|
|
1472
|
+
const input = typeInBlock(el, '/article');
|
|
1473
|
+
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
1474
|
+
input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
|
|
1475
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker'),
|
|
1476
|
+
'the link picker never opened');
|
|
1477
|
+
|
|
1478
|
+
const filter = el.shadowRoot!.querySelector<HTMLInputElement>('.remarkd-editor__link-filter')!;
|
|
1479
|
+
filter.value = 'first';
|
|
1480
|
+
filter.dispatchEvent(new Event('input', {bubbles: true}));
|
|
1481
|
+
await aTimeout(250);
|
|
1482
|
+
filter.value = 'second';
|
|
1483
|
+
filter.dispatchEvent(new Event('input', {bubbles: true}));
|
|
1484
|
+
await aTimeout(250);
|
|
1485
|
+
|
|
1486
|
+
// Resolve out of order: the stale "first" request settles after the current
|
|
1487
|
+
// "second" one, and must not be allowed to overwrite it.
|
|
1488
|
+
resolvers['second']!(respond([{ref: 'kb:document/second', kind: 'document', title: 'Second Result'}]));
|
|
1489
|
+
await aTimeout(0);
|
|
1490
|
+
resolvers['first']!(respond([{ref: 'kb:document/first', kind: 'document', title: 'First Result'}]));
|
|
1491
|
+
|
|
1492
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'),
|
|
1493
|
+
'no results rendered');
|
|
1494
|
+
// An option already exists from the "second" response, so waiting on its mere
|
|
1495
|
+
// presence would race the still-settling "first" response — give it time to
|
|
1496
|
+
// land (and, unguarded, overwrite) before asserting it did not.
|
|
1497
|
+
await aTimeout(50);
|
|
1498
|
+
const titles = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__link-option-title')]
|
|
1499
|
+
.map(title => title.textContent);
|
|
1500
|
+
expect(titles).to.eql(['Second Result']);
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it('should report a failed link search', async () => {
|
|
1504
|
+
const original = window.fetch;
|
|
1505
|
+
window.fetch = () => Promise.resolve(new Response('nope', {status: 500}));
|
|
1506
|
+
afterEach(() => {
|
|
1507
|
+
window.fetch = original;
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1511
|
+
<zn-remarkd-editor value="Hello" link-url="/links"></zn-remarkd-editor>`);
|
|
1512
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
1513
|
+
await el.updateComplete;
|
|
1514
|
+
const input = typeInBlock(el, '/article');
|
|
1515
|
+
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
1516
|
+
input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
|
|
1517
|
+
|
|
1518
|
+
// The debounced request is still pending when the empty state first renders
|
|
1519
|
+
// ("Searching…"), so wait for the failed message itself, not just the div.
|
|
1520
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker-empty')
|
|
1521
|
+
?.textContent?.includes('Could not search'), 'the picker never settled');
|
|
1522
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__link-picker-empty')!.textContent)
|
|
1523
|
+
.to.contain('Could not search');
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
it('should close the link picker without touching the block', async () => {
|
|
1527
|
+
stubLinkSearch([]);
|
|
1528
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1529
|
+
<zn-remarkd-editor value="Hello" link-url="/links"></zn-remarkd-editor>`);
|
|
1530
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
1531
|
+
await el.updateComplete;
|
|
1532
|
+
const input = typeInBlock(el, '/article');
|
|
1533
|
+
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
1534
|
+
input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
|
|
1535
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker'),
|
|
1536
|
+
'the link picker never opened');
|
|
1537
|
+
|
|
1538
|
+
// ZnButton overrides .click() to skip dispatching a real click event, so the
|
|
1539
|
+
// host's own @click listener needs one dispatched directly, as elsewhere in this file.
|
|
1540
|
+
el.shadowRoot!.querySelector<ZnButton>('.remarkd-editor__link-picker zn-button')!
|
|
1541
|
+
.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
1542
|
+
await el.updateComplete;
|
|
1543
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__link-picker')).to.not.exist;
|
|
1544
|
+
expect(el.value).to.equal('Hello');
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* Opens the picker from the toolbar over the open block, with the given
|
|
1549
|
+
* selection — the slash path never has one, so the toolbar is the only way to
|
|
1550
|
+
* exercise a selected label. The fixtures below are 4000px wide so no toolbar
|
|
1551
|
+
* group has collapsed into the overflow menu.
|
|
1552
|
+
*/
|
|
1553
|
+
async function openLinkPicker(el: ZnRemarkdEditor, text: string, from: number, to: number) {
|
|
1554
|
+
await aTimeout(50);
|
|
1555
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
1556
|
+
await el.updateComplete;
|
|
1557
|
+
const input = typeInBlock(el, text);
|
|
1558
|
+
input.setSelectionRange(from, to);
|
|
1559
|
+
el.shadowRoot!.querySelector<ZnButton>('zn-button[tooltip="Link to article"]')!
|
|
1560
|
+
.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
1561
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker'),
|
|
1562
|
+
'the link picker never opened');
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
it('should wrap the selected text as the link label', async () => {
|
|
1566
|
+
stubLinkSearch([{ref: 'kb:document/doc1', kind: 'document', title: 'Refunds'}]);
|
|
1567
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1568
|
+
<zn-remarkd-editor style="width: 4000px" value="Read Refunds now" link-url="/links"></zn-remarkd-editor>`);
|
|
1569
|
+
await openLinkPicker(el, 'Read Refunds now', 5, 12);
|
|
1570
|
+
|
|
1571
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'), 'no results');
|
|
1572
|
+
el.shadowRoot!.querySelector<HTMLButtonElement>('.remarkd-editor__link-option')!.click();
|
|
1573
|
+
await el.updateComplete;
|
|
1574
|
+
|
|
1575
|
+
const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
|
|
1576
|
+
expect(input.value).to.equal('Read [Refunds](kb:document/doc1) now');
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
it('should insert the title as the label with no selection', async () => {
|
|
1580
|
+
stubLinkSearch([{ref: 'kb:document/doc1', kind: 'document', title: 'Refunds [and] more'}]);
|
|
1581
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1582
|
+
<zn-remarkd-editor style="width: 4000px" value="See " link-url="/links"></zn-remarkd-editor>`);
|
|
1583
|
+
await openLinkPicker(el, 'See ', 4, 4);
|
|
1584
|
+
|
|
1585
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'), 'no results');
|
|
1586
|
+
el.shadowRoot!.querySelector<HTMLButtonElement>('.remarkd-editor__link-option')!.click();
|
|
1587
|
+
await el.updateComplete;
|
|
1588
|
+
|
|
1589
|
+
const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
|
|
1590
|
+
expect(input.value).to.equal('See [Refunds (and) more](kb:document/doc1)');
|
|
1591
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__link-picker')).to.not.exist;
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
it('should collapse each CRLF character in the title to a space, matching Go byte-for-byte', async () => {
|
|
1595
|
+
// Label formatting must match app-kb's model.ContentLinkMarkup character for character.
|
|
1596
|
+
stubLinkSearch([{ref: 'kb:document/doc1', kind: 'document', title: 'Contact\r\nnow'}]);
|
|
1597
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1598
|
+
<zn-remarkd-editor style="width: 4000px" value="See " link-url="/links"></zn-remarkd-editor>`);
|
|
1599
|
+
await openLinkPicker(el, 'See ', 4, 4);
|
|
1600
|
+
|
|
1601
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'), 'no results');
|
|
1602
|
+
el.shadowRoot!.querySelector<HTMLButtonElement>('.remarkd-editor__link-option')!.click();
|
|
1603
|
+
await el.updateComplete;
|
|
1604
|
+
|
|
1605
|
+
const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
|
|
1606
|
+
expect(input.value).to.equal('See [Contact now](kb:document/doc1)');
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
it('should commit the inserted link to the value', async () => {
|
|
1610
|
+
stubLinkSearch([{ref: 'kb:category/cat1', kind: 'category', title: 'Billing'}]);
|
|
1611
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1612
|
+
<zn-remarkd-editor style="width: 4000px" value="See " link-url="/links"></zn-remarkd-editor>`);
|
|
1613
|
+
await openLinkPicker(el, 'See ', 4, 4);
|
|
1614
|
+
|
|
1615
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'), 'no results');
|
|
1616
|
+
el.shadowRoot!.querySelector<HTMLButtonElement>('.remarkd-editor__link-option')!.click();
|
|
1617
|
+
await el.updateComplete;
|
|
1618
|
+
el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!
|
|
1619
|
+
.dispatchEvent(new Event('blur', {bubbles: true}));
|
|
1620
|
+
await el.updateComplete;
|
|
1621
|
+
|
|
1622
|
+
expect(el.value).to.equal('See [Billing](kb:category/cat1)');
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
it('should flag a link whose target the app does not know', async () => {
|
|
1626
|
+
const calls = stubLinkSearch([{ref: 'kb:document/doc1', kind: 'document', title: 'Refunds'}]);
|
|
1627
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1628
|
+
<zn-remarkd-editor link-url="/links"
|
|
1629
|
+
value="See [Refunds](kb:document/doc1) and [Gone](kb:document/doc9)."></zn-remarkd-editor>`);
|
|
1630
|
+
|
|
1631
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link--missing'),
|
|
1632
|
+
'the missing state never rendered');
|
|
1633
|
+
expect(calls.some(url => url.includes('refs=') && url.includes('doc9'))).to.be.true;
|
|
1634
|
+
|
|
1635
|
+
const anchors = [...el.shadowRoot!.querySelectorAll<HTMLAnchorElement>('a[href^="kb:"]')];
|
|
1636
|
+
const healthy = anchors.find(a => a.getAttribute('href') === 'kb:document/doc1')!;
|
|
1637
|
+
const missing = anchors.find(a => a.getAttribute('href') === 'kb:document/doc9')!;
|
|
1638
|
+
expect(healthy.classList.contains('remarkd-editor__link--missing')).to.be.false;
|
|
1639
|
+
expect(missing.classList.contains('remarkd-editor__link--missing')).to.be.true;
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
// A list that never arrived is not evidence a link is broken.
|
|
1643
|
+
it('should not flag links when the resolve request fails', async () => {
|
|
1644
|
+
const original = window.fetch;
|
|
1645
|
+
const calls: string[] = [];
|
|
1646
|
+
window.fetch = (input: RequestInfo | URL) => {
|
|
1647
|
+
calls.push(String(input));
|
|
1648
|
+
return Promise.resolve(new Response('nope', {status: 500}));
|
|
1649
|
+
};
|
|
1650
|
+
afterEach(() => {
|
|
1651
|
+
window.fetch = original;
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1655
|
+
<zn-remarkd-editor link-url="/links"
|
|
1656
|
+
value="See [Gone](kb:document/doc9)."></zn-remarkd-editor>`);
|
|
1657
|
+
await aTimeout(200);
|
|
1658
|
+
expect(calls.some(url => url.includes('refs=') && url.includes('doc9'))).to.be.true;
|
|
1659
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__link--missing')).to.not.exist;
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
it('should not flag links without a link-url', async () => {
|
|
1663
|
+
const calls = stubLinkSearch([]);
|
|
1664
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
1665
|
+
<zn-remarkd-editor value="See [Gone](kb:document/doc9)."></zn-remarkd-editor>`);
|
|
1666
|
+
await aTimeout(50);
|
|
1667
|
+
expect(calls).to.have.length(0);
|
|
1668
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__link--missing')).to.not.exist;
|
|
1669
|
+
});
|
|
1346
1670
|
});
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import {classMap} from 'lit/directives/class-map.js';
|
|
2
2
|
import {type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS} from 'lit';
|
|
3
3
|
import {HasSlotController} from '../../internal/slot';
|
|
4
|
-
import {ifDefined} from 'lit/directives/if-defined.js';
|
|
5
4
|
import {property, state} from 'lit/decorators.js';
|
|
6
5
|
import ZnChip from '../chip';
|
|
7
|
-
import
|
|
6
|
+
import ZnFormGroup from '../form-group';
|
|
8
7
|
import ZnOption from '../option';
|
|
9
8
|
import ZnPanel from '../panel/panel.component';
|
|
10
9
|
import ZnSelect from '../select';
|
|
@@ -19,8 +18,12 @@ import styles from './translation-group.scss';
|
|
|
19
18
|
* @status experimental
|
|
20
19
|
* @since 1.0
|
|
21
20
|
*
|
|
22
|
-
* The
|
|
23
|
-
*
|
|
21
|
+
* The fields sit in a `zn-form-group`, so the caption, help text and the language select share its label column and
|
|
22
|
+
* the fields line up with every other form group around them. Choosing a language switches every child at once, and
|
|
23
|
+
* each child hides its own select while it is in a group — `grouped` is set on them here.
|
|
24
|
+
*
|
|
25
|
+
* The select is searchable. Each option holds its language code as its value, so typing `de` finds German without the
|
|
26
|
+
* code being on show.
|
|
24
27
|
*
|
|
25
28
|
* Closed, the select carries how many target languages are done — `1/5`. Its options each carry a chip aggregated
|
|
26
29
|
* across the children:
|
|
@@ -35,11 +38,11 @@ import styles from './translation-group.scss';
|
|
|
35
38
|
* The children own their values; this component only chooses which language is shown and reports on what they hold.
|
|
36
39
|
* It reads them back on every child `zn-change`, so the chips and the count follow an edit as it is typed.
|
|
37
40
|
*
|
|
38
|
-
* Extends `zn-panel`, so `caption`, `
|
|
39
|
-
*
|
|
41
|
+
* Extends `zn-panel`, so `caption`, `flush`, `transparent` and the `footer` slot behave as they do there. Nested
|
|
42
|
+
* inside another panel, add `inline` to drop the chrome and keep the fields aligned with the surrounding form.
|
|
40
43
|
*
|
|
41
44
|
* @dependency zn-chip
|
|
42
|
-
* @dependency zn-
|
|
45
|
+
* @dependency zn-form-group
|
|
43
46
|
* @dependency zn-option
|
|
44
47
|
* @dependency zn-select
|
|
45
48
|
*
|
|
@@ -49,30 +52,33 @@ import styles from './translation-group.scss';
|
|
|
49
52
|
* @slot actions - Buttons for the bottom of the panel, on the white body rather than the grey footer. They sit on
|
|
50
53
|
* the right, as zinc's form action rows do; `align="start"` moves one to the left. Write them in the order they
|
|
51
54
|
* should be read — the sides are set by CSS ordering, so markup order is what a keyboard follows.
|
|
52
|
-
* @slot footer - Content displayed in the grey panel footer.
|
|
53
|
-
* else is slotted into it.
|
|
55
|
+
* @slot footer - Content displayed in the grey panel footer.
|
|
54
56
|
*
|
|
55
57
|
* @csspart base - The component's base wrapper.
|
|
58
|
+
* @csspart form-group - The form group holding the caption, the language select and the fields.
|
|
56
59
|
* @csspart actions - The row of buttons at the bottom of the body.
|
|
57
|
-
* @csspart language-field - The
|
|
60
|
+
* @csspart language-field - The select that chooses the language every child is editing, in the group's chip slot.
|
|
58
61
|
* @csspart language-select - The select itself.
|
|
59
62
|
*/
|
|
60
63
|
export default class ZnTranslationGroup extends ZnPanel {
|
|
61
64
|
static styles: CSSResultGroup = [ZnPanel.styles, unsafeCSS(styles)];
|
|
62
65
|
static dependencies = {
|
|
63
66
|
'zn-chip': ZnChip,
|
|
64
|
-
'zn-
|
|
67
|
+
'zn-form-group': ZnFormGroup,
|
|
65
68
|
'zn-option': ZnOption,
|
|
66
69
|
'zn-select': ZnSelect
|
|
67
70
|
};
|
|
68
71
|
|
|
69
72
|
private readonly _slotController = new HasSlotController(this, 'actions', 'footer');
|
|
70
73
|
|
|
71
|
-
/** The
|
|
74
|
+
/** The form group's label. An alias for the inherited `caption`, which wins where both are set. */
|
|
72
75
|
@property() label = '';
|
|
73
76
|
|
|
77
|
+
/** Sits under the label, above the language select, as help text does in any other form group. */
|
|
78
|
+
@property({attribute: 'help-text'}) helpText = '';
|
|
79
|
+
|
|
74
80
|
/**
|
|
75
|
-
* Drops the panel chrome — border, background and padding — so the group reads as a section of the form
|
|
81
|
+
* Drops the panel chrome — border, background and padding — so the group reads as a section of the surrounding form
|
|
76
82
|
* rather than a panel of its own. For groups nested inside another panel, where the fields would otherwise sit
|
|
77
83
|
* indented behind a second border.
|
|
78
84
|
*/
|
|
@@ -149,11 +155,9 @@ export default class ZnTranslationGroup extends ZnPanel {
|
|
|
149
155
|
return {type: 'error', label: language === 'en' ? 'Empty' : 'English'};
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
/**
|
|
158
|
+
/** The configured name, or the code where `languages` does not name the language. */
|
|
153
159
|
private displayName(language: string): string {
|
|
154
|
-
|
|
155
|
-
const code = language.toUpperCase();
|
|
156
|
-
return name.toUpperCase() === code ? name : `${name} (${code})`;
|
|
160
|
+
return this.languages[language] ?? language.toUpperCase();
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
/** Children take their language list from the group, so a change to `languages` has to reach them. */
|
|
@@ -206,7 +210,7 @@ export default class ZnTranslationGroup extends ZnPanel {
|
|
|
206
210
|
render() {
|
|
207
211
|
const hasActionsSlot = this._slotController.test('actions');
|
|
208
212
|
const hasFooterSlot = this._slotController.test('footer');
|
|
209
|
-
const
|
|
213
|
+
const caption = this.caption || this.label;
|
|
210
214
|
|
|
211
215
|
// A child's value can carry a language `languages` does not list — server-rendered content outliving a config
|
|
212
216
|
// change. Offer those too, or the translation is stranded in the value with no way to reach it.
|
|
@@ -216,7 +220,6 @@ export default class ZnTranslationGroup extends ZnPanel {
|
|
|
216
220
|
.forEach(code => extra.add(code)));
|
|
217
221
|
const languageCodes = [...Object.keys(this.languages), ...extra];
|
|
218
222
|
const hasMultipleLanguages = languageCodes.length > 1;
|
|
219
|
-
const hasHeader = Boolean(headerCaption) || hasMultipleLanguages;
|
|
220
223
|
|
|
221
224
|
// English is the source every other language falls back to, so it is not itself one of the translations counted.
|
|
222
225
|
const targets = languageCodes.filter(code => code !== 'en');
|
|
@@ -229,49 +232,50 @@ export default class ZnTranslationGroup extends ZnPanel {
|
|
|
229
232
|
};
|
|
230
233
|
|
|
231
234
|
return html`
|
|
232
|
-
<div class="${classMap({
|
|
235
|
+
<div part="base" class="${classMap({
|
|
233
236
|
panel: true,
|
|
234
237
|
'panel--flush': this.flush || this.inline,
|
|
235
238
|
'panel--transparent': this.transparent || this.inline,
|
|
236
239
|
'translation-group--inline': this.inline,
|
|
237
|
-
'panel--has-header': hasHeader,
|
|
238
240
|
'panel--has-actions': hasActionsSlot,
|
|
239
241
|
'panel--has-footer': hasFooterSlot,
|
|
240
242
|
})}">
|
|
241
243
|
|
|
242
244
|
<div class="panel__inner">
|
|
243
|
-
${hasHeader ? html`
|
|
244
|
-
<zn-header class="panel__header"
|
|
245
|
-
caption="${ifDefined(headerCaption || undefined)}"
|
|
246
|
-
transparent>
|
|
247
|
-
${hasMultipleLanguages ? html`
|
|
248
|
-
<div slot="actions" class="translation-group__language-field" part="language-field">
|
|
249
|
-
<zn-select
|
|
250
|
-
label="${this.languageLabel}"
|
|
251
|
-
class="translation-group__language-select"
|
|
252
|
-
part="language-select"
|
|
253
|
-
hoist
|
|
254
|
-
.value="${this._activeLanguage}"
|
|
255
|
-
@zn-change="${this.handleLanguageSelect}"
|
|
256
|
-
@zn-input="${this.handleLanguageInput}">
|
|
257
|
-
<zn-chip slot="suffix" type="${summary.type}">${summary.label}</zn-chip>
|
|
258
|
-
${languageCodes.map(code => {
|
|
259
|
-
const optionState = this.languageState(code);
|
|
260
|
-
return html`
|
|
261
|
-
<zn-option value="${code}">
|
|
262
|
-
${this.displayName(code)}
|
|
263
|
-
<zn-chip slot="suffix" type="${optionState.type}">${optionState.label}</zn-chip>
|
|
264
|
-
</zn-option>`;
|
|
265
|
-
})}
|
|
266
|
-
</zn-select>
|
|
267
|
-
</div>` : nothing}
|
|
268
|
-
</zn-header>` : null}
|
|
269
|
-
|
|
270
245
|
<div class="panel__content">
|
|
271
246
|
<div class="panel__body">
|
|
272
|
-
<
|
|
273
|
-
|
|
274
|
-
|
|
247
|
+
<zn-form-group
|
|
248
|
+
part="form-group"
|
|
249
|
+
label="${caption}"
|
|
250
|
+
help-text="${this.helpText}">
|
|
251
|
+
|
|
252
|
+
${hasMultipleLanguages ? html`
|
|
253
|
+
<div slot="chip" class="translation-group__language-field" part="language-field">
|
|
254
|
+
<zn-select
|
|
255
|
+
label="${this.languageLabel}"
|
|
256
|
+
class="translation-group__language-select"
|
|
257
|
+
part="language-select"
|
|
258
|
+
hoist
|
|
259
|
+
search
|
|
260
|
+
.value="${this._activeLanguage}"
|
|
261
|
+
@zn-change="${this.handleLanguageSelect}"
|
|
262
|
+
@zn-input="${this.handleLanguageInput}">
|
|
263
|
+
<zn-chip slot="suffix" type="${summary.type}">${summary.label}</zn-chip>
|
|
264
|
+
${languageCodes.map(code => {
|
|
265
|
+
const optionState = this.languageState(code);
|
|
266
|
+
return html`
|
|
267
|
+
<zn-option value="${code}">
|
|
268
|
+
${this.displayName(code)}
|
|
269
|
+
<zn-chip slot="suffix" type="${optionState.type}">${optionState.label}</zn-chip>
|
|
270
|
+
</zn-option>`;
|
|
271
|
+
})}
|
|
272
|
+
</zn-select>
|
|
273
|
+
</div>` : nothing}
|
|
274
|
+
|
|
275
|
+
<slot
|
|
276
|
+
@slotchange="${this.handleSlotChange}"
|
|
277
|
+
@zn-change="${this.handleChildChange}"></slot>
|
|
278
|
+
</zn-form-group>
|
|
275
279
|
|
|
276
280
|
${hasActionsSlot ? html`
|
|
277
281
|
<div class="translation-group__actions" part="actions">
|