@kubex/zinc 1.1.94 → 1.1.95

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 (36) hide show
  1. package/dist/custom-elements.json +995 -196
  2. package/dist/vscode.html-custom-data.json +74 -19
  3. package/dist/web-types.json +147 -37
  4. package/dist/zn.d.ts +335 -59
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +467 -355
  7. package/docs/pages/components/page-builder.md +121 -19
  8. package/docs/pages/components/slash-menu.md +132 -6
  9. package/docs/pages/components/textarea.md +16 -0
  10. package/package.json +1 -1
  11. package/scss/_root.scss +7 -1
  12. package/src/components/button/button.scss +5 -2
  13. package/src/components/inline-edit/inline-edit.component.ts +6 -1
  14. package/src/components/input/input.component.ts +12 -2
  15. package/src/components/page/page.scss +7 -2
  16. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
  17. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
  18. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
  19. package/src/components/page-builder/page-builder.component.ts +483 -225
  20. package/src/components/page-builder/page-builder.scss +64 -10
  21. package/src/components/page-builder/page-builder.test.ts +656 -110
  22. package/src/components/page-builder/page-tree.test.ts +483 -0
  23. package/src/components/page-builder/page-tree.ts +329 -0
  24. package/src/components/page-builder/page.types.ts +75 -7
  25. package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
  26. package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
  27. package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
  28. package/src/components/settings-container/settings-container.scss +2 -1
  29. package/src/components/slash-item/slash-item.component.ts +1 -1
  30. package/src/components/slash-menu/slash-menu-items.ts +48 -0
  31. package/src/components/slash-menu/slash-menu.component.ts +134 -27
  32. package/src/components/slash-menu/slash-menu.scss +90 -12
  33. package/src/components/slash-menu/slash-menu.test.ts +107 -0
  34. package/src/components/textarea/textarea.component.ts +12 -2
  35. package/src/components/textarea/textarea.test.ts +2 -2
  36. package/src/components/translations/translations.component.ts +5 -1
@@ -28,6 +28,7 @@ interface BlockType {
28
28
  /** Where the caret lands within `prefix`. Defaults to the end. */
29
29
  caretOffset?: number;
30
30
  image?: boolean;
31
+ include?: boolean;
31
32
  }
32
33
 
