@kubex/zinc 1.1.86 → 1.1.88

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.86",
3
+ "version": "1.1.88",
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",
@@ -9,9 +9,9 @@ interface QuillLike {
9
9
  root: HTMLElement;
10
10
  }
11
11
 
12
- interface ContextMenuLike extends HTMLElement {
12
+ interface SlashMenuLike extends HTMLElement {
13
13
  open: boolean;
14
- results: { label: string }[];
14
+ items: { label: string }[];
15
15
  setActiveIndex: (index: number) => void;
16
16
  }
17
17
 
@@ -19,9 +19,13 @@ interface EditorDialogLike extends HTMLElement {
19
19
  dialogEl: HTMLDialogElement;
20
20
  }
21
21
 
22
+ interface DropdownLike extends HTMLElement {
23
+ open: boolean;
24
+ }
25
+
22
26
  describe('<zn-editor> context menu tools', () => {
23
27
  afterEach(() => {
24
- document.querySelectorAll('zn-context-menu, zn-editor-dialog').forEach(el => el.remove());
28
+ document.querySelectorAll('zn-slash-menu, zn-editor-dialog').forEach(el => el.remove());
25
29
  });
26
30
 
27
31
  const editorWithTool = () => fixture<ZnEditor>(html`
@@ -43,8 +47,8 @@ describe('<zn-editor> context menu tools', () => {
43
47
  return quill;
44
48
  };
45
49
 
46
- const contextMenu = (): ContextMenuLike =>
47
- document.querySelector('zn-context-menu') as unknown as ContextMenuLike;
50
+ const contextMenu = (): SlashMenuLike =>
51
+ document.querySelector('zn-slash-menu') as unknown as SlashMenuLike;
48
52
 
49
53
  it('lists a context-menu flagged tool in the slash menu', async () => {
50
54
  const el = await editorWithTool();
@@ -54,7 +58,7 @@ describe('<zn-editor> context menu tools', () => {
54
58
  expect(menu, 'context menu should exist').to.exist;
55
59
  expect(menu.open, 'context menu should be open').to.be.true;
56
60
 
57
- const labels = menu.results.map(r => r.label);
61
+ const labels = menu.items.map(item => item.label);
58
62
  expect(labels).to.include('Canned Responses');
59
63
  });
60
64
 
@@ -63,7 +67,7 @@ describe('<zn-editor> context menu tools', () => {
63
67
  const quill = await openContextMenu(el);
64
68
 
65
69
  const menu = contextMenu();
66
- const index = menu.results.findIndex(r => r.label === 'Canned Responses');
70
+ const index = menu.items.findIndex(item => item.label === 'Canned Responses');
67
71
  expect(index).to.be.greaterThan(-1);
68
72
  menu.setActiveIndex(index);
69
73
 
@@ -80,4 +84,29 @@ describe('<zn-editor> context menu tools', () => {
80
84
  expect(dialog.dialogEl.open, 'dialog should be open').to.be.true;
81
85
  expect(dialog.innerHTML).to.contain('/canned');
82
86
  });
87
+
88
+ it('keeps the date picker open when Date is chosen with the mouse', async () => {
89
+ const el = await fixture<ZnEditor>(html`
90
+ <zn-editor id="test-editor" dates></zn-editor>`);
91
+ const quill = await openContextMenu(el);
92
+ quill.insertText(1, 'date', 'user');
93
+ quill.setSelection(5, 0, 'user');
94
+ await aTimeout(50);
95
+
96
+ const menu = contextMenu();
97
+ const index = menu.items.findIndex(item => item.label === 'Date');
98
+ expect(index, 'Date should be listed').to.be.greaterThan(-1);
99
+
100
+ const button = menu.shadowRoot!.querySelectorAll<HTMLButtonElement>('[data-slash-item]')[index];
101
+ expect(button, 'Date item should be rendered').to.exist;
102
+
103
+ const toolbar = el.shadowRoot!.getElementById('toolbar');
104
+ const dateDropdown = toolbar?.shadowRoot?.querySelector('zn-dropdown.toolbar__date-dropdown') as DropdownLike | null;
105
+ const overflowDropdown = toolbar?.shadowRoot?.querySelector('zn-dropdown.toolbar__overflow') as DropdownLike | null;
106
+
107
+ button.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true, composed: true}));
108
+ await aTimeout(100);
109
+
110
+ expect(dateDropdown?.open || overflowDropdown?.open, 'a dropdown holding the date picker should be open').to.be.true;
111
+ });
83
112
  });
@@ -1,23 +1,38 @@
1
- import './context-menu-component';
2
- import {html} from "lit";
3
- import {litToHTML} from "../../../../utilities/lit-to-html";
4
- import {type ResultItem} from "./context-menu-component";
1
+ import {filterSlashItems, SLASH_ITEM_SELECT} from "../../../slash-menu";
5
2
  import Delta from "quill-delta";
6
3
  import Quill from "quill";
7
4
  import ZnEditorQuickAction from "./quick-action";
8
5
  import ZnEditorTool from "../toolbar/tool";
9
6
  import type {EditorFeatureConfig} from "../../editor.component";
10
- import type ContextMenuComponent from "./context-menu-component";
7
+ import type {SlashMenuItem} from "../../../slash-menu";
8
+ import type {VirtualElement} from "@floating-ui/dom";
11
9
  import type Toolbar from "../toolbar/toolbar";
10
+ import type ZnSlashMenu from "../../../slash-menu";
11
+
12
+ /**
13
+ * A slash menu entry plus what choosing it does in the editor: trigger a toolbar tool by `key`,
14
+ * or apply `format` with `formatValue`. `SlashMenuItem.value` is left unset so the menu never
15
+ * renders format values as insertion tokens.
16
+ */
17
+ interface ContextMenuItem extends SlashMenuItem {
18
+ format?: string;
19
+ key?: string;
20
+ formatValue?: string | boolean;
21
+ }
12
22
 
13
23
  class ContextMenu {
14
24
  private _quill: Quill;
15
25
  private readonly _toolbarModule: Toolbar;
16
- private _component: ContextMenuComponent;
26
+ private _menu: ZnSlashMenu;
17
27
  private _startIndex = -1;
28
+ /** Trigger position the user dismissed with Escape; the menu stays shut until they move off it. */
29
+ private _dismissedIndex = -1;
18
30
  private _keydownHandler = (e: KeyboardEvent) => this.onKeydown(e);
19
31
  private _docClickHandler = (e: MouseEvent) => this.onDocumentClick(e);
20
32
  private _featureConfig: EditorFeatureConfig = {};
33
+ private readonly _caretAnchor: VirtualElement = {
34
+ getBoundingClientRect: () => this.caretRect()
35
+ };
21
36
 
22
37
  constructor(quill: Quill, options: { config: EditorFeatureConfig }) {
23
38
  this._quill = quill;
@@ -29,69 +44,56 @@ class ContextMenu {
29
44
  }
30
45
 
31
46
  private initComponent() {
32
- this._component = this.createComponent()!;
33
- this._quill.container.ownerDocument.body.appendChild(this._component);
47
+ const doc = this._quill.container.ownerDocument;
48
+ // The named import above pulls in the slash-menu module, which registers the element
49
+ this._menu = doc.createElement('zn-slash-menu');
50
+ this._menu.heading = 'Options';
51
+ this._caretAnchor.contextElement = this._quill.root;
52
+ this._menu.anchor = this._caretAnchor;
53
+ doc.body.appendChild(this._menu);
34
54
  }
35
55
 
36
56
  private attachEvents() {
37
- this._quill.on(Quill.events.TEXT_CHANGE, () => this.updateFromEditor());
38
57
  this._quill.on(Quill.events.EDITOR_CHANGE, () => this.updateFromEditor());
39
58
  this._quill.root.addEventListener('keydown', this._keydownHandler);
40
- this._component.addEventListener('zn-format-select', (e: Event) => this.onToolbarSelect(e as CustomEvent<ResultItem>));
41
- this._quill.on('editor-change', () => this.positionComponent());
59
+ this._menu.addEventListener(SLASH_ITEM_SELECT, (e: Event) => this.onItemSelect(e as CustomEvent<{ item: ContextMenuItem }>));
42
60
  this._quill.focus();
43
61
  }
44
62
 
45
- private createComponent() {
46
- const tpl = html`
47
- <zn-context-menu></zn-context-menu>`;
48
- return litToHTML<ContextMenuComponent>(tpl);
49
- }
50
-
51
63
  private onDocumentClick(e: MouseEvent) {
52
- const target = e.composedPath ? e.composedPath()[0] as Node : (e.target as Node);
53
- if (!target) return;
54
-
55
- if (!this._component.contains(target) && !this._quill.root.contains(target)) {
64
+ const path = e.composedPath();
65
+ if (!path.includes(this._menu) && !path.includes(this._quill.root)) {
56
66
  this.hide();
57
67
  }
58
68
  }
59
69
 
60
70
  private updateFromEditor() {
61
71
  const info = this.getToolbarQuery();
62
- if (!info) {
72
+ if (!info || info.start === this._dismissedIndex) {
63
73
  this.hide();
64
74
  return;
65
75
  }
66
76
 
67
77
  const {start, formatQuery} = info;
68
- this._startIndex = start;
69
-
70
- try {
71
- const q = formatQuery.toLowerCase();
72
- this._component.results = this._getOptions().filter(it => !q || it.label.toLowerCase().includes(q) || it?.format?.toLowerCase().includes(q));
73
- } catch {
74
- this._component.results = [];
78
+ const matches = filterSlashItems(this._getOptions(), formatQuery);
79
+ if (!matches.length) {
80
+ this.hide();
81
+ return;
75
82
  }
76
83
 
77
- this._component.query = formatQuery;
84
+ this._startIndex = start;
85
+ this._menu.query = formatQuery;
86
+ this._menu.items = matches;
78
87
  this.show();
79
- this.positionComponent();
80
88
  }
81
89
 
82
- private positionComponent() {
83
- if (!this._component || !this._component.open) return;
84
-
85
- const range = this._quill.getSelection();
86
- if (!range) return;
87
-
88
- const bounds = this._quill.getBounds(range.index);
89
- if (!bounds) return;
90
+ private caretRect(): DOMRect {
91
+ const index = this._startIndex >= 0 ? this._startIndex : (this._quill.getSelection()?.index ?? 0);
92
+ const container = this._quill.container.getBoundingClientRect();
93
+ const bounds = this._quill.getBounds(index);
94
+ if (!bounds) return new DOMRect(container.left, container.top, 0, 0);
90
95
 
91
- const editorBounds = this._quill.container.getBoundingClientRect();
92
- const left = Math.max(0, editorBounds.left + bounds.left);
93
- const top = editorBounds.top + bounds.bottom + 4;
94
- this._component.setPosition(left, top);
96
+ return new DOMRect(container.left + bounds.left, container.top + bounds.top, 0, bounds.height);
95
97
  }
96
98
 
97
99
  private getToolbarQuery(): { start: number; formatQuery: string } | null {
@@ -120,61 +122,50 @@ class ContextMenu {
120
122
  }
121
123
 
122
124
  private onKeydown(e: KeyboardEvent) {
123
- if (!this._component?.open) return;
125
+ if (!this._menu.open) return;
124
126
 
125
- if (e.key === 'Escape') {
126
- e.preventDefault();
127
- e.stopPropagation();
128
- this.hide();
129
- return;
130
- }
131
-
132
- if (e.key === 'ArrowDown') {
133
- e.preventDefault();
134
- e.stopPropagation();
135
- const next = (this._component.getActiveIndex?.() ?? -1) + 1;
136
- this._component.setActiveIndex?.(next);
137
- return;
138
- }
127
+ switch (e.key) {
128
+ case 'Escape':
129
+ e.preventDefault();
130
+ e.stopPropagation();
131
+ this._dismissedIndex = this._startIndex;
132
+ this.hide();
133
+ return;
139
134
 
140
- if (e.key === 'ArrowUp') {
141
- e.preventDefault();
142
- e.stopPropagation();
143
- const prev = (this._component.getActiveIndex?.() ?? 0) - 1;
144
- this._component.setActiveIndex?.(prev);
145
- return;
146
- }
135
+ case 'ArrowDown':
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+ this._menu.moveActive(1);
139
+ return;
147
140
 
148
- if (e.key === 'Enter') {
149
- const idx = this._component.getActiveIndex?.();
150
- const results = (this._component.results as ResultItem[]) || [];
151
- const item = (typeof idx === 'number' && idx >= 0 && idx < results.length) ? results[idx] : undefined;
152
- if (item?.format === 'toolbar' && item?.key) {
141
+ case 'ArrowUp':
153
142
  e.preventDefault();
154
143
  e.stopPropagation();
155
- this._clickToolbarItem(item.key);
144
+ this._menu.moveActive(-1);
156
145
  return;
157
- }
158
- if (item?.format) {
146
+
147
+ case 'Enter': {
148
+ if (!this._menu.activeItem) return;
149
+
159
150
  e.preventDefault();
160
151
  e.stopPropagation();
161
- this._applySelectedFormat(item.format, item.value);
152
+ this._menu.selectActive();
162
153
  }
163
154
  }
164
155
  }
165
156
 
166
- private onToolbarSelect(e: CustomEvent<ResultItem>) {
167
- const key: string | undefined = e.detail?.key as (string | undefined);
168
- if (key) {
169
- this._clickToolbarItem(key);
157
+ private onItemSelect(e: CustomEvent<{ item: ContextMenuItem }>) {
158
+ const item = e.detail?.item;
159
+ if (!item) return;
160
+
161
+ if (item.key) {
162
+ this._clickToolbarItem(item.key);
170
163
  return;
171
164
  }
172
165
 
173
- const format: string = e.detail?.format || '';
174
- const value: string | boolean | undefined = e.detail?.value as (string | boolean | undefined);
175
- if (!format) return;
176
-
177
- this._applySelectedFormat(format, value);
166
+ if (item.format) {
167
+ this._applySelectedFormat(item.format, item.formatValue);
168
+ }
178
169
  }
179
170
 
180
171
  private _clickToolbarItem(key: string) {
@@ -228,8 +219,8 @@ class ContextMenu {
228
219
  }
229
220
  }
230
221
 
231
- private _getOptions(): ResultItem[] {
232
- const options: ResultItem[] = [];
222
+ private _getOptions(): ContextMenuItem[] {
223
+ const options: ContextMenuItem[] = [];
233
224
  let orderCounter = 0;
234
225
 
235
226
  // 1) Quick Actions
@@ -251,11 +242,11 @@ class ContextMenu {
251
242
 
252
243
  if (label && icon) {
253
244
  if (key) {
254
- options.push({icon, label, format: 'toolbar', key: key, order});
245
+ options.push({icon, label, key: key, order});
255
246
  } else if (uri) {
256
- options.push({icon, label, format: 'dialog', value: uri, order});
247
+ options.push({icon, label, format: 'dialog', formatValue: uri, order});
257
248
  } else if (content) {
258
- options.push({icon, label, format: 'insert', value: content, order});
249
+ options.push({icon, label, format: 'insert', formatValue: content, order});
259
250
  }
260
251
  }
261
252
  });
@@ -269,73 +260,72 @@ class ContextMenu {
269
260
  if (!label || !icon || !key) return;
270
261
 
271
262
  const order = typeof tool.order === 'number' ? tool.order : orderCounter++;
272
- options.push({icon, label, format: 'toolbar', key: key, order});
263
+ options.push({icon, label, key: key, order});
273
264
  });
274
265
  }
275
266
 
276
267
  // 2) Built-in Actions (In order they should appear)
277
268
  options.push(
278
- {icon: 'bold@lu', label: 'Bold', format: 'bold', order: orderCounter++},
279
- {icon: 'italic@lu', label: 'Italic', format: 'italic', order: orderCounter++},
280
- {icon: 'underline@lu', label: 'Underline', format: 'underline', order: orderCounter++},
281
- {icon: 'strikethrough@lu', label: 'Strikethrough', format: 'strike', order: orderCounter++},
282
- {icon: 'text-quote@lu', label: 'Blockquote', format: 'blockquote', order: orderCounter++},
269
+ {icon: 'bold@lu', label: 'Bold', format: 'bold', keywords: 'bold', order: orderCounter++},
270
+ {icon: 'italic@lu', label: 'Italic', format: 'italic', keywords: 'italic', order: orderCounter++},
271
+ {icon: 'underline@lu', label: 'Underline', format: 'underline', keywords: 'underline', order: orderCounter++},
272
+ {icon: 'strikethrough@lu', label: 'Strikethrough', format: 'strike', keywords: 'strike', order: orderCounter++},
273
+ {icon: 'text-quote@lu', label: 'Blockquote', format: 'blockquote', keywords: 'blockquote', order: orderCounter++},
283
274
  );
284
275
  if (this._featureConfig.codeEnabled !== false) {
285
- options.push({icon: 'code@lu', label: 'Inline Code', format: 'code', order: orderCounter++});
276
+ options.push({icon: 'code@lu', label: 'Inline Code', format: 'code', keywords: 'code', order: orderCounter++});
286
277
  }
287
278
  if (this._featureConfig.codeBlocksEnabled !== false) {
288
- options.push({icon: 'square-code@lu', label: 'Code Block', format: 'code-block', order: orderCounter++});
279
+ options.push({icon: 'square-code@lu', label: 'Code Block', format: 'code-block', keywords: 'code-block', order: orderCounter++});
289
280
  }
290
281
  options.push(
291
- {icon: 'heading-1@lu', label: 'Heading 1', format: 'header', value: '1', order: orderCounter++},
292
- {icon: 'heading-2@lu', label: 'Heading 2', format: 'header', value: '2', order: orderCounter++},
293
- {icon: 'case-sensitive@lu', label: 'Normal Text', format: 'header', value: '', order: orderCounter++},
294
- {icon: 'list@lu', label: 'Bulleted List', format: 'list', value: 'bullet', order: orderCounter++},
295
- {icon: 'list-ordered@lu', label: 'Numbered List', format: 'list', value: 'ordered', order: orderCounter++},
296
- {icon: 'list-todo@lu', label: 'Checklist', format: 'list', value: 'checked', order: orderCounter++}
282
+ {icon: 'heading-1@lu', label: 'Heading 1', format: 'header', formatValue: '1', keywords: 'header,h1', order: orderCounter++},
283
+ {icon: 'heading-2@lu', label: 'Heading 2', format: 'header', formatValue: '2', keywords: 'header,h2', order: orderCounter++},
284
+ {icon: 'case-sensitive@lu', label: 'Normal Text', format: 'header', formatValue: '', keywords: 'header,paragraph', order: orderCounter++},
285
+ {icon: 'list@lu', label: 'Bulleted List', format: 'list', formatValue: 'bullet', keywords: 'list,bullet', order: orderCounter++},
286
+ {icon: 'list-ordered@lu', label: 'Numbered List', format: 'list', formatValue: 'ordered', keywords: 'list,ordered', order: orderCounter++},
287
+ {icon: 'list-todo@lu', label: 'Checklist', format: 'list', formatValue: 'checked', keywords: 'list,checked,todo', order: orderCounter++}
297
288
  );
298
289
  if (this._featureConfig.linksEnabled !== false) {
299
- options.push({icon: 'link@lu', label: 'Link', format: 'link', value: true, order: orderCounter++});
290
+ options.push({icon: 'link@lu', label: 'Link', format: 'link', formatValue: true, keywords: 'link', order: orderCounter++});
300
291
  }
301
292
  if (this._featureConfig.dividersEnabled !== false) {
302
- options.push({icon: 'minus@lu', label: 'Divider', format: 'divider', order: orderCounter++});
293
+ options.push({icon: 'minus@lu', label: 'Divider', format: 'divider', keywords: 'divider,hr', order: orderCounter++});
303
294
  }
304
295
  if (this._featureConfig.attachmentsEnabled !== false) {
305
- options.push({icon: 'paperclip@lu', label: 'Attachment', format: 'attachment', order: orderCounter++});
296
+ options.push({icon: 'paperclip@lu', label: 'Attachment', format: 'attachment', keywords: 'attachment', order: orderCounter++});
306
297
  }
307
298
  if (this._featureConfig.imagesEnabled !== false) {
308
- options.push({icon: 'image@lu', label: 'Image', format: 'image', order: orderCounter++});
299
+ options.push({icon: 'image@lu', label: 'Image', format: 'image', keywords: 'image', order: orderCounter++});
309
300
  }
310
301
  if (this._featureConfig.videosEnabled !== false) {
311
- options.push({icon: 'video@lu', label: 'Video', format: 'video', order: orderCounter++});
302
+ options.push({icon: 'video@lu', label: 'Video', format: 'video', keywords: 'video', order: orderCounter++});
312
303
  }
313
304
  if (this._featureConfig.datesEnabled !== false) {
314
- options.push({icon: 'calendar@lu', label: 'Date', format: 'date', order: orderCounter++});
305
+ options.push({icon: 'calendar@lu', label: 'Date', format: 'date', keywords: 'date', order: orderCounter++});
315
306
  }
316
- options.push({icon: 'remove-formatting@lu', label: 'Clear Formatting', format: 'clean', order: orderCounter++});
317
-
318
- // Sort options by order property
319
- options.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
307
+ options.push({icon: 'remove-formatting@lu', label: 'Clear Formatting', format: 'clean', keywords: 'clean', order: orderCounter++});
320
308
 
321
309
  return options;
322
310
  }
323
311
 
324
312
  private show() {
325
- if (!this._component.open) {
326
- this._component.show();
327
- this._quill.container.ownerDocument.addEventListener('click', this._docClickHandler);
328
- }
313
+ if (this._menu.open) return;
314
+
315
+ this._menu.show();
316
+ this._quill.container.ownerDocument.addEventListener('click', this._docClickHandler);
329
317
  }
330
318
 
331
319
  private hide() {
332
- this._component.hide();
333
320
  this._startIndex = -1;
321
+ if (!this._menu.open) return;
322
+
323
+ this._menu.hide();
334
324
  this._quill.container.ownerDocument.removeEventListener('click', this._docClickHandler);
335
325
  }
336
326
 
337
327
  public isOpen() {
338
- return this._component?.open;
328
+ return this._menu?.open ?? false;
339
329
  }
340
330
  }
341
331
 
@@ -331,7 +331,10 @@ class Toolbar extends QuillToolbar {
331
331
  const isVisible = (el: HTMLElement | null) => (el?.offsetParent !== null);
332
332
 
333
333
  if (isVisible(button)) {
334
- button!.click();
334
+ // zn-button overrides click() without dispatching a DOM event, so the
335
+ // dropdown's trigger listener never hears it — open the dropdown directly
336
+ const dateDropdown = shadow.querySelector('zn-dropdown.toolbar__date-dropdown') as ZnDropdown | null;
337
+ dateDropdown?.show().then();
335
338
  return;
336
339
  }
337
340
 
@@ -137,6 +137,21 @@ export default class ZnSlashMenu extends ZincElement {
137
137
  disconnectedCallback() {
138
138
  super.disconnectedCallback();
139
139
  this.stopPositioner();
140
+ this.hidePanelPopover();
141
+ }
142
+
143
+ private showPanelPopover() {
144
+ const panel = this.panel;
145
+ if (typeof panel?.showPopover === 'function' && !panel.matches(':popover-open')) {
146
+ panel.showPopover();
147
+ }
148
+ }
149
+
150
+ private hidePanelPopover() {
151
+ const panel = this.panel;
152
+ if (typeof panel?.hidePopover === 'function' && panel.matches(':popover-open')) {
153
+ panel.hidePopover();
154
+ }
140
155
  }
141
156
 
142
157
  private startPositioner() {
@@ -156,6 +171,8 @@ export default class ZnSlashMenu extends ZincElement {
156
171
  const {anchor, panel} = this;
157
172
  if (!this.open || !anchor || !panel) return;
158
173
 
174
+ this.showPanelPopover();
175
+
159
176
  const {x, y} = await computePosition(anchor, panel, {
160
177
  placement: this.placement,
161
178
  strategy: 'fixed',
@@ -211,6 +228,7 @@ export default class ZnSlashMenu extends ZincElement {
211
228
  // mousedown, not click: preventDefault keeps focus (and the caret) in the field
212
229
  private readonly handleItemMouseDown = (event: MouseEvent) => {
213
230
  event.preventDefault();
231
+ event.stopPropagation();
214
232
 
215
233
  const index = Number((event.currentTarget as HTMLElement).dataset.index);
216
234
  const item = this.visibleItems[index];
@@ -233,20 +251,20 @@ export default class ZnSlashMenu extends ZincElement {
233
251
  protected updated(changed: PropertyValues) {
234
252
  super.updated(changed);
235
253
 
236
- // A new list, or a reopen, starts at the top rather than wherever the last one was scrolled to
237
- if (changed.has('items') || (changed.has('open') && this.open)) {
238
- this.scrollActiveIntoView();
239
- }
240
-
241
254
  if (changed.has('open') || changed.has('anchor')) {
242
255
  if (this.open) {
243
256
  this.startPositioner();
244
257
  } else {
245
258
  this.stopPositioner();
259
+ this.hidePanelPopover();
246
260
  }
247
261
  }
248
262
 
249
263
  if (this.open) void this.position();
264
+
265
+ if (changed.has('items') || (changed.has('open') && this.open)) {
266
+ this.scrollActiveIntoView();
267
+ }
250
268
  }
251
269
 
252
270
  private renderItem(item: SlashMenuItem, index: number, showIcons: boolean) {
@@ -310,6 +328,7 @@ export default class ZnSlashMenu extends ZincElement {
310
328
  <div
311
329
  part="panel"
312
330
  class="slash-menu__panel"
331
+ popover="manual"
313
332
  role="listbox"
314
333
  aria-hidden=${this.open ? 'false' : 'true'}
315
334
  aria-label=${this.query ? `Matches for ${this.query}` : this.heading}>
@@ -15,8 +15,8 @@
15
15
  display: flex;
16
16
  flex-direction: column;
17
17
  position: fixed;
18
- top: 0;
19
- left: 0;
18
+ inset: 0 auto auto 0;
19
+ margin: 0;
20
20
  width: var(--slash-menu-width);
21
21
  max-width: var(--auto-size-available-width, none);
22
22
  max-height: min(var(--slash-menu-max-height), var(--auto-size-available-height, 100vh));
@@ -63,10 +63,22 @@ describe('<zn-slash-menu>', () => {
63
63
 
64
64
  const item = el.shadowRoot!.querySelector<HTMLButtonElement>('[data-slash-item]')!;
65
65
  const event = new MouseEvent('mousedown', {bubbles: true, cancelable: true, composed: true});
66
- item.dispatchEvent(event);
66
+
67
+ let reachedDocument = false;
68
+ const documentListener = () => {
69
+ reachedDocument = true;
70
+ };
71
+ document.addEventListener('mousedown', documentListener);
72
+ try {
73
+ item.dispatchEvent(event);
74
+ } finally {
75
+ document.removeEventListener('mousedown', documentListener);
76
+ }
67
77
 
68
78
  expect(selected).to.deep.equal(['Brand name']);
69
79
  expect(event.defaultPrevented, 'mousedown is prevented so the field keeps focus').to.be.true;
80
+ // A selection may open another overlay; the mousedown must not leak to its outside-click dismisser
81
+ expect(reachedDocument, 'mousedown does not bubble to document').to.be.false;
70
82
  });
71
83
 
72
84
  it('reports how many matches were not rendered', async () => {