@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.
- package/dist/custom-elements.json +260 -81
- package/dist/vscode.html-custom-data.json +13 -13
- package/dist/web-types.json +25 -25
- package/dist/zn.d.ts +124 -4
- package/dist/zn.min.js +359 -282
- package/docs/pages/components/remarkd-editor.md +59 -0
- package/package.json +1 -1
- package/src/components/menu/menu.component.ts +4 -1
- package/src/components/menu/menu.scss +3 -0
- package/src/components/remarkd-editor/actions.ts +159 -0
- package/src/components/remarkd-editor/feature-keys.ts +17 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +509 -49
- package/src/components/remarkd-editor/remarkd-editor.scss +68 -1
- package/src/components/remarkd-editor/remarkd-editor.test.ts +675 -9
- package/src/components/slash-menu/slash-menu.scss +2 -0
- package/src/internal/toolbar-overflow.ts +100 -0
|
@@ -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
|
|
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
|
|
541
|
-
|
|
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
|
|
578
|
-
|
|
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
|
|
627
|
-
|
|
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
|
});
|