33
34
  interface ImageBlockData {
@@ -39,6 +40,21 @@ interface ImageBlockData {
39
40
  height: string;
40
41
  }
41
42
 
43
+ interface IncludeBlockData {
44
+ target: string;
45
+ label: string;
46
+ }
47
+
48
+ interface IncludeOption {
49
+ id: string;
50
+ title: string;
51
+ description?: string;
52
+ scope?: string;
53
+ keywords?: string[];
54
+ languages?: string;
55
+ url?: string;
56
+ }
57
+
42
58
  const BLOCK_TYPES: BlockType[] = [
43
59
  {label: 'Text', icon: 'text@lu', prefix: ''},
44
60
  {label: 'Heading 1', icon: 'heading-1@lu', prefix: '# '},
@@ -49,11 +65,25 @@ const BLOCK_TYPES: BlockType[] = [
49
65
  {label: 'Warning', icon: 'triangle-alert@lu', prefix: 'WARNING: '},
50
66
  {label: 'Code', icon: 'code@lu', prefix: '```\n\n```', caretOffset: 4},
51
67
  {label: 'Image', icon: 'image@lu', image: true},
68
+ {label: 'Include', icon: 'blocks@lu', include: true},
52
69
  ];
53
70
 
71
+ /** remarkd's include directive on a line of its own: `include::<target>[<label>]`. */
72
+ const INCLUDE_LINE = /^include::([^[]+)\[(.*)]\s*$/;
73
+
74
+ /**
75
+ * The marker a document body carries for an embed. Mirrors app-kb's
76
+ * `model.IncludeEmbedMarker`: brackets and newlines in the title would break the
77
+ * directive, so they are replaced rather than escaped.
78
+ */
79
+ function includeMarker(id: string, title: string): string {
80
+ const label = title.replace(/[\r\n]+/g, ' ').replace(/\[/g, '(').replace(/]/g, ')').trim();
81
+ return `include::${id}[${label}]`;
82
+ }
83
+
54
84
  /** The block types as slash menu insertions. Image opens the picker, so it inserts nothing. */
55
- const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image
56
- ? {label: item.label, icon: item.icon, action: 'image'}
85
+ const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image || item.include
86
+ ? {label: item.label, icon: item.icon, action: item.image ? 'image' : 'include'}
57
87
  : {label: item.label, icon: item.icon, value: item.prefix ?? '', caretOffset: item.caretOffset});
58
88
 
59
89
  /**
@@ -80,6 +110,8 @@ const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image
80
110
  * @csspart raw - The full-document textarea shown in raw source mode.
81
111
  * @csspart slash-menu - The `zn-slash-menu` opened by typing "/" in an empty block.
82
112
  * @csspart image-controls - The caption / alignment / size panel shown when an image block is clicked.
113
+ * @csspart include - The chip rendered in place of an `include::` directive.
114
+ * @csspart include-picker - The inline Include picker opened from the toolbar or "/include".
83
115
  */
84
116
  export default class ZnRemarkdEditor extends ZincElement implements ZincFormControl {
85
117
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -95,11 +127,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
95
127
  menu: () => this.mountSlashMenu(),
96
128
  // The menu only belongs in a block that is nothing but the slash command — a
97
129
  // block prefix like "## " is not valid remarkd part-way through a line.
98
- items: () => this.isSlashBlock() ? SLASH_ITEMS : [],
130
+ items: () => this.isSlashBlock()
131
+ ? SLASH_ITEMS.filter(item => item.action !== 'include' || !!this.includeUrl)
132
+ : [],
99
133
  onSelect: item => this.handleSlashSelect(item)
100
134
  });
101
135
 
102
136
  private editingDraft = '';
137
+ private includeRequest: Promise<IncludeOption[]> | null = null;
103
138
  private rawEntryValue = '';
104
139
  private suppressValueSync = false;
105
140
  private suppressBlurCommit = false;
@@ -109,6 +144,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
109
144
 
110
145
  @state() private blocks: string[] = [];
111
146
  @state() private editingIndex: number | null = null;
147
+ /**
148
+ * Lists the blocks most recently inserted here above the rest of the slash menu, remembered in
149
+ * `localStorage` under this key. Leave unset to offer no recently used section.
150
+ */
151
+ @property({attribute: 'slash-recent-key'}) slashRecentKey = '';
152
+
112
153
  /** Renders the slash menu only once it has been needed. */
113
154
  @state() private hasSlashMenu = false;
114
155
  @state() private imagePickerIndex: number | null = null;
@@ -117,6 +158,9 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
117
158
  @state() private dragIndex: number | null = null;
118
159
  @state() private editShell = '';
119
160
  @state() private rawMode = false;
161
+ @state() private includeOptions: IncludeOption[] | null = null;
162
+ @state() private includePickerIndex: number | null = null;
163
+ @state() private includeQuery = '';
120
164
 
121
165
  private pendingDragHandle: HTMLElement | null = null;
122
166
  private dragStartX = 0;
@@ -142,6 +186,13 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
142
186
  */
143
187
  @property({attribute: 'attachment-url'}) attachmentUrl = '';
144
188
 
189
+ /**
190
+ * Endpoint listing the Includes this document may embed, as
191
+ * `{"items":[{id,title,description,scope,keywords,languages,url}]}`. Labels the
192
+ * chips rendered for `include::` directives and feeds the include picker.
193
+ */
194
+ @property({attribute: 'include-url'}) includeUrl = '';
195
+
145
196
  /** Adds a toolbar toggle that swaps the block view for the full remarkd source. */
146
197
  @property({type: Boolean, attribute: 'allow-raw', reflect: true}) allowRaw = false;
147
198
 
@@ -201,6 +252,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
201
252
  protected firstUpdated(_changedProperties: PropertyValues) {
202
253
  super.firstUpdated(_changedProperties);
203
254
  this.formControlController.updateValidity();
255
+ if (this.hasIncludeBlock()) void this.loadIncludeOptions();
204
256
  }
205
257
 
206
258
  disconnectedCallback() {
@@ -215,6 +267,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
215
267
  return;
216
268
  }
217
269
  this.blocks = this.splitBlocks(this.value || '');
270
+ if (this.hasUpdated && this.hasIncludeBlock()) void this.loadIncludeOptions();
271
+ }
272
+
273
+ @watch('includeUrl', {waitUntilFirstUpdate: true})
274
+ handleIncludeUrlChange() {
275
+ this.includeRequest = null;
276
+ this.includeOptions = null;
277
+ if (this.hasIncludeBlock()) void this.loadIncludeOptions();
218
278
  }
