@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
@@ -2,13 +2,17 @@ import {classMap} from "lit/directives/class-map.js";
2
2
  import {type CSSResultGroup, html, unsafeCSS} from 'lit';
3
3
  import {defaultValue} from "../../internal/default-value";
4
4
  import {FormControlController} from "../../internal/form";
5
+ import {getSlashMenuPreset, parseSlashItems, type SlashMenuItem} from "../slash-menu/slash-menu-items";
5
6
  import {HasSlotController} from "../../internal/slot";
6
7
  import {ifDefined} from "lit/directives/if-defined.js";
7
8
  import {live} from "lit/directives/live.js";
8
9
  import {property, query, state} from 'lit/decorators.js';
9
10
  import {ResizeController} from '@lit-labs/observers/resize-controller.js';
11
+ import {SlashMenuController} from "../slash-menu/slash-menu-controller";
10
12
  import {watch} from '../../internal/watch';
11
13
  import ZincElement, {type ZincFormControl} from '../../internal/zinc-element';
14
+ import ZnSlashItem from "../slash-item";
15
+ import ZnSlashMenu from "../slash-menu";
12
16
 
13
17
  import styles from './textarea.scss';
14
18
 
@@ -22,12 +26,22 @@ import styles from './textarea.scss';
22
26
  * @slot label-tooltip - Used to add text that is displayed in a tooltip next to the label. Alternatively, you can use the `label-tooltip` attribute.
23
27
  * @slot context-note - Used to add contextual text that is displayed above the textarea, on the right. Alternatively, you can use the `context-note` attribute.
24
28
  * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
29
+ * @slot slash-items - `<zn-slash-item>` elements describing the insertions offered by the slash menu. Items are
30
+ * picked up wherever they sit inside the textarea, so this slot name is optional.
31
+ * @slot slash-menu - A `<zn-slash-menu>` to use instead of the built-in one, so its heading, sizing and styling can
32
+ * be set in markup. Any `<zn-slash-item>` children of it become the menu's items.
33
+ *
34
+ * @dependency zn-slash-item
35
+ * @dependency zn-slash-menu
25
36
  *
26
37
  * @event zn-blur - Emitted when the control loses focus.
27
38
  * @event zn-change - Emitted when an alteration to the control's value is committed by the user.
28
39
  * @event zn-focus - Emitted when the control gains focus.
29
40
  * @event zn-input - Emitted when the control receives input.
30
41
  * @event zn-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
42
+ * @event zn-slash-select - Emitted when a slash menu item is chosen. Cancelable — call `preventDefault()` to
43
+ * suppress the insertion and handle the item yourself.
44
+ * @event zn-slash-insert - Emitted after a slash menu item's value has been inserted.
31
45
  *
32
46
  * @csspart form-control - The form control that wraps the label, input, and help text.
33
47
  * @csspart form-control-label - The label's wrapper.
@@ -35,9 +49,14 @@ import styles from './textarea.scss';
35
49
  * @csspart form-control-help-text - The help text's wrapper.
36
50
  * @csspart base - The component's base wrapper.
37
51
  * @csspart textarea - The internal `<textarea>` control.
52
+ * @csspart slash-menu - The slash menu shown at the caret.
38
53
  */
