@kubex/zinc 1.1.101 → 1.1.103

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