219
279
 
220
280
  /**
@@ -246,6 +306,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
246
306
  fence = marker;
247
307
  continue;
248
308
  }
309
+ if (INCLUDE_LINE.test(trimmed)) {
310
+ push();
311
+ current.push(line);
312
+ push();
313
+ continue;
314
+ }
249
315
  if (trimmed === '') {
250
316
  push();
251
317
  continue;
@@ -337,6 +403,13 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
337
403
  return found ? data : null;
338
404
  }
339
405
 
406
+ /** Parses a block that is nothing but an include directive. */
407
+ private parseIncludeBlock(block: string): IncludeBlockData | null {
408
+ const match = INCLUDE_LINE.exec((block ?? '').trim());
409
+ if (!match) return null;
410
+ return {target: match[1].trim(), label: match[2].trim()};
411
+ }
412
+
340
413
  private serializeImageBlock(data: ImageBlockData): string {
341
414
  const lines: string[] = [];
342
415
  if (data.caption.trim()) lines.push(`.${data.caption.trim()}`);
@@ -549,14 +622,18 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
549
622
 
550
623
  /** Returns false for items the controller should not insert text for. */
551
624
  private handleSlashSelect(item: SlashMenuItem): boolean {
552
- if (item.action !== 'image') return true;
625
+ if (item.action !== 'image' && item.action !== 'include') return true;
553
626
 
554
627
  const index = this.editingIndex ?? this.blocks.length;
555
628
  // Let the controller strip the "/…" text first, then drop the empty draft
556
- // block and run the image flow in its place.
629
+ // block and run the picker in its place.
557
630
  void this.updateComplete.then(() => {
558
631
  this.blur();
559
- this.pickImage(index);
632
+ if (item.action === 'image') {
633
+ this.pickImage(index);
634
+ } else {
635
+ this.pickInclude(index);
636
+ }
560
637
  });
561
638
  return false;
562
639
  }
@@ -682,9 +759,28 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
682
759
 
683
760
  private pickImage(index: number) {
684
761
  if (this.disabled || this.readonly) return;
762
+ this.includePickerIndex = null;
685
763
  this.imagePickerIndex = index;
686
764
  }
687
765
 
766
+ private pickInclude(index: number) {
767
+ if (this.disabled || this.readonly || !this.includeUrl) return;
768
+ this.imagePickerIndex = null;
769
+ this.includeQuery = '';
770
+ this.includePickerIndex = index;
771
+ void this.loadIncludeOptions();
772
+ }
773
+
774
+ private closeIncludePicker = () => {
775
+ this.includePickerIndex = null;
776
+ };
777
+
778
+ private insertInclude(item: IncludeOption) {
779
+ const index = this.includePickerIndex ?? this.blocks.length;
780
+ this.includePickerIndex = null;
781
+ this.addBlockAt(index, includeMarker(item.id, item.title));
782
+ }
783
+
688
784
  private closeImagePicker = () => {
689
785
  this.imagePickerIndex = null;
690
786
  };
@@ -731,6 +827,29 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
731
827
  return data.uploadPath;
732
828
  }
733
829
 
