@kubex/zinc 1.1.101 → 1.1.102

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.
@@ -1,6 +1,10 @@
1
1
  import '../../../dist/zn.min.js';
2
- import {expect, fixture, html, waitUntil} from '@open-wc/testing';
2
+ import {aTimeout, expect, fixture, html, waitUntil} from '@open-wc/testing';
3
+ import {EDITOR_ACTIONS} from './actions';
4
+ import {FEATURE_KEYS} from './feature-keys';
5
+ import {parse as remarkdParse} from 'remarkd-js';
3
6
  import type ZnButton from '../button';
7
+ import type ZnDropdown from '../dropdown';
4
8
  import type ZnRemarkdEditor from './remarkd-editor.component';
5
9
  import type ZnSlashMenu from '../slash-menu';
6
10
 
@@ -14,6 +18,103 @@ function typeInBlock(el: ZnRemarkdEditor, value: string): HTMLTextAreaElement {
14
18
  }
15
19
 
16
20
  describe('<zn-remarkd-editor>', () => {
21
+ it('should insert source that renders to real output for every action', async () => {
22
+ // Rendered by the editor's own chrome rather than the parser, or needing surrounding
23
+ // content to produce output — measured, not assumed:
24
+ // attributes-title → `.Intro` alone is empty; a title decorates the block below it
25
+ // reference-list → `{{reflist}}` is empty until something references it
26
+ // asciidoc-section → `== ` alone is empty; the section needs a body the author types
27
+ // comments → `////` blocks are meant to render nothing, that is the feature
28
+ // hardbreaks → `[%hardbreaks]` alone is an attribute line; it needs the paragraph
29
+ // below it (real fixture: attribute line directly followed by body,
30
+ // no blank line) that the helper's `\n\n` placeholder trick can't reach
31
+ const rendersEmpty = ['attributes-title', 'reference-list', 'asciidoc-section', 'comments', 'hardbreaks'];
32
+ // Logic actions all render through the editor's own chrome (a variable chip or a
33
+ // conditional wrapper) rather than the parser — listed explicitly, not by group, so a
34
+ // future logic action with no chrome of its own falls through and fails this guard.
35
+ const rendersViaChrome = ['document-attributes', 'conditionals', 'ifndef', 'ifeval',
36
+ 'iftrue', 'iffalse', 'ifempty', 'ifnempty'];
37
+ const failures: string[] = [];
38
+ const el = await fixture<ZnRemarkdEditor>(html`<zn-remarkd-editor></zn-remarkd-editor>`);
39
+
40
+ for (const action of EDITOR_ACTIONS) {
41
+ if (action.opens || action.inline) continue;
42
+
43
+ // The prefix must survive the editor's own block splitter intact — a delimiter pair
44
+ // with a blank line inside (e.g. Quote's `____`) that splitBlocks does not recognise
45
+ // fractures into several separately draggable/deletable blocks the instant it lands.
46
+ if (action.prefix?.includes('\n')) {
47
+ el.value = action.prefix;
48
+ await el.updateComplete;
49
+ const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length;
50
+ if (blocks !== 1) failures.push(`${action.key}: split into ${blocks} blocks`);
51
+ }
52
+
53
+ if (rendersViaChrome.includes(action.key) || rendersEmpty.includes(action.key)) continue;
54
+ const source = (action.prefix ?? '').replace(/\n\n/g, '\nplaceholder\n') || 'text';
55
+ const rendered = remarkdParse(source);
56
+ if (rendered.includes('section--empty')) failures.push(`${action.key}: ${JSON.stringify(source)}`);
57
+ }
58
+
59
+ expect(failures, `actions rendering nothing: ${failures.join(', ')}`).to.be.empty;
60
+ });
61
+
62
+ it('should fall back to the raw source when the parser throws', async () => {
63
+ // The parser's only throw source is `readFileSync`, reached only when
64
+ // `process.getBuiltinModule` resolves to a real `node:fs` — this harness's `process` shim
65
+ // does not provide one, so force it to prove `safeParse`'s catch branch actually fires.
66
+ const proc = window as unknown as {process?: {getBuiltinModule?: (name: string) => unknown}};
67
+ const original = proc.process;
68
+ proc.process = {
69
+ getBuiltinModule: () => ({
70
+ existsSync: () => true,
71
+ readFileSync: () => {
72
+ throw Object.assign(new Error('EISDIR: illegal operation on a directory, read'), {code: 'EISDIR'});
73
+ },
74
+ }),
75
+ };
76
+
77
+ try {
78
+ const el = await fixture<ZnRemarkdEditor>(html`
79
+ <zn-remarkd-editor value="t::partial::"></zn-remarkd-editor>`);
80
+ await el.updateComplete;
81
+
82
+ const rendered = el.shadowRoot!.querySelector('.remarkd-editor__rendered')!;
83
+ expect(rendered.querySelector('.remarkd-editor__unparsed')).to.exist;
84
+ expect(rendered.textContent).to.contain('t::partial::');
85
+ expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1);
86
+ } finally {
87
+ proc.process = original;
88
+ }
89
+ });
90
+
91
+ it('should not throw for any action, however incomplete', async () => {
92
+ // In this harness `process.getBuiltinModule` is absent, so remarkd-js's own fallback
93
+ // ("File not found") fires for `t::partial::` instead of throwing — either way the
94
+ // block must render without crashing the editor.
95
+ const el = await fixture<ZnRemarkdEditor>(html`
96
+ <zn-remarkd-editor value="t::partial::"></zn-remarkd-editor>`);
97
+ await el.updateComplete;
98
+
99
+ const rendered = el.shadowRoot!.querySelector('.remarkd-editor__rendered')!;
100
+ expect(rendered.textContent).to.contain('File not found');
101
+ expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1);
102
+ });
103
+
104
+ it('should have an action for every insertable remarkd feature', () => {
105
+ // Fixtures that describe parser behaviour or combine other features, so they are not actions.
106
+ const notActions = ['url-formatting-chars', 'inline-advanced', 'object-fallbacks',
107
+ 'conditionals-advanced', 'smart-quotes', 'typographic-symbols', 'emoji-aliases', 'autolink'];
108
+ // Real actions, deliberately not offered: they render fine in this editor's TS parser but
109
+ // not in the Go renderer the user's app ships in production, tested by the user directly.
110
+ // `id-block` is additionally broken in remarkd itself (its own fixture enshrines the leak).
111
+ const unsupportedByProductionRenderer = ['id-block', 'table', 'pros-cons', 'accordion'];
112
+ const covered = new Set(EDITOR_ACTIONS.map(a => a.key));
113
+ const excluded = [...notActions, ...unsupportedByProductionRenderer];
114
+ const missing = FEATURE_KEYS.filter(f => !excluded.includes(f) && !covered.has(f));
115
+ expect(missing, `features with no action: ${missing.join(', ')}`).to.be.empty;
116
+ });
117
+
17
118
  it('should render a component', async () => {
18
119
  const el = await fixture(html`
19
120
  <zn-remarkd-editor></zn-remarkd-editor>`);
@@ -37,6 +138,152 @@ NOTE: a note"></zn-remarkd-editor>`);
37
138
  expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1);
38
139
  });
