@kubex/zinc 1.1.80 → 1.1.81

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.
@@ -122,8 +122,9 @@ Use `input-type="textarea"` for multi-line text content.
122
122
  {% raw %}
123
123
 
124
124
  `slash-items`, `slash-preset`, `slash-trigger` and `slash-heading` are forwarded to the inner
125
- [`zn-textarea`](/components/textarea#slash-menu-quick-insertions), so typing `/` offers replacement strings at the
126
- caret. The menu claims `Enter` and `Escape` while it is open, so choosing an item neither submits nor cancels the edit.
125
+ [`zn-textarea`](/components/textarea#slash-menu-quick-insertions) or
126
+ [`zn-input`](/components/input#slash-menu-quick-insertions), so typing `/` offers replacement strings at the caret on
127
+ both. The menu claims `Enter` and `Escape` while it is open, so choosing an item neither submits nor cancels the edit.
127
128
 
128
129
  ```html:preview
129
130
  <zn-inline-edit
@@ -464,6 +464,27 @@ Add the `trigger-submit` attribute to make pressing Enter in the input always su
464
464
  </script>
465
465
  ```
466
466
 
467
+ ### Slash Menu Quick Insertions
468
+
469
+ {% raw %}
470
+
471
+ Typing `/` opens a menu at the caret, exactly as it does on
472
+ [`zn-textarea`](/components/textarea#slash-menu-quick-insertions) — useful for short single-line copy that carries
473
+ replacement strings, such as a headline or a button label. The same `slash-items`, `slash-preset`, `slash-trigger` and
474
+ `slash-heading` attributes apply, and `zn-slash-item` children work too.
475
+
476
+ ```html:preview
477
+ <zn-input
478
+ label="Confirmation headline"
479
+ help-text="Type / to insert a replacement string"
480
+ slash-items="Brand name={{BRAND_NAME}}, Customer email={{CUSTOMER_EMAIL}}"></zn-input>
481
+ ```
482
+
483
+ While the menu is open it claims `↑`, `↓`, `Enter`, `Tab` and `Escape`, so choosing an item does not submit the
484
+ surrounding form the way `Enter` otherwise would.
485
+
486
+ {% endraw %}
487
+
467
488
  ### Customizing Label Position
468
489
 
469
490
  Use [CSS parts](#css-parts) to customize the way form controls are drawn. This example uses CSS grid to position the
@@ -42,6 +42,28 @@ block committed empty is removed.
42
42
  ></zn-remarkd-editor>
43
43
  ```
44
44
 
45
+ ### Slash Menu
46
+
47
+ Typing `/` in an empty block opens a [slash menu](/components/slash-menu) of
48
+ block types at the caret. Keep typing to narrow it, `↑`/`↓` to move, `Enter` or
49
+ `Tab` to insert, `Escape` to dismiss. The menu only appears in a block that is
50
+ nothing but the slash command — a prefix like `## ` is not valid remarkd
51
+ part-way through a line — so `/` in the middle of a sentence stays literal
52
+ text.
53
+
54
+ Choosing a block type replaces the command with its remarkd prefix and leaves
55
+ the caret ready to type: `Heading 2` gives you `## `, `Code` opens a fence with
56
+ the caret inside it, and `Image` opens the upload picker instead of inserting
57
+ text. The same block types are on the toolbar, which inserts them as new blocks
58
+ at the end of the document.
59
+
60
+ ```html:preview
61
+ <zn-remarkd-editor
62
+ name="content"
63
+ value="Click the empty space below this block, then type / to see the menu."
64
+ ></zn-remarkd-editor>
65
+ ```
66
+
45
67
  ### Images
46
68
 
47
69
  Adding an image shows a `zn-file` drop area inline, at the point in the
@@ -92,6 +114,27 @@ with a blank line inside
92
114
  ></zn-remarkd-editor>
93
115
  ````
94
116
 
117
+ ### Raw Source
118
+
119
+ Add `allow-raw` for a toolbar toggle that swaps the block view for the whole
120
+ document as editable remarkd source. Useful for pasting in a finished document
121
+ or fixing syntax the block view makes awkward. Toggling back re-splits the
122
+ source into blocks, normalising the spacing between them to a single blank
123
+ line. The toggle is only offered on editable editors — `readonly` and
124
+ `disabled` instances hide the toolbar entirely.
125
+
126
+ ```html:preview
127
+ <zn-remarkd-editor
128
+ name="content"
129
+ allow-raw
130
+ value="# Raw Source
131
+
132
+ Hit the toggle at the right of the toolbar to edit this whole document at once.
133
+
134
+ NOTE: The value is the same remarkd source either way."
135
+ ></zn-remarkd-editor>
136
+ ```
137
+
95
138
  ### Form Integration
96
139
 
97
140
  `zn-remarkd-editor` is a [form control](/getting-started/form-controls); its
@@ -113,20 +113,13 @@ The menu doesn't listen for keys itself — whatever owns the field decides whic
113
113
  scrolls when its content exceeds `--slash-menu-max-height`.
114
114
 
115
115
  ```html:preview
116
- <zn-button id="capped-toggle">Show 3 of 9</zn-button>
117
- <zn-slash-menu id="capped-menu" max-items="3"></zn-slash-menu>
118
-
119
- <script type="module">
120
- const menu = document.getElementById('capped-menu');
121
- const toggle = document.getElementById('capped-toggle');
122
-
123
- await customElements.whenDefined('zn-slash-menu');
124
-
125
- menu.items = Array.from({length: 9}, (_, i) => ({label: `Replacement ${i + 1}`, value: `{{TOKEN_${i + 1}}}`}));
126
- menu.anchor = toggle;
127
-
128
- toggle.addEventListener('click', () => menu.open ? menu.hide() : menu.show());
129
- </script>
116
+ <zn-textarea label="Terms and conditions" rows="5" help-text="Type / to see 3 of 9, then keep typing to narrow"
117
+ slash-items="Brand name={{BRAND_NAME}}, Legal entity={{LEGAL_ENTITY}}, Jurisdiction={{JURISDICTION}},
118
+ Customer name={{CUSTOMER_NAME}}, Customer email={{CUSTOMER_EMAIL}},
119
+ Invoice number={{INVOICE_NUMBER}}, Invoice date={{INVOICE_DATE}},
120
+ Support email={{SUPPORT_EMAIL}}, Support phone={{SUPPORT_PHONE}}">
121
+ <zn-slash-menu slot="slash-menu" heading="Replacement strings" max-items="3"></zn-slash-menu>
122
+ </zn-textarea>
130
123
  ```
131
124
 
132
125
  ### Attaching It To Your Own Field
@@ -328,8 +328,8 @@ A complete example showing product content management with translations.
328
328
  {% raw %}
329
329
 
330
330
  Copy that carries replacement strings — `{{BRAND_NAME}}` and the like — needs the same tokens in every language. Set
331
- `slash-items` alongside `input-type="textarea"` and typing `/` offers them at the caret, in whichever language is
332
- being edited. The attribute takes the same shorthand and JSON that
331
+ `slash-items` and typing `/` offers them at the caret, in whichever language is being edited. This works on both the
332
+ single-line default and `input-type="textarea"`. The attribute takes the same shorthand and JSON that
333
333
  [`zn-textarea`](/components/textarea#slash-menu-quick-insertions) accepts, and `slash-preset`, `slash-trigger` and
334
334
  `slash-heading` are forwarded too.
335
335
 
@@ -365,7 +365,7 @@ way `Enter` otherwise would.
365
365
  | `flush` | `boolean` | `false` | Removes padding for compact layout |
366
366
  | `languages` | `Record<string, string>` | `{en: "EN"}` | Object mapping language codes to display names |
367
367
  | `values` | `Record<string, string>` | `{}` | Object mapping language codes to translation text |
368
- | `slash-items` | `SlashMenuItem[]` | `[]` | Quick insertions offered by the slash menu on a `textarea` input |
368
+ | `slash-items` | `SlashMenuItem[]` | `[]` | Quick insertions offered by the slash menu |
369
369
  | `slash-preset` | `string` | `''` | Registered item sets to offer, comma separated |
370
370
  | `slash-trigger` | `string` | `'/'` | The characters that open the slash menu |
371
371
  | `slash-heading` | `string` | `'Insert'` | Heading shown above the slash menu's items |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.80",
3
+ "version": "1.1.81",
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",
@@ -99,8 +99,8 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
99
99
  @property({ attribute: "textarea-rows", type: Number }) textareaRows: 1;
100
100
 
101
101
  /**
102
- * Quick insertions offered by the slash menu of a `textarea` input. Accepts a JSON array of items, or the
103
- * shorthand `Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}`. Forwarded to the inner `zn-textarea`.
102
+ * Quick insertions offered by the slash menu of a `text` or `textarea` input. Accepts a JSON array of items, or
103
+ * the shorthand `Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}`.
104
104
  */
105
105
  @property({
106
106
  attribute: 'slash-items',
@@ -482,6 +482,11 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
482
482
  autocomplete=${ifDefined(this.autocomplete)}
483
483
  required=${ifDefined(this.required)}
484
484
  dir="${this.dir}"
485
+ slash-trigger="${this.slashTrigger}"
486
+ slash-heading="${this.slashHeading}"
487
+ slash-preset="${this.slashPreset}"
488
+ .slashItems="${this.slashItems}"
489
+ .slashItemsProvider="${this.slashItemsProvider}"
485
490
  @zn-input="${this.handleInput}"
486
491
  @zn-blur="${this.handleBlur}">
487
492
  </zn-input>`;
@@ -1,15 +1,19 @@
1
1
  import {classMap} from "lit/directives/class-map.js";
2
2
  import {defaultValue} from "../../internal/default-value";
3
3
  import {FormControlController} from "../../internal/form";
4
+ import {getSlashMenuPreset, parseSlashItems, type SlashMenuItem} from "../slash-menu/slash-menu-items";
4
5
  import {HasSlotController} from "../../internal/slot";
5
6
  import {html, unsafeCSS} from 'lit';
6
7
  import {ifDefined} from "lit/directives/if-defined.js";
7
8
  import {live} from "lit/directives/live.js";
8
9
  import {LocalizeController} from "../../utilities/localize";
9
10
  import {property, query, state} from 'lit/decorators.js';
11
+ import {SlashMenuController} from "../slash-menu/slash-menu-controller";
10
12
  import {watch} from "../../internal/watch";
11
13
  import ZincElement from '../../internal/zinc-element';
12
14
  import ZnIcon from "../icon";
15
+ import ZnSlashItem from "../slash-item";
16
+ import ZnSlashMenu from "../slash-menu";
13
17
  import ZnTooltip from "../tooltip";
14
18
  import type {ZincFormControl} from '../../internal/zinc-element';
15
19
 
@@ -40,8 +44,20 @@ import styles from './input.scss';
40
44
  * @slot show-password-icon - An icon to use in lieu of the default show password icon.
41
45
  * @slot hide-password-icon - An icon to use in lieu of the default hide password icon.
42
46
  * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
47
+ * @slot slash-items - `<zn-slash-item>` elements describing the insertions offered by the slash menu. Items are
48
+ * picked up wherever they sit inside the input, so this slot name is optional.
49
+ * @slot slash-menu - A `<zn-slash-menu>` to use instead of the built-in one, so its heading, sizing and styling can
50
+ * be set in markup. Any `<zn-slash-item>` children of it become the menu's items.
51
+ *
52
+ * @dependency zn-slash-item
53
+ * @dependency zn-slash-menu
54
+ *
55
+ * @event zn-slash-select - Emitted when a slash menu item is chosen. Cancelable — call `preventDefault()` to
56
+ * suppress the insertion and handle the item yourself.
57
+ * @event zn-slash-insert - Emitted after a slash menu item's value has been inserted.
43
58
  *
44
59
  * @csspart form-control - The form control that wraps the label, input, and help text.
60
+ * @csspart slash-menu - The slash menu shown at the caret.
45
61
  * @csspart form-control-label - The label's wrapper.
46
62
  * @csspart form-control-input - The input's wrapper.
47
63
  * @csspart form-control-help-text - The help text's wrapper.
@@ -62,6 +78,8 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
62
78
  static styles = unsafeCSS(styles);
63
79
  static dependencies = {
64
80
  'zn-icon': ZnIcon,
81
+ 'zn-slash-item': ZnSlashItem,
82
+ 'zn-slash-menu': ZnSlashMenu,
65
83
  'zn-tooltip': ZnTooltip
66
84
  }
67
85
 
@@ -71,10 +89,24 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
71
89
  private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label', 'label-tooltip');
72
90
  private readonly localize = new LocalizeController(this);
73
91
 
92
+ private readonly slashController = new SlashMenuController(this, {
93
+ menu: () => this.mountSlashMenu(),
94
+ items: search => this.resolveSlashItems(search),
95
+ trigger: () => this.slashTrigger,
96
+ onSelect: (item, search) => !this.emit('zn-slash-select', {
97
+ detail: {item, query: search}
98
+ }).defaultPrevented,
99
+ onInsert: (item, value) => this.emit('zn-slash-insert', {detail: {item, value}})
100
+ });
101
+
74
102
  @query('.input__control') input: HTMLInputElement;
75
103
  @query('.input__color-picker') colorPicker: HTMLInputElement;
104
+ @query('zn-slash-menu') slashMenuElement: ZnSlashMenu | null;
76
105
 
77
106
  @state() private hasFocus = false;
107
+
108
+ /** Renders the slash menu only once it has been needed, keeping unused inputs cheap. */
109
+ @state() private hasSlashMenu = false;
78
110
  @state() private isUserTyping = false;
79
111
  @property() title = "" // make reactive pass through
80
112
 
@@ -224,6 +256,35 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
224
256
  */
225
257
  @property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
226
258
 
259
+ /**
260
+ * Quick insertions offered by the slash menu. Accepts a JSON array of items, or the shorthand
261
+ * `Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}`. Can also be set as an array of
262
+ * `SlashMenuItem` objects in JavaScript.
263
+ */
264
+ @property({
265
+ attribute: 'slash-items',
266
+ converter: {
267
+ fromAttribute: (value: string) => parseSlashItems(value),
268
+ toAttribute: (value: SlashMenuItem[]) => JSON.stringify(value)
269
+ }
270
+ })
271
+ slashItems: SlashMenuItem[] = [];
272
+
273
+ /** Names of item sets registered with `registerSlashMenuPreset`, comma separated. */
274
+ @property({attribute: 'slash-preset'}) slashPreset = '';
275
+
276
+ /** The characters that open the slash menu. */
277
+ @property({attribute: 'slash-trigger'}) slashTrigger = '/';
278
+
279
+ /** The heading shown above the slash menu's items. */
280
+ @property({attribute: 'slash-heading'}) slashHeading = 'Insert';
281
+
282
+ /**
283
+ * Resolves additional items each time the menu opens, for lists that come from elsewhere (e.g. an
284
+ * API). Receives the current query and may return a promise. JavaScript only.
285
+ */
286
+ @property({attribute: false}) slashItemsProvider?: (query: string) => SlashMenuItem[] | Promise<SlashMenuItem[]>;
287
+
227
288
  /**
228
289
  * When enabled, pressing enter will always submit the surrounding form, even when the form uses
229
290
  * enter-navigation to move between fields.
@@ -565,6 +626,10 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
565
626
  }
566
627
 
567
628
  private handleKeyDown(event: KeyboardEvent) {
629
+ // The slash menu claims its own keys; while it is open Escape closes it and Enter picks an item,
630
+ // rather than blurring the field or submitting the form
631
+ if (this.slashController.open) return;
632
+
568
633
  if (event.key === 'Escape') {
569
634
  this.input.blur();
570
635
  event.stopPropagation();
@@ -632,6 +697,78 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
632
697
  if (this.type === 'range' && this.value === '') {
633
698
  this.value = this.input.value;
634
699
  }
700
+
701
+ this.slashController.attach(this.input);
702
+ }
703
+
704
+ /** The insertions the slash menu offers, gathered from every source, in the order they were declared. */
705
+ async resolveSlashItems(search = ''): Promise<SlashMenuItem[]> {
706
+ const items = [
707
+ ...getSlashMenuPreset(this.slashPreset),
708
+ ...this.slashItems,
709
+ ...this.slottedSlashItems()
710
+ ];
711
+
712
+ const provided = await this.slashItemsProvider?.(search);
713
+ return provided ? [...items, ...provided] : items;
714
+ }
715
+
716
+ /** Opens the slash menu at the caret, inserting the trigger if it isn't already there. */
717
+ async showSlashMenu() {
718
+ this.focus();
719
+
720
+ const caret = this.input.selectionStart ?? this.input.value.length;
721
+ const precedes = this.input.value.slice(caret - this.slashTrigger.length, caret);
722
+ if (precedes !== this.slashTrigger) {
723
+ this.setRangeText(this.slashTrigger, caret, caret, 'end');
724
+ }
725
+
726
+ await this.updateComplete;
727
+ this.slashController.requestOpen();
728
+ }
729
+
730
+ /** Closes the slash menu. */
731
+ hideSlashMenu() {
732
+ this.slashController.close();
733
+ }
734
+
735
+ private slottedSlashItems(): SlashMenuItem[] {
736
+ const elements = [...this.querySelectorAll<ZnSlashItem>('zn-slash-item')];
737
+
738
+ return elements.map(element => {
739
+ // Elements from a different zinc build may not have upgraded, so fall back to their markup
740
+ if (typeof element.toSlashMenuItem === 'function') return element.toSlashMenuItem();
741
+
742
+ const value = element.getAttribute('value') ?? (element.textContent ?? '').trim();
743
+ const order = element.getAttribute('order');
744
+ const caretOffset = element.getAttribute('caret-offset');
745
+
746
+ return {
747
+ label: element.getAttribute('label') ?? value,
748
+ value,
749
+ icon: element.getAttribute('icon') ?? undefined,
750
+ description: element.getAttribute('description') ?? undefined,
751
+ keywords: element.getAttribute('keywords') ?? undefined,
752
+ group: element.getAttribute('group') ?? undefined,
753
+ action: element.getAttribute('action') ?? undefined,
754
+ order: order === null ? undefined : Number(order),
755
+ caretOffset: caretOffset === null ? undefined : Number(caretOffset),
756
+ disabled: element.hasAttribute('disabled')
757
+ };
758
+ });
759
+ }
760
+
761
+ private async mountSlashMenu(): Promise<ZnSlashMenu | null> {
762
+ // A slotted menu is the author's own: it keeps its own heading, sizing and styling
763
+ const slotted = this.querySelector<ZnSlashMenu>('zn-slash-menu');
764
+ if (slotted) return slotted;
765
+
766
+ if (!this.hasSlashMenu) {
767
+ this.hasSlashMenu = true;
768
+ await this.updateComplete;
769
+ }
770
+
771
+ return this.slashMenuElement;
635
772
  }
636
773
 
637
774
  @watch('disabled', {waitUntilFirstUpdate: true})
@@ -958,6 +1095,12 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
958
1095
  : ''}
959
1096
  </span>
960
1097
  </div>
1098
+ ${this.hasSlashMenu
1099
+ ? html`
1100
+ <zn-slash-menu part="slash-menu" heading=${this.slashHeading}></zn-slash-menu>`
1101
+ : ''}
1102
+ <slot name="slash-menu"></slot>
1103
+ <slot name="slash-items" hidden></slot>
961
1104
  </div>
962
1105
 
963
1106
  <div
@@ -1,7 +1,8 @@
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 {clearFormStoreValues} from '../../utilities/form';
4
4
  import type ZnInput from './input.component';
5
+ import type ZnSlashMenu from '../slash-menu/slash-menu.component';
5
6
 
6
7
  describe('<zn-input>', () => {
7
8
  it('should render a component', async () => {
@@ -217,4 +218,115 @@ describe('<zn-input>', () => {
217
218
  expect(contextNote(el)).to.equal('Custom');
218
219
  });
219
220
  });
221
+
222
+ describe('slash menu', () => {
223
+ function slashMenuOf(el: ZnInput) {
224
+ return el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu');
225
+ }
226
+
227
+ /** Types `text` into the field the way a user would, leaving the caret at the end. */
228
+ function type(el: ZnInput, text: string) {
229
+ el.input.value = text;
230
+ el.input.setSelectionRange(text.length, text.length);
231
+ el.input.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true}));
232
+ }
233
+
234
+ async function openSlashMenu(el: ZnInput, text = '/') {
235
+ await el.updateComplete;
236
+ el.focus();
237
+ type(el, text);
238
+ await waitUntil(() => slashMenuOf(el)?.open, 'the slash menu never opened');
239
+
240
+ return slashMenuOf(el)!;
241
+ }
242
+
243
+ it('stays closed when no items are configured', async () => {
244
+ const el = await fixture<ZnInput>(html`<zn-input></zn-input>`);
245
+ await el.updateComplete;
246
+
247
+ el.focus();
248
+ type(el, '/');
249
+ await el.updateComplete;
250
+ await el.updateComplete;
251
+
252
+ expect(slashMenuOf(el)?.open ?? false, 'nothing to insert, so nothing to show').to.be.false;
253
+ });
254
+
255
+ it('opens on the trigger with items from the slash-items attribute', async () => {
256
+ const el = await fixture<ZnInput>(html`
257
+ <zn-input slash-items="Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}"></zn-input>`);
258
+ const menu = await openSlashMenu(el);
259
+
260
+ expect(menu.items.map(item => item.label)).to.deep.equal(['Brand name', 'Support email']);
261
+ });
262
+
263
+ it('collects items from slotted zn-slash-item elements', async () => {
264
+ const el = await fixture<ZnInput>(html`
265
+ <zn-input>
266
+ <zn-slash-item label="Brand name" value="{{BRAND_NAME}}"></zn-slash-item>
267
+ </zn-input>`);
268
+ const menu = await openSlashMenu(el);
269
+
270
+ expect(menu.items.map(item => item.label)).to.deep.equal(['Brand name']);
271
+ });
272
+
273
+ it('inserts the active item on Enter, replacing the trigger and query', async () => {
274
+ const el = await fixture<ZnInput>(html`
275
+ <zn-input slash-items="Brand name={{BRAND_NAME}}"></zn-input>`);
276
+ await openSlashMenu(el, 'Welcome to /bra');
277
+
278
+ el.input.dispatchEvent(new KeyboardEvent('keydown', {
279
+ key: 'Enter',
280
+ bubbles: true,
281
+ composed: true,
282
+ cancelable: true
283
+ }));
284
+ await el.updateComplete;
285
+
286
+ expect(el.value).to.equal('Welcome to {{BRAND_NAME}}');
287
+ });
288
+
289
+ it('does not submit the form when Enter chooses an item', async () => {
290
+ const form = await fixture<HTMLFormElement>(html`
291
+ <form>
292
+ <zn-input slash-items="Brand name={{BRAND_NAME}}"></zn-input>
293
+ </form>`);
294
+ const el = form.querySelector<ZnInput>('zn-input')!;
295
+
296
+ let submits = 0;
297
+ form.addEventListener('submit', event => {
298
+ event.preventDefault();
299
+ submits++;
300
+ });
301
+
302
+ await openSlashMenu(el);
303
+ el.input.dispatchEvent(new KeyboardEvent('keydown', {
304
+ key: 'Enter',
305
+ bubbles: true,
306
+ composed: true,
307
+ cancelable: true
308
+ }));
309
+ await el.updateComplete;
310
+
311
+ expect(el.value, 'the token should have replaced the trigger').to.equal('{{BRAND_NAME}}');
312
+ expect(submits, 'Enter belongs to the open menu, not the form').to.equal(0);
313
+ });
314
+
315
+ it('closes on Escape without blurring the field', async () => {
316
+ const el = await fixture<ZnInput>(html`
317
+ <zn-input slash-items="Brand name={{BRAND_NAME}}"></zn-input>`);
318
+ const menu = await openSlashMenu(el);
319
+
320
+ el.input.dispatchEvent(new KeyboardEvent('keydown', {
321
+ key: 'Escape',
322
+ bubbles: true,
323
+ composed: true,
324
+ cancelable: true
325
+ }));
326
+ await el.updateComplete;
327
+
328
+ expect(menu.open, 'Escape should close the menu').to.be.false;
329
+ expect(el.shadowRoot!.activeElement, 'the field should keep focus').to.equal(el.input);
330
+ });
331
+ });
220
332
  });