830
+ private hasIncludeBlock(): boolean {
831
+ return this.blocks.some(block => this.parseIncludeBlock(block) !== null);
832
+ }
833
+
834
+ /** Fetches the include list once; every later caller shares the same promise. */
835
+ private loadIncludeOptions(): Promise<IncludeOption[]> {
836
+ if (!this.includeUrl) return Promise.resolve([]);
837
+ if (!this.includeRequest) {
838
+ this.includeRequest = fetch(this.includeUrl, {headers: {Accept: 'application/json'}})
839
+ .then(res => res.ok ? res.json() as Promise<{items?: IncludeOption[]}> : {items: []})
840
+ .then(data => data.items ?? [])
841
+ .catch(error => {
842
+ console.error('[zn-remarkd-editor] include list failed', error);
843
+ return [] as IncludeOption[];
844
+ })
845
+ .then(items => {
846
+ this.includeOptions = items;
847
+ return items;
848
+ });
849
+ }
850
+ return this.includeRequest;
851
+ }
852
+
734
853
  private autosize(input: HTMLTextAreaElement) {
735
854
  input.style.height = 'auto';
736
855
  input.style.height = `${input.scrollHeight}px`;
@@ -796,6 +915,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
796
915
  const index = this.editingIndex !== null ? this.commitEdit() : this.blocks.length;
797
916
  if (item.image) {
798
917
  this.pickImage(index);
918
+ } else if (item.include) {
919
+ this.pickInclude(index);
799
920
  } else {
800
921
  this.insertDraftBlock(index, item.prefix ?? '');
801
922
  }
@@ -867,6 +988,32 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
867
988
  </div>`;
868
989
  }
869
990
 
991
+ private renderIncludeChip(data: IncludeBlockData) {
992
+ const option = this.includeOptions?.find(item => item.id === data.target);
993
+ // A path-like target is remarkd's file include, which this editor cannot
994
+ // resolve and must not call broken.
995
+ const filePath = /[/.]/.test(data.target);
996
+ const pending = !this.includeUrl || this.includeOptions === null;
997
+ const missing = !option && !pending && !filePath;
998
+ return html`
999
+ <div part="include"
1000
+ class=${classMap({
1001
+ 'remarkd-editor__include': true,
1002
+ 'remarkd-editor__include--missing': missing,
1003
+ })}>
1004
+ <zn-icon src=${missing ? 'triangle-alert@lu' : 'blocks@lu'} size="16"></zn-icon>
1005
+ <span class="remarkd-editor__include-title">${option?.title || data.label || data.target}</span>
1006
+ ${option?.scope ? html`
1007
+ <span class="remarkd-editor__include-scope">${option.scope}</span>` : ''}
1008
+ <span class="remarkd-editor__include-meta">${missing
1009
+ ? `Include not found · ${data.target}`
1010
+ : !option && filePath ? 'File include' : 'Included content'}</span>
1011
+ ${option?.url ? html`
1012
+ <a class="remarkd-editor__include-link" href=${option.url} target="_blank" rel="noreferrer"
1013
+ @click=${(e: MouseEvent) => e.stopPropagation()}>Open</a>` : ''}
1014
+ </div>`;
1015
+ }
1016
+
870
1017
  private renderBlock(block: string, index: number) {
871
1018
  if (this.editingIndex === index) {
872
1019
  if (this.imageEdit) {
@@ -890,6 +1037,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
890
1037
  </div>`;
891
1038
  }
892
1039
 
1040
+ const include = this.parseIncludeBlock(block);
893
1041
  return html`
894
1042
  <div part="block"
895
1043
  class=${classMap({
@@ -914,7 +1062,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
914
1062
  tooltip="Delete block"
915
1063
  @click=${() => this.deleteBlock(index)}></zn-button>`}
916
1064
  <div part="rendered" class="remarkd-editor__rendered remarkd-rendered"
917
- @click=${(e: MouseEvent) => this.handleRenderedClick(e, index)}>${unsafeHTML(remarkdParse(block))}
1065
+ @click=${(e: MouseEvent) => this.handleRenderedClick(e, index)}>${
1066
+ include ? this.renderIncludeChip(include) : unsafeHTML(remarkdParse(block))}
918
1067
  </div>
919
1068
  </div>`;
920
1069
  }
@@ -932,7 +1081,9 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
932
1081
  @drop=${this.handleDrop}>
933
1082
  ${editable ? html`