39
140
 
141
+ it('should keep a conditional range as a single block', async () => {
142
+ const source = 'ifdef::flag[]\n\nInside the conditional\n\nendif::[]';
143
+ const el = await fixture<ZnRemarkdEditor>(html`
144
+ <zn-remarkd-editor value=${source}></zn-remarkd-editor>`);
145
+
146
+ expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1);
147
+ expect(el.value).to.equal(source);
148
+ });
149
+
150
+ it('should keep nested conditionals in the same block', async () => {
151
+ const source = 'ifdef::outer[]\nifdef::inner[]\nDeep\nendif::[]\nendif::[]\n\nAfter';
152
+ const el = await fixture<ZnRemarkdEditor>(html`
153
+ <zn-remarkd-editor value=${source}></zn-remarkd-editor>`);
154
+
155
+ const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block');
156
+ expect(blocks.length).to.equal(2);
157
+ expect(el.value).to.equal(source);
158
+ });
159
+
160
+ it('should leave an inline conditional as an ordinary block', async () => {
161
+ const el = await fixture<ZnRemarkdEditor>(html`
162
+ <zn-remarkd-editor value="iftrue::truth[Shown inline]
163
+
164
+ After"></zn-remarkd-editor>`);
165
+ expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(2);
166
+ });
167
+
168
+ it('should label a conditional and still render its content', async () => {
169
+ const el = await fixture<ZnRemarkdEditor>(html`
170
+ <zn-remarkd-editor value="ifdef::flag[]
171
+ Inner content
172
+ endif::[]"></zn-remarkd-editor>`);
173
+ await el.updateComplete;
174
+
175
+ const wrapper = el.shadowRoot!.querySelector('.remarkd-editor__conditional')!;
176
+ expect(wrapper, 'no conditional wrapper').to.exist;
177
+ expect(wrapper.textContent).to.contain('flag');
178
+ // The content must survive: evaluating would hide it, since `flag` is undefined.
179
+ expect(wrapper.textContent).to.contain('Inner content');
180
+ });
181
+
182
+ it('should label a negative conditional differently', async () => {
183
+ const el = await fixture<ZnRemarkdEditor>(html`
184
+ <zn-remarkd-editor value="ifndef::flag[]
185
+ Fallback
186
+ endif::[]"></zn-remarkd-editor>`);
187
+ await el.updateComplete;
188
+ const label = el.shadowRoot!.querySelector('.remarkd-editor__conditional-label')!;
189
+ expect(label.textContent).to.contain('not defined');
190
+ });
191
+
192
+ it('should render a nested conditional as two nested labelled wrappers, without losing content', async () => {
193
+ const el = await fixture<ZnRemarkdEditor>(html`
194
+ <zn-remarkd-editor value="ifdef::outer[]
195
+ ifdef::inner[]
196
+ Deep
197
+ endif::[]
198
+ endif::[]"></zn-remarkd-editor>`);
199
+ await el.updateComplete;
200
+
201
+ const wrappers = el.shadowRoot!.querySelectorAll('.remarkd-editor__conditional');
202
+ expect(wrappers.length, 'expected an outer and an inner wrapper').to.equal(2);
203
+ expect(wrappers[0].textContent).to.contain('outer');
204
+ expect(wrappers[1].textContent).to.contain('inner');
205
+ // parse() evaluates a directive handed to it verbatim, which is exactly how 'Deep' used
206
+ // to get silently dropped: the unstripped `ifdef::inner[]` line parsed to an empty section.
207
+ expect(wrappers[1].textContent).to.contain('Deep');
208
+ });
209
+
210
+ it('should render content before and after a nested conditional in the same wrapper', async () => {
211
+ const el = await fixture<ZnRemarkdEditor>(html`
212
+ <zn-remarkd-editor value="ifdef::outer[]
213
+ Before
214
+
215
+ ifdef::inner[]
216
+ Nested
217
+ endif::[]
218
+
219
+ After
220
+ endif::[]"></zn-remarkd-editor>`);
221
+ await el.updateComplete;
222
+
223
+ const wrappers = el.shadowRoot!.querySelectorAll('.remarkd-editor__conditional');
224
+ expect(wrappers.length).to.equal(2);
225
+ // Exercises the plain-content-run splitting on both sides of the nested range, not just
226
+ // a single run either before or after it.
227
+ expect(wrappers[0].textContent).to.contain('Before');
228
+ expect(wrappers[0].textContent).to.contain('Nested');
229
+ expect(wrappers[0].textContent).to.contain('After');
230
+ });
231
+
232
+ it('should not lose content from a conditional nested inside an unclosed range', async () => {
233
+ const el = await fixture<ZnRemarkdEditor>(html`
234
+ <zn-remarkd-editor value="ifdef::outer[]
235
+ Before
236
+ ifdef::inner[]
237
+ Deep"></zn-remarkd-editor>`);
238
+ await el.updateComplete;
239
+
240
+ const wrappers = el.shadowRoot!.querySelectorAll('.remarkd-editor__conditional');
241
+ expect(wrappers.length, 'expected an outer and an inner wrapper').to.equal(2);
242
+ expect(wrappers[0].textContent).to.contain('Before');
243
+ // Neither ifdef has a closing endif::[] anywhere in the source; the nested one must
244
+ // still show its content rather than being evaluated (and blanked) as an unmatched directive.
245
+ expect(wrappers[1].textContent).to.contain('Deep');
246
+ });
247
+
248
+ it('should mark and render each single-line conditional form, never evaluating it', async () => {
249
+ // Measured against the real parser: iftrue/iffalse/ifnempty match no rule of its own and
250
+ // render an empty section, and ifempty is genuinely evaluated — none of that may reach
251
+ // the reader; the bracket text must render, visibly, inside a labelled wrapper instead.
252
+ const cases: [string, string][] = [
253
+ ['iftrue::flag[Shown]', 'is true'],
254
+ ['iffalse::flag[Shown]', 'is false'],
255
+ ['ifempty::flag[Shown]', 'is empty'],
256
+ ['ifnempty::flag[Shown]', 'is not empty'],
257
+ ];
258
+
259
+ for (const [source, expectedLabel] of cases) {
260
+ const el = await fixture<ZnRemarkdEditor>(html`
261
+ <zn-remarkd-editor value=${source}></zn-remarkd-editor>`);
262
+ await el.updateComplete;
263
+
264
+ const wrapper = el.shadowRoot!.querySelector('.remarkd-editor__conditional');
265
+ expect(wrapper, `${source}: no conditional wrapper`).to.exist;
266
+ expect(wrapper!.textContent, source).to.contain('flag');
267
+ expect(wrapper!.textContent, source).to.contain(expectedLabel);
268
+ expect(wrapper!.textContent, source).to.contain('Shown');
269
+ expect(el.value, source).to.equal(source);
270
+ }
271
+ });
272
+
273
+ it('should keep a table with blank rows as a single block', async () => {
274
+ const table = '.Data\n[striped=true]\n|===\n|Name |Value\n\n|Alpha |1\n\n|Beta |2\n|===';
275
+ const el = await fixture<ZnRemarkdEditor>(html`
276
+ <zn-remarkd-editor value=${table}></zn-remarkd-editor>`);
277
+
278
+ expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1);
279
+ expect(el.value).to.equal(table);
280
+
281
+ // The rows must land in the body; a split table renders them as more headers.
282
+ const rendered = el.shadowRoot!.querySelector('.remarkd-editor__rendered')!;
283
+ expect(rendered.querySelectorAll('tbody tr').length).to.equal(2);
284
+ expect(rendered.querySelectorAll('thead th').length).to.equal(2);
285
+ });
286
+
40
287
  it('should swap a block to source editing on click and commit on blur', async () => {
41
288
  const el = await fixture<ZnRemarkdEditor>(html`
42
289
  <zn-remarkd-editor value="# Title"></zn-remarkd-editor>`);
@@ -80,6 +327,120 @@ Second"></zn-remarkd-editor>`);
80
327
  expect(el.shadowRoot!.querySelector('.remarkd-editor__toolbar')).to.exist;
81
328
  });
