@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.
@@ -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 - 1);
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 ZnHeader from '../header';
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 select sits at the top right of the header, opposite the caption. Choosing a language switches every child at
23
- * once, and each child hides its own select while it is in a group — `grouped` is set on them here.
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`, `icon`, `flush`, `transparent` and the `footer` slot behave as they do there.
39
- * Nested inside another panel, add `inline` to drop the chrome and keep the fields aligned with the surrounding form.
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-header
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. The header belongs to the language select; nothing
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 label and select that choose the language every child is editing.
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-header': ZnHeader,
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 caption shown in the panel header. An alias for the inherited `caption`, which wins where both are set. */
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 around it
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
- /** `English (EN)`, or the code alone where the configured name already is the code. */
158
+ /** The configured name, or the code where `languages` does not name the language. */
153
159
  private displayName(language: string): string {
154
- const name = this.languages[language] ?? language.toUpperCase();
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 headerCaption = this.caption || this.label;
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
- <slot
273
- @slotchange="${this.handleSlotChange}"
274
- @zn-change="${this.handleChildChange}"></slot>
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">