934
1083
  <div part="toolbar" class="remarkd-editor__toolbar">
935
- ${this.rawMode ? '' : BLOCK_TYPES.map(item => html`
1084
+ ${this.rawMode ? '' : BLOCK_TYPES
1085
+ .filter(item => !item.include || !!this.includeUrl)
1086
+ .map(item => html`
936
1087
  <zn-button type="button" icon-button plain icon=${item.icon} icon-size="18"
937
1088
  tooltip=${item.label}
938
1089
  @click=${() => this.handleToolbarInsert(item)}></zn-button>`)}
@@ -954,7 +1105,10 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
954
1105
  </div>`}
955
1106
  </div>
956
1107
  ${this.hasSlashMenu ? html`
957
- <zn-slash-menu part="slash-menu" heading="Insert block"></zn-slash-menu>` : ''}
1108
+ <zn-slash-menu
1109
+ part="slash-menu"
1110
+ heading="Insert block"
1111
+ recent-key=${this.slashRecentKey}></zn-slash-menu>` : ''}
958
1112
  <textarea class="remarkd-editor__validation"
959
1113
  .value=${this.value}
960
1114
  ?required=${this.required}
@@ -980,9 +1134,44 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
980
1134
  if (this.imagePickerIndex !== null) {
981
1135
  views.splice(this.imagePickerIndex, 0, this.renderImagePicker());
982
1136
  }
1137
+ if (this.includePickerIndex !== null) {
1138
+ views.splice(this.includePickerIndex, 0, this.renderIncludePicker());
1139
+ }
983
1140
  return views;
984
1141
  }
985
1142
 
1143
+ private renderIncludePicker() {
1144
+ const filter = this.includeQuery.trim().toLowerCase();
1145
+ const items = (this.includeOptions ?? []).filter(item => !filter || [
1146
+ item.title, item.description ?? '', (item.keywords ?? []).join(' '),
1147
+ ].some(field => field.toLowerCase().includes(filter)));
1148
+ return html`
1149
+ <div part="include-picker" class="remarkd-editor__include-picker">
1150
+ <div class="remarkd-editor__include-picker-head">
1151
+ <input class="remarkd-editor__include-filter"
1152
+ placeholder="Find an Include"
1153
+ .value=${this.includeQuery}
1154
+ @input=${(e: Event) => {
1155
+ this.includeQuery = (e.target as HTMLInputElement).value;
1156
+ }}>
1157
+ <zn-button type="button" icon-button="small" plain icon="x@lu"
1158
+ tooltip="Cancel" @click=${this.closeIncludePicker}></zn-button>
1159
+ </div>
1160
+ ${this.includeOptions === null
1161
+ ? html`<div class="remarkd-editor__include-picker-empty">Loading…</div>`
1162
+ : items.length
1163
+ ? items.map(item => html`
1164
+ <button type="button" class="remarkd-editor__include-option"
1165
+ ?disabled=${!item.languages}
1166
+ @click=${() => this.insertInclude(item)}>
1167
+ <span class="remarkd-editor__include-option-title">${item.title}</span>
1168
+ <span class="remarkd-editor__include-option-meta">${item.scope ?? ''}${
1169
+ item.languages ? ` · ${item.languages}` : ' · No content'}</span>
1170
+ </button>`)
1171
+ : html`<div class="remarkd-editor__include-picker-empty">No Includes found</div>`}
1172
+ </div>`;
1173
+ }
1174
+
986
1175
  private renderImagePicker() {
987
1176
  return html`
988
1177
  <div class="remarkd-editor__image-picker">
@@ -224,6 +224,87 @@
224
224
  }
225
225
  }
226
226
 