82
329
 
330
+ it('should group the toolbar buttons', async () => {
331
+ const el = await fixture<ZnRemarkdEditor>(html`
332
+ <zn-remarkd-editor include-url="/includes"></zn-remarkd-editor>`);
333
+ const groups = el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar .toolbar__group');
334
+
335
+ expect(groups.length).to.be.greaterThan(5);
336
+ expect([...groups].map(g => g.getAttribute('data-group'))).to.include('admonitions');
337
+ // Every non-picker action reachable from the toolbar.
338
+ const buttons = el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar .toolbar__group zn-button');
339
+ expect(buttons.length).to.equal(EDITOR_ACTIONS.length);
340
+ });
341
+
342
+ it('should move toolbar groups into an overflow menu when narrow', async () => {
343
+ const el = await fixture<ZnRemarkdEditor>(html`
344
+ <zn-remarkd-editor style="width: 240px"></zn-remarkd-editor>`);
345
+ await el.updateComplete;
346
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more'),
347
+ 'the overflow trigger never appeared');
348
+
349
+ const collapsed = [...el.shadowRoot!.querySelectorAll<HTMLElement>('.toolbar__group')]
350
+ .filter(group => getComputedStyle(group).display === 'none');
351
+ expect(collapsed.length, 'nothing collapsed at 240px').to.be.greaterThan(0);
352
+
353
+ // No action is lost: the collapsed groups' actions are all in the menu.
354
+ const menuItems = el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar-more zn-menu-item');
355
+ expect(menuItems.length).to.be.greaterThan(0);
356
+
357
+ // The trigger itself must stay on-screen — a group that doesn't fit must not force its
358
+ // way into the bar and push the trigger past the host's own `overflow: hidden` edge.
359
+ const toolbar = el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__toolbar')!;
360
+ const trigger = el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__toolbar-more')!;
361
+ expect(trigger.getBoundingClientRect().right, 'the overflow trigger is clipped off-screen')
362
+ .to.be.at.most(toolbar.getBoundingClientRect().right + 1);
363
+
364
+ // Every action is reachable somewhere: either as a visible bar button or a menu item.
365
+ const reachableButtons = [...el.shadowRoot!.querySelectorAll<HTMLElement>('.toolbar__group')]
366
+ .filter(group => getComputedStyle(group).display !== 'none')
367
+ .flatMap(group => [...group.querySelectorAll('zn-button')]);
368
+ const expectedCount = EDITOR_ACTIONS.filter(action => action.opens !== 'include').length;
369
+ expect(reachableButtons.length + menuItems.length, 'an action fell through the cracks')
370
+ .to.equal(expectedCount);
371
+ });
372
+
373
+ it('should scroll the overflow menu instead of running off the viewport', async () => {
374
+ const el = await fixture<ZnRemarkdEditor>(html`
375
+ <zn-remarkd-editor style="width: 240px"></zn-remarkd-editor>`);
376
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more'),
377
+ 'the overflow trigger never appeared');
378
+
379
+ const dropdown = el.shadowRoot!.querySelector<ZnDropdown>('.remarkd-editor__toolbar-more')!;
380
+ await dropdown.show();
381
+ // The popup's auto-size pass lands an animation frame after 'zn-after-show' fires — same
382
+ // wait used for the same reason in expanding-action's drop-panel overflow tests.
383
+ await new Promise(resolve => requestAnimationFrame(resolve));
384
+
385
+ // At 240px, every action lands in the menu — enough zn-menu-items to overflow the
386
+ // max-height. The scroll container is zn-menu's own inner `.menu` div (set via the
387
+ // --zn-menu-max-height custom property), not the zn-menu host itself: a constraint on
388
+ // the host leaves the host with room to spare, so a wheel over an item finds `.menu`
389
+ // has nothing to scroll and never chains out to the host.
390
+ const menu = dropdown.querySelector('zn-menu')!;
391
+ const inner = menu.shadowRoot!.querySelector<HTMLElement>('.menu')!;
392
+ expect(inner.scrollHeight, 'the menu needs room to scroll').to.be.greaterThan(inner.clientHeight);
393
+ expect(getComputedStyle(inner).overflow).to.equal('auto');
394
+ });
395
+
396
+ it('should apply an inline action reached through the overflow menu, keeping the block open', async () => {
397
+ // At 800px, 'text' (the first group) fits but 'inline' (~638px right after it) does
398
+ // not, so every inline action — including Strong — lives only in the overflow menu.
399
+ const el = await fixture<ZnRemarkdEditor>(html`
400
+ <zn-remarkd-editor value="hello world" style="width: 800px"></zn-remarkd-editor>`);
401
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
402
+ await el.updateComplete;
403
+ const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
404
+ input.setSelectionRange(0, 5);
405
+
406
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more'),
407
+ 'the overflow trigger never appeared');
408
+ const dropdown = el.shadowRoot!.querySelector<ZnDropdown>('.remarkd-editor__toolbar-more')!;
409
+ await dropdown.show();
410
+ await new Promise(resolve => requestAnimationFrame(resolve));
411
+
412
+ // Opening the trigger moves focus off the textarea in real use — zn-dropdown's
413
+ // handleTriggerClick calls focusOnTrigger() right after show(). A synthetic click on the
414
+ // trigger wouldn't actually move focus in a test DOM, so the blur is reproduced directly.
415
+ input.dispatchEvent(new Event('blur'));
416
+
417
+ const strong = [...dropdown.querySelectorAll('zn-menu-item')]
418
+ .find(item => item.textContent?.includes('Strong'));
419
+ expect(strong, 'Strong should be reachable through the overflow menu').to.exist;
420
+ strong!.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
421
+ await el.updateComplete;
422
+
423
+ // If the blur had committed the edit, editingIndex would be null: the menu item would
424
+ // read as disabled and applyInline would find no textarea to act on.
425
+ const result = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
426
+ expect(result.value, 'the block must stay open through the overflow-menu interaction')
427
+ .to.equal('**hello** world');
428
+ });
429
+
430
+ it('should show every toolbar group when wide', async () => {
431
+ // The full toolbar (all ~70 actions) measures ~3100px, so 2000px would still collapse
432
+ // some groups; this width comfortably fits everything with room to spare.
433
+ const el = await fixture<ZnRemarkdEditor>(html`
434
+ <zn-remarkd-editor style="width: 4000px"></zn-remarkd-editor>`);
435
+ await el.updateComplete;
436
+ await aTimeout(50);
437
+
438
+ const collapsed = [...el.shadowRoot!.querySelectorAll<HTMLElement>('.toolbar__group')]
439
+ .filter(group => getComputedStyle(group).display === 'none');
440
+ expect(collapsed.length).to.equal(0);
441
+ expect(el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more')).to.not.exist;
442
+ });
443
+
83
444
  /** Every icon-only button needs a name of its own — it has no text for a screen reader to read. */
84
445
  async function expectNamedIconButtons(el: ZnRemarkdEditor, root: ParentNode = el.shadowRoot!) {
85
446
  const iconOnly = [...root.querySelectorAll<ZnButton>('zn-button')].filter(b => !b.textContent!.trim());
@@ -110,6 +471,157 @@ Second"></zn-remarkd-editor>`);
110
471
  await expectNamedIconButtons(el, el.shadowRoot!.querySelector('.remarkd-editor__block')!);
111
472
  });
