@kubex/zinc 1.1.78 → 1.1.80

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 (34) hide show
  1. package/dist/custom-elements.json +1149 -130
  2. package/dist/vscode.html-custom-data.json +175 -1
  3. package/dist/web-types.json +396 -2
  4. package/dist/zn.d.ts +384 -0
  5. package/dist/zn.min.js +350 -295
  6. package/docs/pages/components/inline-edit.md +21 -0
  7. package/docs/pages/components/rating.md +2 -1
  8. package/docs/pages/components/slash-item.md +126 -0
  9. package/docs/pages/components/slash-menu.md +168 -0
  10. package/docs/pages/components/textarea.md +148 -0
  11. package/docs/pages/components/translations.md +34 -0
  12. package/package.json +1 -1
  13. package/src/components/inline-edit/inline-edit.component.ts +31 -0
  14. package/src/components/inline-edit/inline-edit.test.ts +83 -1
  15. package/src/components/rating/rating.component.ts +10 -1
  16. package/src/components/rating/rating.scss +6 -9
  17. package/src/components/slash-item/index.ts +12 -0
  18. package/src/components/slash-item/slash-item.component.ts +76 -0
  19. package/src/components/slash-item/slash-item.scss +5 -0
  20. package/src/components/slash-menu/index.ts +14 -0
  21. package/src/components/slash-menu/slash-menu-controller.ts +361 -0
  22. package/src/components/slash-menu/slash-menu-items.ts +122 -0
  23. package/src/components/slash-menu/slash-menu.component.ts +305 -0
  24. package/src/components/slash-menu/slash-menu.scss +120 -0
  25. package/src/components/slash-menu/slash-menu.test.ts +154 -0
  26. package/src/components/textarea/textarea.component.ts +143 -0
  27. package/src/components/textarea/textarea.test.ts +310 -2
  28. package/src/components/translations/translations.component.ts +31 -0
  29. package/src/components/translations/translations.test.ts +89 -1
  30. package/src/events/events.ts +2 -0
  31. package/src/events/zn-slash-insert.ts +9 -0
  32. package/src/events/zn-slash-select.ts +9 -0
  33. package/src/utilities/caret-position.ts +118 -0
  34. package/src/zinc.ts +3 -0
@@ -1,7 +1,19 @@
1
1
  import '../../../dist/zn.min.js';
2
- import { expect, fixture, html } from '@open-wc/testing';
2
+ import { expect, fixture, html, waitUntil } from '@open-wc/testing';
3
3
  import type ZnInlineEdit from './inline-edit.component';
4
4
  import type ZnSelect from '../select/select.component';
5
+ import type ZnSlashMenu from '../slash-menu/slash-menu.component';
6
+ import type ZnTextarea from '../textarea/textarea.component';
7
+
8
+ // The auto-resizing textarea's ResizeObserver can emit a benign "loop completed with undelivered
9
+ // notifications" warning while the slash menu is positioned. It's not a real error — ignore it so the
10
+ // test runner doesn't treat it as an uncaught exception (capture phase runs before the runner's).
11
+ window.addEventListener('error', (e: ErrorEvent) => {
12
+ if (typeof e.message === 'string' && e.message.includes('ResizeObserver loop')) {
13
+ e.stopImmediatePropagation();
14
+ e.preventDefault();
15
+ }
16
+ }, true);
5
17
 