227
+ .remarkd-editor__include {
228
+ display: flex;
229
+ align-items: center;
230
+ gap: var(--zn-spacing-small);
231
+ padding: var(--zn-spacing-small);
232
+ border: 1px dashed rgb(var(--zn-border-color));
233
+ border-radius: var(--zn-border-radius);
234
+ background: var(--zn-color-neutral-0, white);
235
+ }
236
+
237
+ .remarkd-editor__include-title {
238
+ font-weight: 600;
239
+ }
240
+
241
+ .remarkd-editor__include-meta {
242
+ color: var(--zn-color-neutral-400, #9aa4b2);
243
+ font-size: var(--zn-font-size-small);
244
+ }
245
+
246
+ .remarkd-editor__include--missing {
247
+ border-style: solid;
248
+ border-color: rgb(var(--zn-color-error));
249
+ }
250
+
251
+ .remarkd-editor__include-scope {
252
+ color: var(--zn-color-neutral-400, #9aa4b2);
253
+ font-size: var(--zn-font-size-small);
254
+ }
255
+
256
+ .remarkd-editor__include-link {
257
+ margin-left: auto;
258
+ font-size: var(--zn-font-size-small);
259
+ }
260
+
261
+ .remarkd-editor__include-picker {
262
+ display: flex;
263
+ flex-direction: column;
264
+ gap: var(--zn-spacing-x-small);
265
+ padding: var(--zn-spacing-small);
266
+ border: 1px solid rgb(var(--zn-border-color));
267
+ border-radius: var(--zn-border-radius);
268
+ background: var(--zn-color-neutral-0, white);
269
+ }
270
+
271
+ .remarkd-editor__include-picker-head {
272
+ display: flex;
273
+ align-items: center;
274
+ gap: var(--zn-spacing-x-small);
275
+ }
276
+
277
+ .remarkd-editor__include-filter {
278
+ flex: 1 1 auto;
279
+ }
280
+
281
+ .remarkd-editor__include-option {
282
+ display: flex;
283
+ justify-content: space-between;
284
+ gap: var(--zn-spacing-small);
285
+ padding: var(--zn-spacing-x-small) var(--zn-spacing-small);
286
+ border: 0;
287
+ border-radius: var(--zn-border-radius);
288
+ background: none;
289
+ cursor: pointer;
290
+ text-align: left;
291
+
292
+ &:hover:not(:disabled) {
293
+ background: var(--zn-color-neutral-100);
294
+ }
295
+
296
+ &:disabled {
297
+ cursor: default;
298
+ opacity: 0.6;
299
+ }
300
+ }
301
+
302
+ .remarkd-editor__include-option-meta,
303
+ .remarkd-editor__include-picker-empty {
304
+ color: var(--zn-color-neutral-400, #9aa4b2);
305
+ font-size: var(--zn-font-size-small);
306
+ }
307
+
227
308
  .remarkd-editor__image-controls {
228
309
  display: flex;
229
310
  flex-direction: column;
@@ -282,4 +282,183 @@ Second"></zn-remarkd-editor>`);
282
282
 
283
283
  expect(fired).to.be.true;
284
284
  });
285
+ it('should keep an include directive in a block of its own', async () => {
286
+ const el = await fixture<ZnRemarkdEditor>(html`
287
+ <zn-remarkd-editor value="Intro
288
+ include::inc-1[Payment Terms]
289
+ Outro"></zn-remarkd-editor>`);
290
+
291
+ const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block');
292
+ expect(blocks.length).to.equal(3);
293
+ expect(blocks[1].querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms');
294
+ expect(blocks[0].querySelector('.remarkd-editor__include')).to.not.exist;
295
+ });
296
+
297
+ it('should leave an include directive inside a fence alone', async () => {
298
+ const fenced = '```\ninclude::inc-1[Payment Terms]\n```';
299
+ const el = await fixture<ZnRemarkdEditor>(html`
300
+ <zn-remarkd-editor value=${fenced}></zn-remarkd-editor>`);
301
+
302
+ expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1);
303
+ expect(el.shadowRoot!.querySelector('.remarkd-editor__include')).to.not.exist;
304
+ });
305
+
306
+ it('should label an include chip from the marker when no list is configured', async () => {
307
+ const el = await fixture<ZnRemarkdEditor>(html`
308
+ <zn-remarkd-editor value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
309
+
310
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!;
311
+ expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms');
312
+ expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false;
313
+ });
314
+
315
+ // Ordering an embed within the content is the whole point of the chip: the
316
+ // block chrome's drag handle has to move it like any other block.
317
+ it('should reorder an include block by dragging its handle', async () => {
318
+ const el = await fixture<ZnRemarkdEditor>(html`
319
+ <zn-remarkd-editor value="Intro
320
+
321
+ include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
322
+
323
+ const first = el.shadowRoot!.querySelectorAll('.remarkd-editor__block')[0].getBoundingClientRect();
324
+ const handle = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__drag-handle')[1];
325
+ const target = first.top + 1;
326
+
327
+ handle.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, button: 0, buttons: 1, clientX: 0, clientY: 0}));
328
+ document.dispatchEvent(new PointerEvent('pointermove', {bubbles: true, buttons: 1, clientX: 0, clientY: target}));
329
+ document.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, buttons: 0, clientX: 0, clientY: target}));
330
+ await el.updateComplete;
331
+
332
+ expect(el.value).to.equal('include::inc-1[Payment Terms]\n\nIntro');
333
+ });
334
+ /** Serves one include list to the editor, and restores fetch when the test ends. */
335
+ function stubIncludeList(items: unknown[]): void {
336
+ const original = window.fetch;
337
+ window.fetch = () => Promise.resolve(new Response(JSON.stringify({items}),
338
+ {headers: {'Content-Type': 'application/json'}}));
339
+ afterEach(() => {
340
+ window.fetch = original;
341
+ });
342
+ }
343
+
344
+ it('should label an include chip from the fetched list', async () => {
345
+ stubIncludeList([{id: 'inc-1', title: 'Refund Policy', scope: 'Global',
346
+ languages: 'English', url: '/kb/kb1/includes/inc-1'}]);
347
+ const el = await fixture<ZnRemarkdEditor>(html`
348
+ <zn-remarkd-editor include-url="/options"
349
+ value="include::inc-1[Stale Title]"></zn-remarkd-editor>`);
350
+
351
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'),
352
+ 'the include list never resolved');
353
+ const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!;
354
+ expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Refund Policy');
355
+ expect(chip.querySelector('.remarkd-editor__include-scope')!.textContent).to.contain('Global');
356
+ expect(chip.querySelector<HTMLAnchorElement>('.remarkd-editor__include-link')!.getAttribute('href'))
357
+ .to.equal('/kb/kb1/includes/inc-1');
358
+ expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false;
359
+ });
360
+
361
+ it('should flag an include the list does not know', async () => {
362
+ stubIncludeList([{id: 'inc-1', title: 'Refund Policy'}]);
363
+ const el = await fixture<ZnRemarkdEditor>(html`
364
+ <zn-remarkd-editor include-url="/options"
365
+ value="include::inc-9[Archived]"></zn-remarkd-editor>`);
366
+
367
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include--missing'),
368
+ 'the missing state never rendered');
369
+ expect(el.shadowRoot!.querySelector('.remarkd-editor__include-meta')!.textContent).to.contain('inc-9');
370
+ });
371
+
372
+ // remarkd's file include shares the syntax, so an unknown path is not broken.
373
+ it('should not flag a file include the list does not know', async () => {
374
+ stubIncludeList([{id: 'inc-1', title: 'Refund Policy'}]);
375
+ const el = await fixture<ZnRemarkdEditor>(html`
376
+ <zn-remarkd-editor include-url="/options"
377
+ value="include::partials/legal.adoc[Legal]"></zn-remarkd-editor>`);
378
+
379
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include'),
380
+ 'the chip never rendered');
381
+ expect(el.shadowRoot!.querySelector('.remarkd-editor__include--missing')).to.not.exist;
382
+ });
383
+
384
+ it('should not request the include list for a document with no embeds', async () => {
385
+ const original = window.fetch;
386
+ let requests = 0;
387
+ window.fetch = () => {
388
+ requests++;
389
+ return Promise.resolve(new Response('{"items":[]}'));
390
+ };
391
+ try {
392
+ const el = await fixture<ZnRemarkdEditor>(html`
393
+ <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
394
+ await el.updateComplete;
395
+ expect(requests).to.equal(0);
396
+ } finally {
397
+ window.fetch = original;
398
+ }
399
+ });
400
+ it('should insert an include marker from the toolbar picker', async () => {
401
+ stubIncludeList([{id: 'inc-1', title: 'Refund Policy', scope: 'Global', languages: 'English'}]);
402
+ const el = await fixture<ZnRemarkdEditor>(html`
403
+ <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
404
+
405
+ const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
406
+ const includeButton = buttons[buttons.length - 1];
407
+ includeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
408
+
409
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-option'),
410
+ 'the include picker never listed anything');
411
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__include-option')!.click();
412
+ await el.updateComplete;
413
+
414
+ expect(el.value).to.equal('# Title\n\ninclude::inc-1[Refund Policy]');
415
+ });
416
+
417
+ it('should swap the slash command for the include picker when Include is chosen', async () => {
418
+ stubIncludeList([{id: 'inc-1', title: 'Refund Policy', languages: 'English'}]);
419
+ const el = await fixture<ZnRemarkdEditor>(html`
420
+ <zn-remarkd-editor include-url="/options" value="Hello"></zn-remarkd-editor>`);
421
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
422
+ await el.updateComplete;
423
+
424
+ const input = typeInBlock(el, '/include');
425
+ await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
426
+
427
+ input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
428
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-picker'),
429
+ 'the include picker never opened');
430
+
431
+ // The "/include" draft is dropped rather than committed as a block.
432
+ expect(el.value).to.equal('');
433
+ });
434
+
435
+ it('should filter the include picker', async () => {
436
+ stubIncludeList([
437
+ {id: 'inc-1', title: 'Refund Policy', keywords: ['money'], languages: 'English'},
438
+ {id: 'inc-2', title: 'Shipping', languages: 'English'},
439
+ ]);
440
+ const el = await fixture<ZnRemarkdEditor>(html`
441
+ <zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
442
+ const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
443
+ buttons[buttons.length - 1].dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
444
+ await waitUntil(() => el.shadowRoot!.querySelectorAll('.remarkd-editor__include-option').length === 2,
445
+ 'the include picker never listed both');
446
+
447
+ const filter = el.shadowRoot!.querySelector<HTMLInputElement>('.remarkd-editor__include-filter')!;
448
+ filter.value = 'money';
449
+ filter.dispatchEvent(new Event('input', {bubbles: true}));
450
+ await el.updateComplete;
451
+
452
+ const options = el.shadowRoot!.querySelectorAll('.remarkd-editor__include-option');
453
+ expect(options.length).to.equal(1);
454
+ expect(options[0].textContent).to.contain('Refund Policy');
455
+ });
456
+
457
+ it('should offer no include entry points without an include-url', async () => {
458
+ const el = await fixture<ZnRemarkdEditor>(html`
459
+ <zn-remarkd-editor value="# Title"></zn-remarkd-editor>`);
460
+ const tooltips = Array.from(el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button'))
461
+ .map(button => button.getAttribute('tooltip'));
462
+ expect(tooltips).to.not.contain('Include');
463
+ });
285
464
  });
@@ -105,7 +105,8 @@ zn-button {
105
105
  border-bottom-width: 0;
106
106
  border-radius: 0 0 0 var(--zn-border-radius);
107
107
  transition: grid-template-rows 0.22s ease, border-bottom-width 0s linear 0.22s;
108
- will-change: grid-template-rows;
108
+ // PERF: grid-template-rows is not a compositable property, so will-change
109
+ // cannot help it — it only promoted a layer per settings container for free.
109
110
  }
110
111
 
111
112
  &--open &__panel {
@@ -2,7 +2,7 @@ import {html, unsafeCSS} from 'lit';
2
2
  import {property} from 'lit/decorators.js';
3
3
  import ZincElement from '../../internal/zinc-element';
4
4
  import type {CSSResultGroup} from 'lit';
5
- import type {SlashMenuItem} from '../slash-menu/slash-menu-items';
5
+ import type {SlashMenuItem} from '../slash-menu';
6
6
 
7
7
  import styles from './slash-item.scss';
8
8