112
473
 
474
+ it('should wrap the selection with an inline action', async () => {
475
+ const el = await fixture<ZnRemarkdEditor>(html`
476
+ <zn-remarkd-editor value="hello world"></zn-remarkd-editor>`);
477
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
478
+ await el.updateComplete;
479
+
480
+ const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
481
+ input.setSelectionRange(0, 5);
482
+ const strong = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')]
483
+ .find(b => b.getAttribute('tooltip') === 'Strong')!;
484
+ strong.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
485
+ await el.updateComplete;
486
+
487
+ const result = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
488
+ expect(result.value).to.equal('**hello** world');
489
+ // The wrapped text stays selected, so a second click (or typing) acts on it, not the marks.
490
+ expect([result.selectionStart, result.selectionEnd]).to.eql([2, 7]);
491
+ });
492
+
493
+ it('should insert a placeholder when nothing is selected', async () => {
494
+ const el = await fixture<ZnRemarkdEditor>(html`
495
+ <zn-remarkd-editor value="hello"></zn-remarkd-editor>`);
496
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
497
+ await el.updateComplete;
498
+ const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
499
+ input.setSelectionRange(5, 5);
500
+
501
+ [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')]
502
+ .find(b => b.getAttribute('tooltip') === 'Strong')!
503
+ .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
504
+ await el.updateComplete;
505
+
506
+ const result = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
507
+ expect(result.value).to.equal('hello**text**');
508
+ // Placeholder selected so typing replaces it.
509
+ expect([result.selectionStart, result.selectionEnd]).to.eql([7, 11]);
510
+ });
511
+
512
+ it('should unwrap a selection already carrying the mark', async () => {
513
+ const el = await fixture<ZnRemarkdEditor>(html`
514
+ <zn-remarkd-editor value="**hello** world"></zn-remarkd-editor>`);
515
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
516
+ await el.updateComplete;
517
+ const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
518
+ input.setSelectionRange(2, 7); // hello, inside the marks
519
+ [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')]
520
+ .find(b => b.getAttribute('tooltip') === 'Strong')!
521
+ .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
522
+ await el.updateComplete;
523
+
524
+ expect(el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!.value)
525
+ .to.equal('hello world');
526
+ });
527
+
528
+ it('should not corrupt a shorter mark nested inside a longer sibling of the same character', async () => {
529
+ const el = await fixture<ZnRemarkdEditor>(html`
530
+ <zn-remarkd-editor value="~~hello~~ world"></zn-remarkd-editor>`);
531
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
532
+ await el.updateComplete;
533
+ const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
534
+ input.setSelectionRange(2, 7); // hello, inside the strike marks
535
+
536
+ [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')]
537
+ .find(b => b.getAttribute('tooltip') === 'Subscript')!
538
+ .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
539
+ await el.updateComplete;
540
+
541
+ // Ambiguous remarkd, but the strike marks must survive intact rather than being stripped.
542
+ expect(el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!.value)
543
+ .to.equal('~~~hello~~~ world');
544
+ });
545
+
546
+ it('should disable inline actions when no block is being edited', async () => {
547
+ const el = await fixture<ZnRemarkdEditor>(html`
548
+ <zn-remarkd-editor value="hello"></zn-remarkd-editor>`);
549
+ const strong = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')]
550
+ .find(b => b.getAttribute('tooltip') === 'Strong')!;
551
+ expect(strong.hasAttribute('disabled')).to.be.true;
552
+ });
553
+
554
+ it('should toggle off an asymmetric mark instead of double-wrapping it', async () => {
555
+ const el = await fixture<ZnRemarkdEditor>(html`
556
+ <zn-remarkd-editor value="([Label](https://))"></zn-remarkd-editor>`);
557
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
558
+ await el.updateComplete;
559
+ const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
560
+ input.setSelectionRange(2, 7); // Label, inside the link markup
561
+
562
+ [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')]
563
+ .find(b => b.getAttribute('tooltip') === 'Link')!
564
+ .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
565
+ await el.updateComplete;
566
+
567
+ // A `)` trailing the mark must not be mistaken for a repeated-delimiter run; the mark
568
+ // toggles off cleanly rather than double-wrapping.
569
+ expect(el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!.value)
570
+ .to.equal('(Label)');
571
+ });
572
+
573
+ it('should unwrap a link whose target is a real URL, not just the placeholder tail', async () => {
574
+ // A literal-text unwrap check only recognises "](https://)" — the placeholder itself. A
575
+ // real document never contains that; it contains a real URL, which must unwrap too.
576
+ const el = await fixture<ZnRemarkdEditor>(html`
577
+ <zn-remarkd-editor value="[Label](https://example.com)"></zn-remarkd-editor>`);
578
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
579
+ await el.updateComplete;
580
+ const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
581
+ input.setSelectionRange(1, 6); // Label, inside the real link markup
582
+
583
+ [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')]
584
+ .find(b => b.getAttribute('tooltip') === 'Link')!
585
+ .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
586
+ await el.updateComplete;
587
+
588
+ expect(el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!.value)
589
+ .to.equal('Label');
590
+ });
591
+
592
+ it('should honour a block action\'s caretOffset from the toolbar, matching the slash menu', async () => {
593
+ const el = await fixture<ZnRemarkdEditor>(html`
594
+ <zn-remarkd-editor></zn-remarkd-editor>`);
595
+ // Two actions share the "Code" tooltip (inline-code in the inline group, code-fence in
596
+ // the blocks group) — disambiguate by the group the button actually lives in.
597
+ const codeButton = [...el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button')]
598
+ .find(b => b.getAttribute('tooltip') === 'Code'
599
+ && b.closest('.toolbar__group')?.getAttribute('data-group') === 'blocks')!;
600
+ codeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
601
+ await el.updateComplete;
602
+
603
+ const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
604
+ expect(input.value).to.equal('```\n\n```');
605
+ // caretOffset: 4 — right on the blank interior line, same as inserting "/code" would.
606
+ expect([input.selectionStart, input.selectionEnd]).to.eql([4, 4]);
607
+ });
608
+
609
+ it('should prevent the toolbar mousedown default so a real click keeps focus and selection in the textarea', async () => {
610
+ const el = await fixture<ZnRemarkdEditor>(html`
611
+ <zn-remarkd-editor value="hello world"></zn-remarkd-editor>`);
612
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
613
+ await el.updateComplete;
614
+
615
+ const strong = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')]
616
+ .find(b => b.getAttribute('tooltip') === 'Strong')!;
617
+ const mousedown = new MouseEvent('mousedown', {bubbles: true, composed: true, cancelable: true});
618
+ strong.dispatchEvent(mousedown);
619
+
620
+ // If this were not prevented, the mousedown would shift focus off the textarea, blurring
621
+ // and committing the edit before the click even fires — disabling the button underneath it.
622
+ expect(mousedown.defaultPrevented).to.be.true;
623
+ });
624
+
113
625
  it('should give every image control icon button an accessible name', async () => {
114
626
  const el = await fixture<ZnRemarkdEditor>(html`
115
627
  <zn-remarkd-editor value="image::photo.png[Alt,640,480]"></zn-remarkd-editor>`);
@@ -228,6 +740,32 @@ Second"></zn-remarkd-editor>`);
228
740
  expect(menu.activeItem?.label).to.equal('Text');
229
741
  });
230
742
 
743
+ it('should list every action in the slash menu under its group', async () => {
744
+ const el = await fixture<ZnRemarkdEditor>(html`
745
+ <zn-remarkd-editor value="Hello" include-url="/includes"></zn-remarkd-editor>`);
746
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
747
+ await el.updateComplete;
748
+ typeInBlock(el, '/');
749
+ await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
750
+
751
+ const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
752
+ expect(menu.items.length).to.equal(EDITOR_ACTIONS.length);
753
+ expect(menu.items.some(item => item.group === 'Admonitions')).to.be.true;
754
+ });
755
+
756
+ it('should omit the Include action from the slash menu without an include-url', async () => {
757
+ const el = await fixture<ZnRemarkdEditor>(html`
758
+ <zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
759
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
760
+ await el.updateComplete;
761
+ typeInBlock(el, '/');
762
+ await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
763
+
764
+ const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
765
+ expect(menu.items.length).to.equal(EDITOR_ACTIONS.length - 1);
766
+ expect(menu.items.every(item => item.action !== 'include')).to.be.true;
767
+ });
768
+
231
769
  it('should not open the slash menu part-way through a block', async () => {
232
770
  const el = await fixture<ZnRemarkdEditor>(html`
233
771
  <zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
@@ -260,6 +798,26 @@ Second"></zn-remarkd-editor>`);
260
798
  expect(el.shadowRoot!.querySelector('zn-slash-menu[open]')).to.not.exist;
261
799
  });
262
800
 
801
+ it('should insert the full inline construct from the slash menu, not just the opener', async () => {
802
+ const el = await fixture<ZnRemarkdEditor>(html`
803
+ <zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
804
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
805
+ await el.updateComplete;
806
+
807
+ const input = typeInBlock(el, '/strong');
808
+ await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
809
+
810
+ const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
811
+ expect(menu.activeItem?.label).to.equal('Strong');
812
+
813
+ input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
814
+ await el.updateComplete;
815
+
816
+ // Full construct, not a bare unclosed opener — caret lands at the placeholder's start.
817
+ expect(input.value).to.equal('**text**');
818
+ expect([input.selectionStart, input.selectionEnd]).to.eql([2, 2]);
819
+ });
820
+
263
821
  it('should start a new block on shift+enter', async () => {
264
822
  const el = await fixture<ZnRemarkdEditor>(html`
265
823
  <zn-remarkd-editor value="First"></zn-remarkd-editor>`);
@@ -298,8 +856,7 @@ Second"></zn-remarkd-editor>`);
298
856
  it('should show an inline zn-file picker from the toolbar image button', async () => {
299
857
  const el = await fixture<ZnRemarkdEditor>(html`
300
858
  <zn-remarkd-editor attachment-url="/upload"></zn-remarkd-editor>`);
301
- const buttons = el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button');
302
- const imageButton = buttons[buttons.length - 1];
859
+ const imageButton = el.shadowRoot!.querySelector('.remarkd-editor__toolbar zn-button[tooltip="Image"]')!;
303
860
  imageButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
304
861
  await el.updateComplete;
305
862
 
@@ -537,8 +1094,8 @@ include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
537
1094
  const el = await fixture<ZnRemarkdEditor>(html`
538
1095
  <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
539
1096
 
540
- const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
541
- const includeButton = buttons[buttons.length - 1];
1097
+ const includeButton = el.shadowRoot!
1098
+ .querySelector<HTMLElement>('.remarkd-editor__toolbar zn-button[tooltip="Include"]')!;
542
1099
  includeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
543
1100
 
544
1101
  await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-option'),
@@ -574,8 +1131,9 @@ include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
574
1131
  ]);
575
1132
  const el = await fixture<ZnRemarkdEditor>(html`
576
1133
  <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
577
- const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
578
- buttons[buttons.length - 1].dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
1134
+ const includeButton = el.shadowRoot!
1135
+ .querySelector<HTMLElement>('.remarkd-editor__toolbar zn-button[tooltip="Include"]')!;
1136
+ includeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
579
1137
  await waitUntil(() => el.shadowRoot!.querySelectorAll('.remarkd-editor__include-option').length === 2,
580
1138
  'the include picker never listed both');
581
1139
 
@@ -623,8 +1181,9 @@ include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
623
1181
  try {
624
1182
  const el = await fixture<ZnRemarkdEditor>(html`
625
1183
  <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
626
- const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
627
- buttons[buttons.length - 1].dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
1184
+ const includeButton = el.shadowRoot!
1185
+ .querySelector<HTMLElement>('.remarkd-editor__toolbar zn-button[tooltip="Include"]')!;
1186
+ includeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
628
1187
 
629
1188
  await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-picker-empty')?.textContent
630
1189
  ?.includes('Could not load'), 'the picker never reported the failure');
@@ -675,4 +1234,111 @@ include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
675
1234
  expect(el.shadowRoot!.querySelector<HTMLAnchorElement>('.remarkd-editor__include-link')!
676
1235
  .getAttribute('href')).to.equal('/global/includes/inc-1');
677
1236
  });
1237
+
1238
+ it('should render an attribute definition as a variable chip', async () => {
1239
+ const el = await fixture<ZnRemarkdEditor>(html`
1240
+ <zn-remarkd-editor value=":product: Remarkd"></zn-remarkd-editor>`);
1241
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!;
1242
+ expect(chip, 'no variable chip').to.exist;
1243
+ expect(chip.textContent).to.contain('product');
1244
+ expect(chip.textContent).to.contain('Remarkd');
1245
+ });
1246
+
1247
+ it('should render a title-only block as a chip', async () => {
1248
+ const el = await fixture<ZnRemarkdEditor>(html`
1249
+ <zn-remarkd-editor value=".Intro"></zn-remarkd-editor>`);
1250
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!;
1251
+ expect(chip, 'no title chip').to.exist;
1252
+ expect(chip.textContent).to.contain('Intro');
1253
+ });
1254
+
1255
+ it('should render a bracketed attribute line as a chip', async () => {
1256
+ const el = await fixture<ZnRemarkdEditor>(html`
1257
+ <zn-remarkd-editor value="[%hardbreaks]"></zn-remarkd-editor>`);
1258
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!;
1259
+ expect(chip, 'no bracket chip').to.exist;
1260
+ expect(chip.textContent).to.contain('[%hardbreaks]');
1261
+ });
1262
+
1263
+ it('should render a title immediately followed by content normally, not as a chip', async () => {
1264
+ const el = await fixture<ZnRemarkdEditor>(html`
1265
+ <zn-remarkd-editor value=".Data
1266
+ |===
1267
+ |A |B
1268
+
1269
+ |1 |2
1270
+ |==="></zn-remarkd-editor>`);
1271
+ await el.updateComplete;
1272
+ const rendered = el.shadowRoot!.querySelector('.remarkd-editor__rendered')!;
1273
+ expect(rendered.querySelector('.remarkd-editor__variable'), 'should not chip mixed content').to.not.exist;
1274
+ expect(rendered.textContent).to.contain('Data');
1275
+ });
1276
+
1277
+ // remarkd drops a lone-period paragraph entirely (verified against the parser directly:
1278
+ // parse('.NET is a popular framework.') returns section--empty) — treating it as a title
1279
+ // chip surfaces content the parser would otherwise silently discard, not the reverse.
1280
+ it('should chip a period-led sentence remarkd would otherwise render empty', async () => {
1281
+ const el = await fixture<ZnRemarkdEditor>(html`
1282
+ <zn-remarkd-editor value=".NET is a popular framework."></zn-remarkd-editor>`);
1283
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!;
1284
+ expect(chip, 'no title chip').to.exist;
1285
+ expect(chip.textContent).to.contain('NET is a popular framework.');
1286
+ });
1287
+
1288
+ // Same reasoning for a bracket-only line that happens to look like a citation marker:
1289
+ // parse('[1]') also returns section--empty, so this is content the chip rescues.
1290
+ it('should chip a bracketed line remarkd would otherwise render empty', async () => {
1291
+ const el = await fixture<ZnRemarkdEditor>(html`
1292
+ <zn-remarkd-editor value="[1]"></zn-remarkd-editor>`);
1293
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!;
1294
+ expect(chip, 'no bracket chip').to.exist;
1295
+ expect(chip.textContent).to.contain('[1]');
1296
+ });
1297
+
1298
+ // markVariables must never touch the meta-chip branch's Lit-owned ChildPart: it assumes its
1299
+ // next sibling stays a Text node, so wrapping a {name} inside it and then re-rendering with a
1300
+ // different value would leave a stale token or silently freeze the display. Scoping
1301
+ // markVariables to the parser-rendered branch only (remarkd-editor__rendered--parsed) is what
1302
+ // this test guards.
1303
+ it('should refresh a metadata chip cleanly when its value changes, leaving no stale token', async () => {
1304
+ const el = await fixture<ZnRemarkdEditor>(html`
1305
+ <zn-remarkd-editor value=":tip: See {syntax} for details"></zn-remarkd-editor>`);
1306
+ await el.updateComplete;
1307
+
1308
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
1309
+ await el.updateComplete;
1310
+
1311
+ const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
1312
+ input.value = ':tip: See {other} for details';
1313
+ input.dispatchEvent(new Event('input', {bubbles: true}));
1314
+ input.dispatchEvent(new Event('blur'));
1315
+ await el.updateComplete;
1316
+
1317
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!;
1318
+ expect(chip, 'no variable chip after edit').to.exist;
1319
+ expect(chip.textContent).to.contain('other');
1320
+ expect(chip.textContent).to.not.contain('syntax');
1321
+ // Never evaluated: still the raw {other} reference, not resolved to a value.
1322
+ expect(chip.textContent).to.contain('{other}');
1323
+ });
1324
+
1325
+ it('should mark a variable reference in rendered text', async () => {
1326
+ const el = await fixture<ZnRemarkdEditor>(html`
1327
+ <zn-remarkd-editor value="This is {product} here"></zn-remarkd-editor>`);
1328
+ await el.updateComplete;
1329
+ const tokens = el.shadowRoot!.querySelectorAll('.remarkd-editor__var');
1330
+ expect(tokens.length).to.equal(1);
1331
+ expect(tokens[0].textContent).to.equal('{product}');
1332
+ // Not evaluated: the source is untouched.
1333
+ expect(el.value).to.equal('This is {product} here');
1334
+ });
1335
+
1336
+ it('should leave braces in code alone', async () => {
1337
+ const el = await fixture<ZnRemarkdEditor>(html`
1338
+ <zn-remarkd-editor value="\`\`\`
1339
+ const x = {product};
1340
+ \`\`\`"></zn-remarkd-editor>`);
1341
+ await el.updateComplete;
1342
+ expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__var').length).to.equal(0);
1343
+ });
678
1344
  });