6
18
  describe('<zn-inline-edit>', () => {
7
19
  it('should render a component', async () => {
@@ -483,4 +495,74 @@ describe('<zn-inline-edit>', () => {
483
495
  expect(el.shadowRoot!.querySelector('.ai--editing')).to.not.exist;
484
496
  expect(el.value).to.equal('real@example.com');
485
497
  });
498
+
499
+ // -- Slash menu --
500
+
501
+ describe('slash menu', () => {
502
+ async function openSlashMenu(el: ZnInlineEdit) {
503
+ await el.updateComplete;
504
+
505
+ el.shadowRoot!.querySelector<HTMLElement>('.ai__left')!
506
+ .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
507
+ await el.updateComplete;
508
+
509
+ const textarea = el.shadowRoot!.querySelector<ZnTextarea>('zn-textarea')!;
510
+ await textarea.updateComplete;
511
+
512
+ textarea.focus();
513
+ textarea.input.value = '/';
514
+ textarea.input.setSelectionRange(1, 1);
515
+ textarea.input.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true}));
516
+
517
+ const menuOf = () => textarea.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu');
518
+ await waitUntil(() => menuOf()?.open, 'the slash menu never opened');
519
+
520
+ return {textarea, menu: menuOf()!};
521
+ }
522
+
523
+ it('offers its slash items on the inner textarea', async () => {
524
+ const el = await fixture<ZnInlineEdit>(html`
525
+ <zn-inline-edit input-type="textarea"
526
+ slash-items="Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}"></zn-inline-edit>`);
527
+ const {menu} = await openSlashMenu(el);
528
+
529
+ expect(menu.items.map(item => item.label)).to.deep.equal(['Brand name', 'Support email']);
530
+ });
531
+
532
+ it('forwards a custom trigger', async () => {
533
+ const el = await fixture<ZnInlineEdit>(html`
534
+ <zn-inline-edit input-type="textarea" slash-trigger="{{"
535
+ slash-items="Brand name={{BRAND_NAME}}"></zn-inline-edit>`);
536
+ await el.updateComplete;
537
+
538
+ const textarea = el.shadowRoot!.querySelector<ZnTextarea>('zn-textarea')!;
539
+ expect(textarea.slashTrigger).to.equal('{{');
540
+ });
541
+
542
+ it('does not submit the form when Enter chooses an item', async () => {
543
+ const form = await fixture<HTMLFormElement>(html`
544
+ <form>
545
+ <zn-inline-edit input-type="textarea" slash-items="Brand name={{BRAND_NAME}}"></zn-inline-edit>
546
+ </form>`);
547
+ const el = form.querySelector<ZnInlineEdit>('zn-inline-edit')!;
548
+
549
+ let submits = 0;
550
+ form.addEventListener('submit', event => {
551
+ event.preventDefault();
552
+ submits++;
553
+ });
554
+
555
+ const {textarea} = await openSlashMenu(el);
556
+ textarea.input.dispatchEvent(new KeyboardEvent('keydown', {
557
+ key: 'Enter',
558
+ bubbles: true,
559
+ composed: true,
560
+ cancelable: true
561
+ }));
562
+ await el.updateComplete;
563
+
564
+ expect(el.value, 'the token should have replaced the trigger').to.equal('{{BRAND_NAME}}');
565
+ expect(submits, 'Enter belongs to the open menu, not the form').to.equal(0);
566
+ });
567
+ });
486
568
  });
