@kubex/zinc 1.1.43 → 1.1.44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.43",
3
+ "version": "1.1.44",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -21,6 +21,7 @@ import ZnTextarea from "../textarea";
21
21
  import type {OnEvent} from "../../utilities/on";
22
22
  import type {Range} from "quill";
23
23
  import type {ZincFormControl} from '../../internal/zinc-element';
24
+ import type {ZnEditorInsertEvent} from "./modules/events/zn-editor-insert";
24
25
  import type ToolbarComponent from "./modules/toolbar/toolbar.component";
25
26
 
26
27
  import styles from './editor.scss';
@@ -281,6 +282,9 @@ export default class ZnEditor extends ZincElement implements ZincFormControl {
281
282
 
282
283
  this.quillElement = quill;
283
284
 
285
+ const dialogModule = quill.getModule('dialog') as Dialog | undefined;
286
+ dialogModule?.component?.addEventListener('zn-editor-insert', this._handleEditorInsert);
287
+
284
288
  this.getForm()?.addEventListener('submit', () => {
285
289
  this._clearCachedContent();
286
290
  setTimeout(() => {
@@ -509,6 +513,52 @@ export default class ZnEditor extends ZincElement implements ZincFormControl {
509
513
  }
510
514
  }
511
515
 
516
+ private _handleEditorInsert = (e: ZnEditorInsertEvent) => {
517
+ const detail: Partial<ZnEditorInsertEvent['detail']> = e.detail ?? {};
518
+ const mode = detail.mode;
519
+ if (mode !== 'insert' && mode !== 'replace') return;
520
+
521
+ e.stopPropagation();
522
+
523
+ if (detail.html) {
524
+ this._insertHtml(mode, detail.html);
525
+ return;
526
+ }
527
+
528
+ if (detail.text) {
529
+ this._content = detail.text.trim();
530
+ if (mode === 'replace') {
531
+ this._replaceTextAtSelection();
532
+ } else {
533
+ this._insertTextAtSelection();
534
+ }
535
+ }
536
+ };
537
+
538
+ private _insertHtml(mode: 'insert' | 'replace', content: string) {
539
+ const quill = this.quillElement;
540
+ const range = quill.getSelection();
541
+
542
+ let index: number;
543
+ if (mode === 'replace') {
544
+ if (range && range.length > 0) {
545
+ quill.deleteText(range.index, range.length);
546
+ index = range.index;
547
+ } else {
548
+ quill.setText('');
549
+ index = 0;
550
+ }
551
+ } else {
552
+ index = range ? range.index : this._selectionRange.index;
553
+ }
554
+
555
+ const lengthBefore = quill.getLength();
556
+ quill.clipboard.dangerouslyPasteHTML(index, content, Quill.sources.USER);
557
+ quill.setSelection(index + Math.max(0, quill.getLength() - lengthBefore), 0);
558
+
559
+ this._closePopups();
560
+ }
561
+
512
562
  private _replaceTextAtSelection() {
513
563
  const range = this.quillElement.getSelection();
514
564
  if (range) {
@@ -0,0 +1,83 @@
1
+ import '../../../../../dist/zn.min.js';
2
+ import {aTimeout, expect, fixture, html} from '@open-wc/testing';
3
+ import type ZnEditor from '../../editor.component';
4
+
5
+ interface QuillLike {
6
+ focus: () => void;
7
+ insertText: (index: number, text: string, source: string) => void;
8
+ setSelection: (index: number, length: number, source: string) => void;
9
+ root: HTMLElement;
10
+ }
11
+
12
+ interface ContextMenuLike extends HTMLElement {
13
+ open: boolean;
14
+ results: { label: string }[];
15
+ setActiveIndex: (index: number) => void;
16
+ }
17
+
18
+ interface EditorDialogLike extends HTMLElement {
19
+ dialogEl: HTMLDialogElement;
20
+ }
21
+
22
+ describe('<zn-editor> context menu tools', () => {
23
+ afterEach(() => {
24
+ document.querySelectorAll('zn-context-menu, zn-editor-dialog').forEach(el => el.remove());
25
+ });
26
+
27
+ const editorWithTool = () => fixture<ZnEditor>(html`
28
+ <zn-editor id="test-editor">
29
+ <zn-editor-tool uri="/canned"
30
+ label="Canned Responses"
31
+ icon="messages-square@lu"
32
+ key="canned-responses"
33
+ context-menu
34
+ slot="tools"></zn-editor-tool>
35
+ </zn-editor>`);
36
+
37
+ const openContextMenu = async (el: ZnEditor): Promise<QuillLike> => {
38
+ const quill = (el as unknown as { quillElement: QuillLike }).quillElement;
39
+ quill.focus();
40
+ quill.insertText(0, '/', 'user');
41
+ quill.setSelection(1, 0, 'user');
42
+ await aTimeout(50);
43
+ return quill;
44
+ };
45
+
46
+ const contextMenu = (): ContextMenuLike =>
47
+ document.querySelector('zn-context-menu') as unknown as ContextMenuLike;
48
+
49
+ it('lists a context-menu flagged tool in the slash menu', async () => {
50
+ const el = await editorWithTool();
51
+ await openContextMenu(el);
52
+
53
+ const menu = contextMenu();
54
+ expect(menu, 'context menu should exist').to.exist;
55
+ expect(menu.open, 'context menu should be open').to.be.true;
56
+
57
+ const labels = menu.results.map(r => r.label);
58
+ expect(labels).to.include('Canned Responses');
59
+ });
60
+
61
+ it('opens the tool dialog when the slash menu entry is activated with Enter', async () => {
62
+ const el = await editorWithTool();
63
+ const quill = await openContextMenu(el);
64
+
65
+ const menu = contextMenu();
66
+ const index = menu.results.findIndex(r => r.label === 'Canned Responses');
67
+ expect(index).to.be.greaterThan(-1);
68
+ menu.setActiveIndex(index);
69
+
70
+ quill.root.dispatchEvent(new KeyboardEvent('keydown', {
71
+ key: 'Enter',
72
+ bubbles: true,
73
+ composed: true,
74
+ cancelable: true,
75
+ }));
76
+ await aTimeout(100);
77
+
78
+ const dialog = document.querySelector('zn-editor-dialog') as unknown as EditorDialogLike;
79
+ expect(dialog, 'editor dialog should exist').to.exist;
80
+ expect(dialog.dialogEl.open, 'dialog should be open').to.be.true;
81
+ expect(dialog.innerHTML).to.contain('/canned');
82
+ });
83
+ });
@@ -2,9 +2,10 @@ import './context-menu-component';
2
2
  import {html} from "lit";
3
3
  import {litToHTML} from "../../../../utilities/lit-to-html";
4
4
  import {type ResultItem} from "./context-menu-component";
5
- import Quill from "quill";
6
5
  import Delta from "quill-delta";
6
+ import Quill from "quill";
7
7
  import ZnEditorQuickAction from "./quick-action";
8
+ import ZnEditorTool from "../toolbar/tool";
8
9
  import type {EditorFeatureConfig} from "../../editor.component";
9
10
  import type ContextMenuComponent from "./context-menu-component";
10
11
  import type Toolbar from "../toolbar/toolbar";
@@ -258,6 +259,18 @@ class ContextMenu {
258
259
  }
259
260
  }
260
261
  });
262
+
263
+ const toolSlot = root.querySelector('slot[name="tools"]') as HTMLSlotElement | null;
264
+ const tools = toolSlot ? toolSlot.assignedElements({flatten: true}) : [];
265
+ tools.forEach((tool: Element) => {
266
+ if (!(tool instanceof ZnEditorTool) || !tool.contextMenu) return;
267
+
268
+ const {label, icon, key} = tool;
269
+ if (!label || !icon || !key) return;
270
+
271
+ const order = typeof tool.order === 'number' ? tool.order : orderCounter++;
272
+ options.push({icon, label, format: 'toolbar', key: key, order});
273
+ });
261
274
  }
262
275
 
263
276
  // 2) Built-in Actions (In order they should appear)
@@ -11,6 +11,11 @@ export default class DialogComponent extends ZincElement {
11
11
 
12
12
  @state() private hasFocus = false;
13
13
 
14
+ /** Set when loaded content declares it renders its own header (including a
15
+ * [dialog-closer] control) via a composed zn-dialog-header event, replacing
16
+ * the floating chrome close button. */
17
+ @state() private hasContentHeader = false;
18
+
14
19
  @query('dialog') dialogEl!: HTMLDialogElement;
15
20
 
16
21
  @property({type: Boolean, reflect: true}) open = false;
@@ -33,9 +38,29 @@ export default class DialogComponent extends ZincElement {
33
38
  this.dialogEl.addEventListener("close", () => {
34
39
  this.open = false;
35
40
  this.innerHTML = '';
41
+ this.hasContentHeader = false;
42
+ });
43
+
44
+ // Content is nested inside the app-space's shadow tree, so both contracts
45
+ // are composed: clicks on [dialog-closer] close the dialog (same contract
46
+ // as zn-dialog), and zn-dialog-header swaps the chrome closer for one the
47
+ // content lays out itself.
48
+ this.addEventListener('click', this.handleContentCloserClick);
49
+ this.addEventListener('zn-dialog-header', () => {
50
+ this.hasContentHeader = true;
36
51
  });
37
52
  }
38
53
 
54
+ private handleContentCloserClick = (e: Event) => {
55
+ for (const node of e.composedPath()) {
56
+ if (node === this) return;
57
+ if (node instanceof HTMLElement && node.hasAttribute('dialog-closer')) {
58
+ this.dialogEl.close();
59
+ return;
60
+ }
61
+ }
62
+ };
63
+
39
64
  setContent(content: string) {
40
65
  this.innerHTML = content;
41
66
  }
@@ -94,15 +119,14 @@ export default class DialogComponent extends ZincElement {
94
119
  'editor-dialog--has-focus': this.hasFocus,
95
120
  })}"
96
121
  context-data=${JSON.stringify({'editor-id': this._editorId})}>
97
- <zn-button
98
- class="editor-dialog__close"
99
- icon="x@lu"
100
- icon-button="small"
101
- icon-size="20"
102
- type="button"
103
- color="transparent"
104
- @click="${() => this.dialogEl.close()}"
105
- ></zn-button>
122
+ ${this.hasContentHeader ? nothing : html`
123
+ <zn-button
124
+ class="editor-dialog__close"
125
+ icon="x@lu"
126
+ icon-button="small"
127
+ icon-size="20"
128
+ @click="${() => this.dialogEl.close()}"
129
+ ></zn-button>`}
106
130
  <div class="editor-dialog__content">
107
131
  <slot>${this._getLoadingState()}</slot>
108
132
  </div>
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Dispatched (composed, bubbling) by editor-dialog content that renders its
3
+ * own header row, including a [dialog-closer] control. The dialog responds by
4
+ * removing its floating chrome close button so content doesn't need to
5
+ * reserve space for it.
6
+ */
7
+ export type ZnDialogHeaderEvent = CustomEvent<void>;
8
+
9
+ declare global {
10
+ interface GlobalEventHandlersEventMap {
11
+ 'zn-dialog-header': ZnDialogHeaderEvent;
12
+ }
13
+ }
@@ -0,0 +1,11 @@
1
+ export type ZnEditorInsertEvent = CustomEvent<{
2
+ mode: 'insert' | 'replace';
3
+ text?: string;
4
+ html?: string;
5
+ }>;
6
+
7
+ declare global {
8
+ interface GlobalEventHandlersEventMap {
9
+ 'zn-editor-insert': ZnEditorInsertEvent;
10
+ }
11
+ }
@@ -9,12 +9,15 @@ export default class ZnEditorTool extends ZincElement {
9
9
  @property() icon: string;
10
10
  @property() handler: string = 'dialog';
11
11
 
12
+ @property({type: Boolean, attribute: 'context-menu', reflect: true}) contextMenu = false;
13
+ @property({type: Number}) order?: number | null;
14
+
12
15
  render() {
13
16
  return html`
14
17
  <zn-button class="tool-action"
15
18
  color="transparent"
16
19
  icon="${this.icon}"
17
- icon-size="18"
20
+ icon-size="20"
18
21
  data-format="${this.handler}"
19
22
  data-format-type="${this.uri}"
20
23
  data-toolbar-key="${this.key}"
@@ -98,8 +98,20 @@ class Toolbar extends QuillToolbar {
98
98
  formatter => formatter.getAttribute('data-toolbar-key') === key && formatter.tagName === 'ZN-BUTTON'
99
99
  );
100
100
 
101
- const tool = (matches?.length ? (matches[0] as HTMLElement) : null);
101
+ // _formatters is collected once at init, before slotted zn-editor-tool
102
+ // elements have necessarily rendered their shadow roots — resolve slotted
103
+ // tools again at trigger time so context-menu activation finds them.
104
+ const tool = (matches?.length ? (matches[0] as HTMLElement) : null)
105
+ ?? this._slottedToolButton(key);
102
106
  if (tool) {
107
+ // Tool buttons live in the tool's own shadow root; a synthetic click
108
+ // doesn't propagate across the slot boundary to the toolbar's click
109
+ // listener, so invoke the format directly.
110
+ const format = tool.getAttribute('data-format');
111
+ if (format) {
112
+ this.callFormat(format, tool.getAttribute('data-format-type') ?? undefined);
113
+ return;
114
+ }
103
115
  tool.click();
104
116
  return;
105
117
  }
@@ -115,6 +127,18 @@ class Toolbar extends QuillToolbar {
115
127
  }
116
128
  }
117
129
 
130
+ private _slottedToolButton(key: string): HTMLElement | null {
131
+ const slot = this._component.shadowRoot?.querySelector('slot');
132
+ const assigned = slot ? slot.assignedElements({flatten: true}) : [];
133
+ for (const element of assigned) {
134
+ const button = element.shadowRoot?.querySelector(`[data-toolbar-key="${key}"]`);
135
+ if (button instanceof HTMLElement) {
136
+ return button;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
118
142
  private _attachToolbarHandlers() {
119
143
  const shadowRoot = this._component.shadowRoot;
120
144
  const shadowFormatters = shadowRoot?.querySelectorAll('[data-format]') ?? [];