@kubex/zinc 1.1.43 → 1.1.45

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.45",
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",
@@ -196,6 +196,7 @@ export default class ContentBlock extends ZincElement {
196
196
  <slot name="icon">
197
197
  <zn-icon src="${this.avatar}"
198
198
  library="avatar"
199
+ size="36"
199
200
  round></zn-icon>
200
201
  </slot>
201
202
  </div>
@@ -16,10 +16,10 @@
16
16
  .content-block-header {
17
17
  display: flex;
18
18
  align-items: center;
19
- gap: 10px;
19
+ gap: var(--zn-spacing-small);
20
20
  width: 100%;
21
21
  max-width: 100%;
22
- padding: var(--zn-base-gap) 0;
22
+ padding-block: var(--zn-spacing-small);
23
23
  position: relative;
24
24
  }
25
25
 
@@ -37,7 +37,7 @@
37
37
  display: flex;
38
38
  flex-direction: row;
39
39
  align-items: center;
40
- gap: 10px;
40
+ gap: var(--zn-spacing-small);
41
41
  min-width: 0;
42
42
  flex-grow: 1;
43
43
  overflow: hidden;
@@ -51,7 +51,7 @@
51
51
  margin: 0;
52
52
  display: flex;
53
53
  align-items: center;
54
- gap: var(--zn-spacing-x-small);
54
+ gap: var(--zn-spacing-small);
55
55
  flex-shrink: 0;
56
56
  overflow: hidden;
57
57
  white-space: nowrap;
@@ -72,7 +72,7 @@
72
72
  .content-block-header__actions {
73
73
  display: flex;
74
74
  align-items: center;
75
- gap: 10px;
75
+ gap: var(--zn-spacing-small);
76
76
  margin-left: auto;
77
77
  flex-shrink: 0;
78
78
  }
@@ -92,11 +92,10 @@
92
92
  }
93
93
 
94
94
  .text-section {
95
- font-size: 13px;
96
- line-height: 19px;
95
+ @include wc.text-style(paragraph);
97
96
 
98
97
  a {
99
- font-size: 10px;
98
+ font-size: var(--zn-font-size-small);
100
99
  opacity: 0.8;
101
100
  }
102
101
  }
@@ -245,6 +245,7 @@ export default class ZnDataTable extends ZincElement {
245
245
 
246
246
  // Data Table Properties
247
247
  private _initialLoad = true;
248
+ private _hasLoadedData = false;
248
249
  private _lastTableContent: TemplateResult = html``;
249
250
 
250
251
  private readonly resizeObserver = new ResizeController(this, {
@@ -363,7 +364,9 @@ export default class ZnDataTable extends ZincElement {
363
364
  } else if (this.dataUri) {
364
365
  tableBody = this._dataTask.render({
365
366
  pending: () => {
366
- if (this._initialLoad) {
367
+ // Show the skeleton until data has rendered at least once, so the
368
+ // first fetch on a no-initial-load table doesn't render blank space
369
+ if (this._initialLoad || !this._hasLoadedData) {
367
370
  return html`
368
371
  <div>${this.loadingTable()}</div>`;
369
372
  }
@@ -372,6 +375,7 @@ export default class ZnDataTable extends ZincElement {
372
375
  },
373
376
  complete: (data) => {
374
377
  this._initialLoad = false;
378
+ this._hasLoadedData = true;
375
379
  this._lastTableContent = html`
376
380
  <div>${this.renderTable(data as Response)}</div>`;
377
381
  return this._lastTableContent;
@@ -401,6 +405,7 @@ export default class ZnDataTable extends ZincElement {
401
405
  page: 1,
402
406
  };
403
407
  this._initialLoad = false;
408
+ this._hasLoadedData = true;
404
409
  this._lastTableContent = html`
405
410
  <div>${this.renderTable(response)}</div>`;
406
411
  tableBody = this._lastTableContent;
@@ -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]') ?? [];