39
54
  export default class ZnTextarea extends ZincElement implements ZincFormControl {
40
55
  static styles: CSSResultGroup = unsafeCSS(styles);
56
+ static dependencies = {
57
+ 'zn-slash-item': ZnSlashItem,
58
+ 'zn-slash-menu': ZnSlashMenu
59
+ };
41
60
 
42
61
  private readonly formControlController = new FormControlController(this, {
43
62
  assumeInteractionOn: ['zn-blur', 'zn-input']
@@ -50,11 +69,26 @@ export default class ZnTextarea extends ZincElement implements ZincFormControl {
50
69
  /** Ensures we only attempt to derive the initial value from light DOM content once */
51
70
  private _didInitFromContent = false;
52
71
 
72
+ private readonly slashController = new SlashMenuController(this, {
73
+ menu: () => this.mountSlashMenu(),
74
+ items: search => this.resolveSlashItems(search),
75
+ trigger: () => this.slashTrigger,
76
+ onSelect: (item, search) => !this.emit('zn-slash-select', {
77
+ detail: {item, query: search}
78
+ }).defaultPrevented,
79
+ // The field's own input event has already synced `value` by this point
80
+ onInsert: (item, value) => this.emit('zn-slash-insert', {detail: {item, value}})
81
+ });
82
+
53
83
  @query('.form-control-input') formControl: HTMLElement;
54
84
  @query('.textarea__control') input: HTMLTextAreaElement;
85
+ @query('zn-slash-menu') slashMenuElement: ZnSlashMenu | null;
55
86
 
56
87
  @state() hasFocus = false;
57
88
 
89
+ /** Renders the slash menu only once it has been needed, keeping unused textareas cheap. */
90
+ @state() private hasSlashMenu = false;
91
+
58
92
  @property() title = ''; // make reactive to pass through
59
93
 
60
94
  /** The name of the textarea, submitted as a name/value pair with form data. */
@@ -146,6 +180,35 @@ export default class ZnTextarea extends ZincElement implements ZincFormControl {
146
180
  */
147
181
  @property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
148
182
 
183
+ /**
184
+ * Quick insertions offered by the slash menu. Accepts a JSON array of items, or the shorthand
185
+ * `Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}`. Can also be set as an array of
186
+ * `SlashMenuItem` objects in JavaScript.
187
+ */
188
+ @property({
189
+ attribute: 'slash-items',
190
+ converter: {
191
+ fromAttribute: (value: string) => parseSlashItems(value),
192
+ toAttribute: (value: SlashMenuItem[]) => JSON.stringify(value)
193
+ }
194
+ })
195
+ slashItems: SlashMenuItem[] = [];
196
+
197
+ /** Names of item sets registered with `registerSlashMenuPreset`, comma separated. */
198
+ @property({attribute: 'slash-preset'}) slashPreset = '';
199
+
200
+ /** The characters that open the slash menu. */
201
+ @property({attribute: 'slash-trigger'}) slashTrigger = '/';
202
+
203
+ /** The heading shown above the slash menu's items. */
204
+ @property({attribute: 'slash-heading'}) slashHeading = 'Insert';
205
+
206
+ /**
207
+ * Resolves additional items each time the menu opens, for lists that come from elsewhere (e.g. an
208
+ * API). Receives the current query and may return a promise. JavaScript only.
209
+ */
210
+ @property({attribute: false}) slashItemsProvider?: (query: string) => SlashMenuItem[] | Promise<SlashMenuItem[]>;
211
+
149
212
  /** The default value of the form control. Primarily used for resetting the form control. */
150
213
  @defaultValue() defaultValue = '';
151
214
 
@@ -202,6 +265,77 @@ export default class ZnTextarea extends ZincElement implements ZincFormControl {
202
265
 
203
266
  firstUpdated() {
204
267
  this.formControlController.updateValidity();
268
+ this.slashController.attach(this.input);
269
+ }
270
+
271
+ /** The insertions the slash menu offers, gathered from every source, in the order they were declared. */
272
+ async resolveSlashItems(search = ''): Promise<SlashMenuItem[]> {
273
+ const items = [
274
+ ...getSlashMenuPreset(this.slashPreset),
275
+ ...this.slashItems,
276
+ ...this.slottedSlashItems()
277
+ ];
278
+
279
+ const provided = await this.slashItemsProvider?.(search);
280
+ return provided ? [...items, ...provided] : items;
281
+ }
282
+
283
+ /** Opens the slash menu at the caret, inserting the trigger if it isn't already there. */
284
+ async showSlashMenu() {
285
+ this.focus();
286
+
287
+ const caret = this.input.selectionStart ?? this.input.value.length;
288
+ const precedes = this.input.value.slice(caret - this.slashTrigger.length, caret);
289
+ if (precedes !== this.slashTrigger) {
290
+ this.setRangeText(this.slashTrigger, caret, caret, 'end');
291
+ }
292
+
293
+ await this.updateComplete;
294
+ this.slashController.requestOpen();
295
+ }
296
+
297
+ /** Closes the slash menu. */
298
+ hideSlashMenu() {
299
+ this.slashController.close();
300
+ }
301
+
302
+ private slottedSlashItems(): SlashMenuItem[] {
303
+ const elements = [...this.querySelectorAll<ZnSlashItem>('zn-slash-item')];
304
+
305
+ return elements.map(element => {
306
+ // Elements from a different zinc build may not have upgraded, so fall back to their markup
307
+ if (typeof element.toSlashMenuItem === 'function') return element.toSlashMenuItem();
308
+
309
+ const value = element.getAttribute('value') ?? (element.textContent ?? '').trim();
310
+ const order = element.getAttribute('order');
311
+ const caretOffset = element.getAttribute('caret-offset');
312
+
313
+ return {
314
+ label: element.getAttribute('label') ?? value,
315
+ value,
316
+ icon: element.getAttribute('icon') ?? undefined,
317
+ description: element.getAttribute('description') ?? undefined,
318
+ keywords: element.getAttribute('keywords') ?? undefined,
319
+ group: element.getAttribute('group') ?? undefined,
320
+ action: element.getAttribute('action') ?? undefined,
321
+ order: order === null ? undefined : Number(order),
322
+ caretOffset: caretOffset === null ? undefined : Number(caretOffset),
323
+ disabled: element.hasAttribute('disabled')
324
+ };
325
+ });
326
+ }
327
+
328
+ private async mountSlashMenu(): Promise<ZnSlashMenu | null> {
329
+ // A slotted menu is the author's own: it keeps its own heading, sizing and styling
330
+ const slotted = this.querySelector<ZnSlashMenu>('zn-slash-menu');
331
+ if (slotted) return slotted;
332
+
333
+ if (!this.hasSlashMenu) {
334
+ this.hasSlashMenu = true;
335
+ await this.updateComplete;
336
+ }
337
+
338
+ return this.slashMenuElement;
205
339
  }
206
340
 
207
341
  private handleBlur() {
@@ -231,6 +365,9 @@ export default class ZnTextarea extends ZincElement implements ZincFormControl {
231
365
  }
232
366
 
233
367
  private handleKeyDown(event: KeyboardEvent) {
368
+ // The slash menu claims its own keys before this handler runs; Escape closes it, not the field
369
+ if (this.slashController.open) return;
370
+
234
371
  if (event.key === 'Escape') {
235
372
  this.input.blur();
236
373
  event.stopPropagation();
@@ -445,6 +582,12 @@ export default class ZnTextarea extends ZincElement implements ZincFormControl {
445
582
  @blur=${this.handleBlur}
446
583
  ></textarea>
447
584
  </div>
585
+ ${this.hasSlashMenu
586
+ ? html`
587
+ <zn-slash-menu part="slash-menu" heading=${this.slashHeading}></zn-slash-menu>`
588
+ : ''}
589
+ <slot name="slash-menu"></slot>
590
+ <slot name="slash-items" hidden></slot>
448
591
  </div>
449
592
 
450
593
  <div
@@ -1,7 +1,51 @@
1
1
  import '../../../dist/zn.min.js';
2
- import {expect, fixture, html} from '@open-wc/testing';
2
+ import {aTimeout, expect, fixture, html, waitUntil} from '@open-wc/testing';
3
+ import type ZnSlashMenu from '../slash-menu/slash-menu.component';
3
4
  import type ZnTextarea from './textarea.component';
4
5
 
6
+ /** Types `text` into the field the way a user would, leaving the caret at the end. */
7
+ function type(el: ZnTextarea, text: string) {
8
+ const field = el.input;
9
+ field.value = text;
10
+ field.setSelectionRange(text.length, text.length);
11
+ field.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true}));
12
+ }
13
+
14
+ function press(el: ZnTextarea, key: string) {
15
+ const event = new KeyboardEvent('keydown', {key, bubbles: true, composed: true, cancelable: true});
16
+ el.input.dispatchEvent(event);
17
+ return event;
18
+ }
19
+
20
+ /**
21
+ * A real key press, keyup included — the menu has to survive the caret checks that run on it.
22
+ * Item resolution is async, so this settles anything the keyup kicked off before returning.
23
+ */
24
+ async function pressAndRelease(el: ZnTextarea, key: string) {
25
+ press(el, key);
26
+ el.input.dispatchEvent(new KeyboardEvent('keyup', {key, bubbles: true, composed: true}));
27
+
28
+ await aTimeout(0);
29
+ await el.updateComplete;
30
+ await slashMenuOf(el)?.updateComplete;
31
+ }
32
+
33
+ function slashMenuOf(el: ZnTextarea) {
34
+ return el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu');
35
+ }
36
+
37
+ function menuItemLabels(menu: ZnSlashMenu) {
38
+ return [...menu.shadowRoot!.querySelectorAll('.slash-menu__label')].map(item => item.textContent?.trim());
39
+ }
40
+
41
+ async function openSlashMenu(el: ZnTextarea, text = '/') {
42
+ el.focus();
43
+ type(el, text);
44
+ await waitUntil(() => slashMenuOf(el)?.open, 'the slash menu never opened');
45
+
46
+ return slashMenuOf(el)!;
47
+ }
48
+
5
49
  describe('<zn-textarea>', () => {
6
50
  it('should render a component', async () => {
7
51
  const el = await fixture(html` <zn-textarea></zn-textarea> `);
@@ -16,7 +60,7 @@ describe('<zn-textarea>', () => {
16
60
  <zn-textarea></zn-textarea>
17
61
  </div>
18
62
  `);
19
- const el = wrapper.querySelector('zn-textarea') as ZnTextarea;
63
+ const el = wrapper.querySelector<ZnTextarea>('zn-textarea')!;
20
64
  await el.updateComplete;
21
65
 
22
66
  let wrapperEscapes = 0;
@@ -40,4 +84,268 @@ describe('<zn-textarea>', () => {
40
84
  expect(wrapperEscapes, 'wrapper should not see Escape on press 1').to.equal(0);
41
85
  });
42
86
  });
87
+
88
+ describe('slash menu', () => {
89
+ it('stays closed when no items are configured', async () => {
90
+ const el = await fixture<ZnTextarea>(html`
91
+ <zn-textarea></zn-textarea>`);
92
+ await el.updateComplete;
93
+
94
+ el.focus();
95
+ type(el, '/');
96
+ await el.updateComplete;
97
+ await el.updateComplete;
98
+
99
+ expect(slashMenuOf(el)?.open ?? false, 'nothing to insert, so nothing to show').to.be.false;
100
+ });
101
+
102
+ it('opens on the trigger with items from the slash-items attribute', async () => {
103
+ const el = await fixture<ZnTextarea>(html`
104
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}"></zn-textarea>`);
105
+ const menu = await openSlashMenu(el);
106
+
107
+ expect(menuItemLabels(menu)).to.deep.equal(['Brand name', 'Support email']);
108
+ });
109
+
110
+ it('collects items from slotted zn-slash-item elements', async () => {
111
+ const el = await fixture<ZnTextarea>(html`
112
+ <zn-textarea>
113
+ <zn-slash-item slot="slash-items" label="Brand name" value="{{BRAND_NAME}}"></zn-slash-item>
114
+ <zn-slash-item slot="slash-items" label="Governing law">This agreement is governed by {{JURISDICTION}}.
115
+ </zn-slash-item>
116
+ </zn-textarea>`);
117
+ const menu = await openSlashMenu(el);
118
+
119
+ expect(menuItemLabels(menu)).to.deep.equal(['Brand name', 'Governing law']);
120
+ expect(menu.items[1].value, 'text content is used when no value attribute is set')
121
+ .to.equal('This agreement is governed by {{JURISDICTION}}.');
122
+ });
123
+
124
+ it('picks up zn-slash-item children with no slot attribute', async () => {
125
+ const el = await fixture<ZnTextarea>(html`
126
+ <zn-textarea>
127
+ <zn-slash-item label="Brand name" value="{{BRAND_NAME}}"></zn-slash-item>
128
+ <zn-slash-item label="Support email" value="{{SUPPORT_EMAIL}}"></zn-slash-item>
129
+ </zn-textarea>`);
130
+ await el.updateComplete;
131
+ expect(el.value, 'the items must not be read as initial content').to.equal('');
132
+
133
+ const menu = await openSlashMenu(el);
134
+ expect(menuItemLabels(menu)).to.deep.equal(['Brand name', 'Support email']);
135
+ });
136
+
137
+ it('uses a slotted zn-slash-menu and the items inside it', async () => {
138
+ const el = await fixture<ZnTextarea>(html`
139
+ <zn-textarea>
140
+ <zn-slash-menu slot="slash-menu" heading="Replacements" max-items="1">
141
+ <zn-slash-item label="Brand name" value="{{BRAND_NAME}}"></zn-slash-item>
142
+ <zn-slash-item label="Support email" value="{{SUPPORT_EMAIL}}"></zn-slash-item>
143
+ </zn-slash-menu>
144
+ </zn-textarea>`);
145
+ const slotted = el.querySelector<ZnSlashMenu>('zn-slash-menu')!;
146
+
147
+ el.focus();
148
+ type(el, '/');
149
+ await waitUntil(() => slotted.open, 'the slotted menu never opened');
150
+ await slotted.updateComplete;
151
+
152
+ expect(el.shadowRoot!.querySelector('zn-slash-menu'), 'no second menu is created').to.be.null;
153
+ expect(slotted.items.map(item => item.label)).to.deep.equal(['Brand name', 'Support email']);
154
+ expect(slotted.shadowRoot!.querySelector('[part="heading"]')?.textContent?.trim())
155
+ .to.contain('Replacements');
156
+ expect(menuItemLabels(slotted), 'the author\'s max-items is respected').to.deep.equal(['Brand name']);
157
+
158
+ press(el, 'Enter');
159
+ await el.updateComplete;
160
+ expect(el.value).to.equal('{{BRAND_NAME}}');
161
+ });
162
+
163
+ it('filters as the query is typed', async () => {
164
+ const el = await fixture<ZnTextarea>(html`
165
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}"></zn-textarea>`);
166
+ const menu = await openSlashMenu(el, 'Legal text /sup');
167
+
168
+ expect(menuItemLabels(menu)).to.deep.equal(['Support email']);
169
+ });
170
+
171
+ it('closes when the query matches nothing', async () => {
172
+ const el = await fixture<ZnTextarea>(html`
173
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}"></zn-textarea>`);
174
+ const menu = await openSlashMenu(el);
175
+
176
+ type(el, '/zzz');
177
+ await waitUntil(() => !menu.open, 'the menu stayed open with no matches');
178
+ });
179
+
180
+ it('ignores a trigger in the middle of a word', async () => {
181
+ const el = await fixture<ZnTextarea>(html`
182
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}"></zn-textarea>`);
183
+ await el.updateComplete;
184
+
185
+ el.focus();
186
+ type(el, 'http://example.com/br');
187
+ await el.updateComplete;
188
+ await el.updateComplete;
189
+
190
+ expect(slashMenuOf(el)?.open ?? false).to.be.false;
191
+ });
192
+
193
+ it('inserts the active item on Enter, replacing the trigger and query', async () => {
194
+ const el = await fixture<ZnTextarea>(html`
195
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}"></zn-textarea>`);
196
+ const menu = await openSlashMenu(el, 'Provided by /bra');
197
+
198
+ const inserted: string[] = [];
199
+ el.addEventListener('zn-slash-insert', (event: Event) => {
200
+ inserted.push((event as CustomEvent<{value: string}>).detail.value);
201
+ });
202
+
203
+ const event = press(el, 'Enter');
204
+ await el.updateComplete;
205
+
206
+ expect(event.defaultPrevented, 'Enter is claimed by the menu').to.be.true;
207
+ expect(el.value).to.equal('Provided by {{BRAND_NAME}}');
208
+ expect(el.input.selectionStart, 'the caret lands after the insertion').to.equal(el.value.length);
209
+ expect(inserted).to.deep.equal(['{{BRAND_NAME}}']);
210
+ expect(menu.open).to.be.false;
211
+ });
212
+
213
+ it('moves the active item with the arrow keys', async () => {
214
+ const el = await fixture<ZnTextarea>(html`
215
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}"></zn-textarea>`);
216
+ const menu = await openSlashMenu(el);
217
+
218
+ press(el, 'ArrowDown');
219
+ await menu.updateComplete;
220
+ expect(menu.activeItem?.label).to.equal('Support email');
221
+
222
+ press(el, 'Enter');
223
+ await el.updateComplete;
224
+ expect(el.value).to.equal('{{SUPPORT_EMAIL}}');
225
+ });
226
+
227
+ it('keeps its place in the list once the arrow key is released', async () => {
228
+ const el = await fixture<ZnTextarea>(html`
229
+ <zn-textarea
230
+ slash-items="Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}, Jurisdiction={{JURISDICTION}}"></zn-textarea>`);
231
+ const menu = await openSlashMenu(el);
232
+
233
+ await pressAndRelease(el, 'ArrowDown');
234
+ expect(menu.activeItem?.label, 'keyup must not rebuild the list').to.equal('Support email');
235
+
236
+ await pressAndRelease(el, 'ArrowDown');
237
+ expect(menu.activeItem?.label).to.equal('Jurisdiction');
238
+
239
+ await pressAndRelease(el, 'ArrowUp');
240
+ expect(menu.activeItem?.label).to.equal('Support email');
241
+
242
+ press(el, 'Enter');
243
+ await el.updateComplete;
244
+ expect(el.value).to.equal('{{SUPPORT_EMAIL}}');
245
+ });
246
+
247
+ it('lets zn-slash-select cancel the insertion', async () => {
248
+ const el = await fixture<ZnTextarea>(html`
249
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}"></zn-textarea>`);
250
+ await openSlashMenu(el);
251
+
252
+ const selected: string[] = [];
253
+ el.addEventListener('zn-slash-select', (event: Event) => {
254
+ selected.push((event as CustomEvent<{item: {label: string}}>).detail.item.label);
255
+ event.preventDefault();
256
+ });
257
+
258
+ press(el, 'Enter');
259
+ await el.updateComplete;
260
+
261
+ expect(selected).to.deep.equal(['Brand name']);
262
+ expect(el.value, 'the value is not inserted, but the command text still goes').to.equal('');
263
+ });
264
+
265
+ it('closes on Escape without blurring the field', async () => {
266
+ const el = await fixture<ZnTextarea>(html`
267
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}"></zn-textarea>`);
268
+ const menu = await openSlashMenu(el);
269
+
270
+ press(el, 'Escape');
271
+ await el.updateComplete;
272
+
273
+ expect(menu.open).to.be.false;
274
+ expect(el.shadowRoot!.activeElement, 'Escape closed the menu, not the field').to.equal(el.input);
275
+ });
276
+
277
+ it('does not reopen at a dismissed trigger while the query grows', async () => {
278
+ const el = await fixture<ZnTextarea>(html`
279
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}"></zn-textarea>`);
280
+ const menu = await openSlashMenu(el);
281
+
282
+ press(el, 'Escape');
283
+ await el.updateComplete;
284
+
285
+ type(el, '/br');
286
+ await el.updateComplete;
287
+ await el.updateComplete;
288
+ expect(menu.open).to.be.false;
289
+ });
290
+
291
+ it('supports a custom trigger', async () => {
292
+ const el = await fixture<ZnTextarea>(html`
293
+ <zn-textarea slash-trigger="{{" slash-items="Brand name={{BRAND_NAME}}"></zn-textarea>`);
294
+ const menu = await openSlashMenu(el, '{{bra');
295
+
296
+ expect(menu.items.map(item => item.label)).to.deep.equal(['Brand name']);
297
+
298
+ press(el, 'Enter');
299
+ await el.updateComplete;
300
+ expect(el.value).to.equal('{{BRAND_NAME}}');
301
+ });
302
+
303
+ it('appends items from slashItemsProvider', async () => {
304
+ const el = await fixture<ZnTextarea>(html`
305
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}"></zn-textarea>`);
306
+ el.slashItemsProvider = () => [{label: 'Merchant name', value: '{{MERCHANT_NAME}}'}];
307
+ await el.updateComplete;
308
+
309
+ const menu = await openSlashMenu(el);
310
+ expect(menuItemLabels(menu)).to.deep.equal(['Brand name', 'Merchant name']);
311
+ });
312
+
313
+ it('removes the command text for items that only carry an action', async () => {
314
+ const el = await fixture<ZnTextarea>(html`
315
+ <zn-textarea></zn-textarea>`);
316
+ el.slashItems = [{label: 'Timestamp', action: 'timestamp'}];
317
+ await el.updateComplete;
318
+
319
+ await openSlashMenu(el, 'Logged at /time');
320
+ press(el, 'Enter');
321
+ await el.updateComplete;
322
+
323
+ expect(el.value).to.equal('Logged at ');
324
+ });
325
+
326
+ it('renders the panel at the caret', async () => {
327
+ const el = await fixture<ZnTextarea>(html`
328
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}" style="margin-top: 100px"></zn-textarea>`);
329
+ const menu = await openSlashMenu(el);
330
+ await menu.updateComplete;
331
+
332
+ const panel = menu.shadowRoot!.querySelector('[part="panel"]')!.getBoundingClientRect();
333
+ const field = el.input.getBoundingClientRect();
334
+
335
+ expect(panel.width, 'the panel is laid out').to.be.greaterThan(0);
336
+ expect(panel.height, 'the panel is laid out').to.be.greaterThan(0);
337
+ expect(panel.top, 'the panel sits below the first line of the field').to.be.greaterThan(field.top);
338
+ });
339
+
340
+ it('is opened programmatically by showSlashMenu()', async () => {
341
+ const el = await fixture<ZnTextarea>(html`
342
+ <zn-textarea slash-items="Brand name={{BRAND_NAME}}"></zn-textarea>`);
343
+ await el.updateComplete;
344
+
345
+ await el.showSlashMenu();
346
+ await waitUntil(() => slashMenuOf(el)?.open, 'showSlashMenu() did not open the menu');
347
+
348
+ expect(el.value).to.equal('/');
349
+ });
350
+ });
43
351
  });
@@ -5,6 +5,7 @@ import {html, nothing, unsafeCSS} from 'lit';
5
5
  import {ifDefined} from 'lit/directives/if-defined.js';
6
6
  import {keyed} from 'lit/directives/keyed.js';
7
7
  import {live} from 'lit/directives/live.js';
8
+ import {parseSlashItems, type SlashMenuItem} from '../slash-menu/slash-menu-items';
8
9
  import {property, state} from 'lit/decorators.js';
9
10
  import {ResizeController} from '@lit-labs/observers/resize-controller.js';
10
11
  import ZincElement from '../../internal/zinc-element';
@@ -43,6 +44,31 @@ export default class ZnTranslations extends ZincElement implements ZincFormContr
43
44
  @property({attribute: "input-type"}) inputType: 'select' | 'text' | 'number' | 'textarea' = 'text';
44
45
  @property({attribute: "textarea-rows", type: Number}) textareaRows: number | undefined;
45
46
 
47
+ /**
48
+ * Quick insertions offered by the slash menu when `input-type` is `textarea`. Accepts a JSON array of items, or
49
+ * the shorthand `Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}`. Every language shares the list.
50
+ */
51
+ @property({
52
+ attribute: 'slash-items',
53
+ converter: {
54
+ fromAttribute: (value: string) => parseSlashItems(value),
55
+ toAttribute: (value: SlashMenuItem[]) => JSON.stringify(value)
56
+ }
57
+ })
58
+ slashItems: SlashMenuItem[] = [];
59
+
60
+ /** Names of item sets registered with `registerSlashMenuPreset`, comma separated. */
61
+ @property({attribute: 'slash-preset'}) slashPreset = '';
62
+
63
+ /** The characters that open the slash menu. */
64
+ @property({attribute: 'slash-trigger'}) slashTrigger = '/';
65
+
66
+ /** The heading shown above the slash menu's items. */
67
+ @property({attribute: 'slash-heading'}) slashHeading = 'Insert';
68
+
69
+ /** Resolves additional slash menu items each time the menu opens. JavaScript only. */
70
+ @property({attribute: false}) slashItemsProvider?: (query: string) => SlashMenuItem[] | Promise<SlashMenuItem[]>;
71
+
46
72
  /** When true, hides the individual language navbar and defers language control to a parent zn-translation-group. */
47
73
  @property({type: Boolean, reflect: true}) grouped = false;
48
74
 
@@ -406,6 +432,11 @@ export default class ZnTranslations extends ZincElement implements ZincFormContr
406
432
  name="${this.name}"
407
433
  placeholder="Enter translation..."
408
434
  dir="${isRTL ? 'rtl' : 'ltr'}"
435
+ slash-trigger="${this.slashTrigger}"
436
+ slash-heading="${this.slashHeading}"
437
+ slash-preset="${this.slashPreset}"
438
+ .slashItems="${this.slashItems}"
439
+ .slashItemsProvider="${this.slashItemsProvider}"
409
440
  @zn-change="${this.handleValueUpdate}"
410
441
  @zn-input="${this.handleValueUpdate}"
411
442
  @zn-submit="${this.handleSubmit}"