@@ -112,7 +112,16 @@ export default class ZnRating extends ZincElement implements ZincFormControl {
112
112
 
113
113
  private _getValueFromXCoordinate(coordinate: number): number {
114
114
  const {left, width} = this.symbols.getBoundingClientRect();
115
- const value = this._roundToPrecision(((coordinate - left) / width) * this.max, this.precision);
115
+ // Symbols are spaced with a gap rather than padding, so the symbols only cover part of the
116
+ // container and a plain width-to-value ratio would skew fractions within each symbol.
117
+ const gap = parseFloat(getComputedStyle(this.symbols).columnGap) || 0;
118
+ const symbolWidth = (width - gap * (this.max - 1)) / this.max;
119
+ const stride = symbolWidth + gap;
120
+ const position = Math.min(Math.max(coordinate - left, 0), width);
121
+ const index = Math.min(Math.floor(position / stride), this.max - 1);
122
+ // Landing in a gap reads as the preceding symbol being complete.
123
+ const fraction = Math.min((position - index * stride) / symbolWidth, 1);
124
+ const value = this._roundToPrecision(index + fraction, this.precision);
116
125
 
117
126
  return Math.min(Math.max(value, 0), this.max);
118
127
  }
@@ -5,7 +5,7 @@
5
5
  --symbol-color: rgb(var(--zn-border-color));
6
6
  --symbol-color-active: var(--zn-color-amber-400);
7
7
  --symbol-size: 1.2rem;
8
- --symbol-spacing: 4px;
8
+ --symbol-spacing: var(--zn-spacing-x-small);
9
9
  --preview-color: rgb(var(--zn-text));
10
10
  --preview-size: 0.875rem;
11
11
 
@@ -31,15 +31,12 @@
31
31
  &__symbols {
32
32
  display: inline-flex;
33
33
  position: relative;
34
+ gap: var(--symbol-spacing);
34
35
  font-size: var(--symbol-size);
35
36
  line-height: 0;
36
37
  color: var(--symbol-color);
37
38
  white-space: nowrap;
38
39
  cursor: pointer;
39
-
40
- > * {
41
- padding: var(--symbol-spacing);
42
- }
43
40
  }
44
41
 
45
42
  &__symbol--active,
@@ -53,8 +50,8 @@
53
50
 
54
51
  &__partial--filled {
55
52
  position: absolute;
56
- top: var(--symbol-spacing);
57
- left: var(--symbol-spacing);
53
+ top: 0;
54
+ left: 0;
58
55
  }
59
56
 
60
57
  &__symbol {
@@ -106,7 +103,7 @@
106
103
 
107
104
  &--small {
108
105
  --symbol-size: 1rem;
109
- --symbol-spacing: 2px;
106
+ --symbol-spacing: var(--zn-spacing-2x-small);
110
107
  }
111
108
 
112
109
  &--medium {
@@ -115,6 +112,6 @@
115
112
 
116
113
  &--large {
117
114
  --symbol-size: 1.5rem;
118
- --symbol-spacing: 6px;
115
+ --symbol-spacing: 12px;
119
116
  }
120
117
  }
@@ -0,0 +1,12 @@
1
+ import ZnSlashItem from './slash-item.component';
2
+
3
+ export * from './slash-item.component';
4
+ export default ZnSlashItem;
5
+
6
+ ZnSlashItem.define('zn-slash-item');
7
+
8
+ declare global {
9
+ interface HTMLElementTagNameMap {
10
+ 'zn-slash-item': ZnSlashItem;
11
+ }
12
+ }
@@ -0,0 +1,76 @@
1
+ import {html, unsafeCSS} from 'lit';
2
+ import {property} from 'lit/decorators.js';
3
+ import ZincElement from '../../internal/zinc-element';
4
+ import type {CSSResultGroup} from 'lit';
5
+ import type {SlashMenuItem} from '../slash-menu/slash-menu-items';
6
+
7
+ import styles from './slash-item.scss';
8
+
9
+ /**
10
+ * @summary Declares a single insertion for a slash menu. Renders nothing itself — it describes an
11
+ * entry for the component it is slotted into, e.g. `<zn-textarea>`'s `slash-items` slot.
12
+ * @documentation https://zinc.style/components/slash-item
13
+ * @status experimental
14
+ * @since 1.1
15
+ *
16
+ * @slot - The text to insert, for values that are long or span multiple lines. Ignored when the
17
+ * `value` attribute is set.
18
+ */
19
+ export default class ZnSlashItem extends ZincElement {
20
+ static styles: CSSResultGroup = unsafeCSS(styles);
21
+
22
+ /** The text shown in the menu. */
23
+ @property() label = '';
24
+
25
+ /** The text inserted into the field. Falls back to this element's text content. */
26
+ @property() value: string;
27
+
28
+ /** Icon shown against the item, e.g. `tag@lu`. */
29
+ @property() icon: string;
30
+
31
+ /** Supporting text shown under the label. */
32
+ @property() description: string;
33
+
34
+ /** Extra terms the item can be found by, comma separated. */
35
+ @property() keywords: string;
36
+
37
+ /** Heading the item is listed under. */
38
+ @property() group: string;
39
+
40
+ /** Overrides the item's position in the menu. Lower sorts first. */
41
+ @property({type: Number}) order: number;
42
+
43
+ /** Where the caret lands after insertion, as an offset into the inserted value. */
44
+ @property({attribute: 'caret-offset', type: Number}) caretOffset: number;
45
+
46
+ /** Identifier passed through on `zn-slash-select`, for items that do something other than insert. */
47
+ @property() action: string;
48
+
49
+ /** Listed, but not selectable. */
50
+ @property({type: Boolean, reflect: true}) disabled = false;
51
+
52
+ /** The item as the slash menu consumes it. */
53
+ toSlashMenuItem(): SlashMenuItem {
54
+ return {
55
+ label: this.label || this.insertValue,
56
+ value: this.insertValue,
57
+ icon: this.icon,
58
+ description: this.description,
59
+ keywords: this.keywords,
60
+ group: this.group,
61
+ order: this.order,
62
+ action: this.action,
63
+ caretOffset: this.caretOffset,
64
+ disabled: this.disabled
65
+ };
66
+ }
67
+
68
+ private get insertValue(): string {
69
+ return this.value ?? (this.textContent ?? '').trim();
70
+ }
71
+
72
+ render() {
73
+ return html`
74
+ <slot></slot>`;
75
+ }
76
+ }
@@ -0,0 +1,5 @@
1
+ @use "../../wc";
2
+
3
+ :host {
4
+ display: none;
5
+ }
@@ -0,0 +1,14 @@
1
+ import ZnSlashMenu from './slash-menu.component';
2
+
3
+ export * from './slash-menu.component';
4
+ export * from './slash-menu-controller';
5
+ export * from './slash-menu-items';
6
+ export default ZnSlashMenu;
7
+
8
+ ZnSlashMenu.define('zn-slash-menu');
9
+
10
+ declare global {
11
+ interface HTMLElementTagNameMap {
12
+ 'zn-slash-menu': ZnSlashMenu;
13
+ }
14
+ }
@@ -0,0 +1,361 @@
1
+ import {caretRectFrom, getCaretCoordinates} from '../../utilities/caret-position';
2
+ import {filterSlashItems} from './slash-menu-items';
3
+ import {SLASH_ITEM_SELECT} from './slash-menu.component';
4
+ import type {CaretCoordinates, TextField} from '../../utilities/caret-position';
5
+ import type {ReactiveController, ReactiveControllerHost} from 'lit';
6
+ import type {SlashMenuItem} from './slash-menu-items';
7
+ import type {VirtualElement} from '../popup';
8
+ import type ZnSlashMenu from './slash-menu.component';
9
+
10
+ export interface SlashMenuControllerOptions {
11
+ /**
12
+ * Resolves the menu to render results into. Called the first time the menu is needed, so the host
13
+ * can render it lazily; may return a promise (e.g. after awaiting `updateComplete`).
14
+ */
15
+ menu: () => ZnSlashMenu | null | Promise<ZnSlashMenu | null>;
16
+ /** The available items, unfiltered. Receives the current query so lists can be resolved remotely. */
17
+ items: (query: string) => SlashMenuItem[] | Promise<SlashMenuItem[]>;
18
+ /** The characters that open the menu. Defaults to `/`. */
19
+ trigger?: () => string;
20
+ /** Called before an item is inserted. Return `false` to handle the item yourself. */
21
+ onSelect?: (item: SlashMenuItem, query: string) => boolean;
22
+ /** Called after an item's value has been written into the field. */
23
+ onInsert?: (item: SlashMenuItem, value: string) => void;
24
+ }
25
+
26
+ /** Queries longer than this are treated as prose the user never meant as a menu search. */
27
+ const MAX_QUERY_LENGTH = 40;
28
+
29
+ /**
30
+ * Drives a slash menu for a plain `<textarea>` or `<input>`: watches the caret for the trigger
31
+ * sequence, resolves and filters items, and inserts the chosen value.
32
+ *
33
+ * The host owns the field and the menu element; this controller owns the interaction.
34
+ */
35
+ export class SlashMenuController implements ReactiveController {
36
+ private readonly host: ReactiveControllerHost & HTMLElement;
37
+ private readonly options: SlashMenuControllerOptions;
38
+ private readonly caretAnchor: VirtualElement = {
39
+ getBoundingClientRect: () => this.caretRect()
40
+ };
41
+
42
+ private field: TextField | null = null;
43
+ private menu: ZnSlashMenu | null = null;
44
+ private triggerIndex = -1;
45
+ private query = '';
46
+ /** Trigger position the user dismissed with Escape; the menu stays shut until they move off it. */
47
+ private dismissedIndex = -1;
48
+ private resolveToken = 0;
49
+ private inserting = false;
50
+ private isOpen = false;
51
+ private listening = false;
52
+ /** Caret measurement is the expensive part of positioning, so the last one is reused. */
53
+ private measured?: {value: string; index: number; width: number; coordinates: CaretCoordinates};
54
+
55
+ constructor(host: ReactiveControllerHost & HTMLElement, options: SlashMenuControllerOptions) {
56
+ this.host = host;
57
+ this.options = options;
58
+ host.addController(this);
59
+ }
60
+
61
+ /** Whether the menu is currently showing. */
62
+ get open(): boolean {
63
+ return this.isOpen;
64
+ }
65
+
66
+ hostConnected() {
67
+ this.addListeners();
68
+ }
69
+
70
+ hostDisconnected() {
71
+ // The field is kept, so reconnecting the host resumes where it left off
72
+ this.removeListeners();
73
+ }
74
+
75
+ /** Starts watching a field. Safe to call repeatedly with the same field. */
76
+ attach(field: TextField) {
77
+ if (this.field === field && this.listening) return;
78
+
79
+ this.removeListeners();
80
+ this.field = field;
81
+ this.caretAnchor.contextElement = field;
82
+ this.addListeners();
83
+ }
84
+
85
+ /** Stops watching the current field and closes the menu. */
86
+ detach() {
87
+ this.removeListeners();
88
+ this.field = null;
89
+ }
90
+
91
+ private addListeners() {
92
+ const field = this.field;
93
+ if (!field || this.listening) return;
94
+
95
+ // Capture phase, so menu navigation keys are claimed before the host's own key handling
96
+ field.addEventListener('keydown', this.handleKeyDown, {capture: true});
97
+ field.addEventListener('input', this.handleInput);
98
+ field.addEventListener('keyup', this.handleKeyUp);
99
+ field.addEventListener('click', this.handleCaretMove);
100
+ field.addEventListener('blur', this.handleBlur);
101
+ field.addEventListener('scroll', this.handleScroll);
102
+ this.listening = true;
103
+ }
104
+
105
+ private removeListeners() {
106
+ const field = this.field;
107
+ if (!field || !this.listening) return;
108
+
109
+ this.close();
110
+ field.removeEventListener('keydown', this.handleKeyDown, {capture: true});
111
+ field.removeEventListener('input', this.handleInput);
112
+ field.removeEventListener('keyup', this.handleKeyUp);
113
+ field.removeEventListener('click', this.handleCaretMove);
114
+ field.removeEventListener('blur', this.handleBlur);
115
+ field.removeEventListener('scroll', this.handleScroll);
116
+ this.listening = false;
117
+ }
118
+
119
+ /** Closes the menu without marking the trigger as dismissed. */
120
+ close() {
121
+ this.resolveToken++;
122
+ this.triggerIndex = -1;
123
+ this.query = '';
124
+
125
+ if (this.isOpen) {
126
+ this.isOpen = false;
127
+ this.menu?.hide();
128
+ this.field?.setAttribute('aria-expanded', 'false');
129
+ }
130
+ }
131
+
132
+ /** Opens the menu at the caret, as a toolbar button or keyboard shortcut would. */
133
+ requestOpen() {
134
+ this.dismissedIndex = -1;
135
+ this.detect();
136
+ }
137
+
138
+ private caretRect(): DOMRect {
139
+ const field = this.field;
140
+ if (!field) return new DOMRect();
141
+
142
+ const index = this.triggerIndex >= 0 ? this.triggerIndex : (field.selectionStart ?? 0);
143
+ const width = field.clientWidth;
144
+ const cached = this.measured;
145
+
146
+ if (!cached || cached.index !== index || cached.width !== width || cached.value !== field.value) {
147
+ this.measured = {value: field.value, index, width, coordinates: getCaretCoordinates(field, index)};
148
+ }
149
+
150
+ return caretRectFrom(field, this.measured!.coordinates);
151
+ }
152
+
153
+ private readonly handleInput = () => this.detect();
154
+
155
+ private readonly handleCaretMove = () => this.detect();
156
+
157
+ private readonly handleKeyUp = (event: KeyboardEvent) => {
158
+ // The vertical arrows belong to the open menu, and its keydown handler stopped them from moving
159
+ // the caret — re-detecting here would rebuild the list and drop the user's place in it
160
+ if (this.isOpen && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) return;
161
+
162
+ // Typing is covered by `input`; this catches caret moves that don't change the value
163
+ if (event.key.startsWith('Arrow') || event.key === 'Home' || event.key === 'End') {
164
+ this.detect();
165
+ }
166
+ };
167
+
168
+ private readonly handleBlur = () => this.close();
169
+
170
+ private readonly handleScroll = () => {
171
+ if (this.isOpen) this.menu?.reposition();
172
+ };
173
+
174
+ private readonly handleKeyDown = (event: KeyboardEvent) => {
175
+ if (!this.isOpen || event.isComposing) return;
176
+
177
+ const claim = () => {
178
+ event.preventDefault();
179
+ event.stopPropagation();
180
+ };
181
+
182
+ switch (event.key) {
183
+ case 'Escape':
184
+ claim();
185
+ this.dismissedIndex = this.triggerIndex;
186
+ this.close();
187
+ return;
188
+
189
+ case 'ArrowDown':
190
+ claim();
191
+ this.menu?.moveActive(1);
192
+ return;
193
+
194
+ case 'ArrowUp':
195
+ claim();
196
+ this.menu?.moveActive(-1);
197
+ return;
198
+
199
+ case 'Enter':
200
+ case 'Tab': {
201
+ if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
202
+
203
+ const item = this.menu?.activeItem;
204
+ if (!item) {
205
+ this.close();
206
+ return;
207
+ }
208
+
209
+ claim();
210
+ this.select(item);
211
+ }
212
+ }
213
+ };
214
+
215
+ private readonly handleItemSelect = (event: Event) => {
216
+ const {item} = (event as CustomEvent<{item: SlashMenuItem}>).detail;
217
+ if (item) this.select(item);
218
+ };
219
+
220
+ private detect() {
221
+ if (this.inserting) return;
222
+
223
+ const field = this.field;
224
+ if (!field || field.disabled || field.readOnly) {
225
+ this.close();
226
+ return;
227
+ }
228
+
229
+ const trigger = this.options.trigger?.() || '/';
230
+ const caret = field.selectionStart;
231
+ // Only a collapsed caret opens the menu — a selection means the user is doing something else
232
+ if (!trigger || caret === null || field.selectionEnd !== caret) {
233
+ this.close();
234
+ return;
235
+ }
236
+
237
+ const value = field.value;
238
+ const index = value.lastIndexOf(trigger, Math.max(0, caret - trigger.length));
239
+ if (index === -1 || index + trigger.length > caret) {
240
+ this.close();
241
+ return;
242
+ }
243
+
244
+ // The trigger only counts at the start of a word
245
+ const preceding = index > 0 ? value.charAt(index - 1) : '';
246
+ if (preceding !== '' && !/\s/.test(preceding)) {
247
+ this.close();
248
+ return;
249
+ }
250
+
251
+ const query = value.slice(index + trigger.length, caret);
252
+ if (query.length > MAX_QUERY_LENGTH || /[\r\n]/.test(query)) {
253
+ this.close();
254
+ return;
255
+ }
256
+
257
+ if (index === this.dismissedIndex) {
258
+ this.close();
259
+ return;
260
+ }
261
+
262
+ this.triggerIndex = index;
263
+ this.query = query;
264
+ void this.resolve(query);
265
+ }
266
+
267
+ private async resolve(query: string) {
268
+ const token = ++this.resolveToken;
269
+
270
+ let items: SlashMenuItem[] = [];
271
+ try {
272
+ items = await this.options.items(query);
273
+ } catch (error: unknown) {
274
+ console.warn('slash menu items could not be resolved', error);
275
+ }
276
+
277
+ if (token !== this.resolveToken) return;
278
+
279
+ const matches = filterSlashItems(items, query);
280
+ if (!matches.length) {
281
+ this.close();
282
+ return;
283
+ }
284
+
285
+ const menu = await this.options.menu();
286
+ if (!menu || token !== this.resolveToken) return;
287
+
288
+ if (menu !== this.menu) {
289
+ this.menu?.removeEventListener(SLASH_ITEM_SELECT, this.handleItemSelect);
290
+ menu.addEventListener(SLASH_ITEM_SELECT, this.handleItemSelect);
291
+ this.menu = menu;
292
+ }
293
+
294
+ menu.anchor = this.caretAnchor;
295
+ menu.query = query;
296
+ menu.items = matches;
297
+
298
+ if (!this.isOpen) {
299
+ this.isOpen = true;
300
+ this.dismissedIndex = -1;
301
+ menu.show();
302
+ this.field?.setAttribute('aria-expanded', 'true');
303
+ this.field?.setAttribute('aria-haspopup', 'listbox');
304
+ }
305
+
306
+ await menu.updateComplete;
307
+ if (this.isOpen) menu.reposition();
308
+ }
309
+
310
+ private select(item: SlashMenuItem) {
311
+ const field = this.field;
312
+ const start = this.triggerIndex;
313
+ const end = field?.selectionStart ?? start;
314
+ const query = this.query;
315
+ const trigger = this.options.trigger?.() || '/';
316
+
317
+ if (item.disabled) return;
318
+
319
+ this.close();
320
+ if (!field || start < 0) return;
321
+
322
+ const cancelled = this.options.onSelect?.(item, query) === false;
323
+ const value = cancelled ? '' : (item.value ?? '');
324
+
325
+ // A handler may have rewritten the field already; only touch text we still recognise as ours
326
+ if (field.value.slice(start, end) !== trigger + query) return;
327
+
328
+ // The trigger and query are a command rather than content, so they go either way
329
+ this.replace(field, start, end, value, item);
330
+
331
+ if (!cancelled && value) this.options.onInsert?.(item, value);
332
+ }
333
+
334
+ private replace(field: TextField, start: number, end: number, value: string, item: SlashMenuItem) {
335
+ this.inserting = true;
336
+ try {
337
+ field.focus({preventScroll: true});
338
+ field.setSelectionRange(start, end);
339
+
340
+ // execCommand keeps the field's native undo history intact; setRangeText does not
341
+ let handled = false;
342
+ try {
343
+ handled = field.ownerDocument.execCommand('insertText', false, value);
344
+ } catch {
345
+ handled = false;
346
+ }
347
+
348
+ if (!handled) {
349
+ field.setRangeText(value, start, end, 'end');
350
+ field.dispatchEvent(new Event('input', {bubbles: true, composed: true}));
351
+ }
352
+
353
+ const offset = Math.min(Math.max(item.caretOffset ?? value.length, 0), value.length);
354
+ field.setSelectionRange(start + offset, start + offset);
355
+ } finally {
356
+ this.inserting = false;
357
+ }
358
+
359
+ this.host.requestUpdate();
360
+ }
361
+ }