@kubex/zinc 1.1.94 → 1.1.95

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 (36) hide show
  1. package/dist/custom-elements.json +995 -196
  2. package/dist/vscode.html-custom-data.json +74 -19
  3. package/dist/web-types.json +147 -37
  4. package/dist/zn.d.ts +335 -59
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +467 -355
  7. package/docs/pages/components/page-builder.md +121 -19
  8. package/docs/pages/components/slash-menu.md +132 -6
  9. package/docs/pages/components/textarea.md +16 -0
  10. package/package.json +1 -1
  11. package/scss/_root.scss +7 -1
  12. package/src/components/button/button.scss +5 -2
  13. package/src/components/inline-edit/inline-edit.component.ts +6 -1
  14. package/src/components/input/input.component.ts +12 -2
  15. package/src/components/page/page.scss +7 -2
  16. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
  17. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
  18. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
  19. package/src/components/page-builder/page-builder.component.ts +483 -225
  20. package/src/components/page-builder/page-builder.scss +64 -10
  21. package/src/components/page-builder/page-builder.test.ts +656 -110
  22. package/src/components/page-builder/page-tree.test.ts +483 -0
  23. package/src/components/page-builder/page-tree.ts +329 -0
  24. package/src/components/page-builder/page.types.ts +75 -7
  25. package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
  26. package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
  27. package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
  28. package/src/components/settings-container/settings-container.scss +2 -1
  29. package/src/components/slash-item/slash-item.component.ts +1 -1
  30. package/src/components/slash-menu/slash-menu-items.ts +48 -0
  31. package/src/components/slash-menu/slash-menu.component.ts +134 -27
  32. package/src/components/slash-menu/slash-menu.scss +90 -12
  33. package/src/components/slash-menu/slash-menu.test.ts +107 -0
  34. package/src/components/textarea/textarea.component.ts +12 -2
  35. package/src/components/textarea/textarea.test.ts +2 -2
  36. package/src/components/translations/translations.component.ts +5 -1
@@ -120,3 +120,51 @@ export function filterSlashItems(items: SlashMenuItem[], query: string): SlashMe
120
120
  a.index - b.index)
121
121
  .map(entry => entry.item);
122
122
  }
123
+
124
+ const RECENT_PREFIX = 'zn-slash-recent:';
125
+ /** Kept deeper than any menu shows, so history survives items that aren't in the current list. */
126
+ const RECENT_LIMIT = 10;
127
+
128
+ /** The identity an item is remembered by in a menu's recently used list. */
129
+ export function slashItemKey(item: SlashMenuItem): string {
130
+ return item.action ? `action:${item.action}` : `value:${item.value || item.label}`;
131
+ }
132
+
133
+ /** The keys of the items most recently chosen from the menu stored under `key`, newest first. */
134
+ export function readRecentSlashItems(key: string): string[] {
135
+ if (!key) return [];
136
+
137
+ try {
138
+ const parsed: unknown = JSON.parse(localStorage.getItem(RECENT_PREFIX + key) ?? '[]');
139
+ return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+
145
+ /** Moves an item to the front of the recently used list stored under `key`, and returns the list. */
146
+ export function recordRecentSlashItem(key: string, item: SlashMenuItem): string[] {
147
+ if (!key) return [];
148
+
149
+ const itemKey = slashItemKey(item);
150
+ const keys = [itemKey, ...readRecentSlashItems(key).filter(entry => entry !== itemKey)].slice(0, RECENT_LIMIT);
151
+
152
+ try {
153
+ localStorage.setItem(RECENT_PREFIX + key, JSON.stringify(keys));
154
+ } catch {
155
+ // No storage (private browsing, quota) — the list just doesn't outlive the page
156
+ }
157
+
158
+ return keys;
159
+ }
160
+
161
+ /** Forgets the recently used items stored under `key`. */
162
+ export function clearRecentSlashItems(key: string) {
163
+ if (!key) return;
164
+
165
+ try {
166
+ localStorage.removeItem(RECENT_PREFIX + key);
167
+ } catch {
168
+ // As above
169
+ }
170
+ }
@@ -1,10 +1,11 @@
1
1
  import {autoUpdate, computePosition, flip, offset, shift, size} from '@floating-ui/dom';
2
2
  import {classMap} from 'lit/directives/class-map.js';
3
+ import {clearRecentSlashItems, readRecentSlashItems, recordRecentSlashItem, slashItemKey} from './slash-menu-items';
3
4
  import {html, unsafeCSS} from 'lit';
4
5
  import {property, query, state} from 'lit/decorators.js';
5
6
  import ZincElement from '../../internal/zinc-element';
6
7
  import ZnIcon from '../icon';
7
- import type {CSSResultGroup, PropertyValues} from 'lit';
8
+ import type {CSSResultGroup, PropertyValues, TemplateResult} from 'lit';
8
9
  import type {Placement, VirtualElement} from '@floating-ui/dom';
9
10
  import type {SlashMenuItem} from './slash-menu-items';
10
11
 
@@ -29,12 +30,19 @@ function sameItems(a: SlashMenuItem[] | undefined, b: SlashMenuItem[]): boolean
29
30
  * component driving the menu (e.g. `zn-textarea`) re-emits it as `zn-slash-select`.
30
31
  *
31
32
  * @csspart panel - The floating panel that holds the list.
32
- * @csspart heading - The panel's heading.
33
+ * @csspart list - The scrolling list of items.
33
34
  * @csspart item - An item in the list.
35
+ * @csspart icon - The chip holding an item's icon.
34
36
  * @csspart group-heading - A group heading between items.
37
+ * @csspart divider - The rule closing the recently used section, when the items below it have no heading of their own.
35
38
  * @csspart footer - The truncation footer, shown when not every match fits.
39
+ * @csspart hints - The pinned footer of keyboard hints.
40
+ * @csspart hint - A single keyboard hint within the footer.
41
+ * @csspart hint-key - The key shown against a hint.
36
42
  *
37
43
  * @cssproperty --slash-menu-width - The width of the panel.
44
+ * @cssproperty --slash-menu-border-radius - The corner radius of the panel.
45
+ * @cssproperty --slash-menu-item-border-radius - The corner radius of the items and their icon chips.
38
46
  * @cssproperty --slash-menu-max-height - The maximum height of the panel before it scrolls.
39
47
  */
40
48
  export default class ZnSlashMenu extends ZincElement {
@@ -44,6 +52,7 @@ export default class ZnSlashMenu extends ZincElement {
44
52
  };
45
53
 
46
54
  @query('.slash-menu__panel') private panel: HTMLElement;
55
+ @query('.slash-menu__list') private list: HTMLElement;
47
56
 
48
57
  private stopAutoUpdate?: () => void;
49
58
 
@@ -56,7 +65,7 @@ export default class ZnSlashMenu extends ZincElement {
56
65
  /** The query the items were matched against, shown in the heading. */
57
66
  @property() query = '';
58
67
 
59
- /** The heading shown when there is no query. */
68
+ /** The name the list is announced by when there is no query. */
60
69
  @property() heading = 'Insert';
61
70
 
62
71
  /** Shown in place of the list when there are no items. */
@@ -68,6 +77,22 @@ export default class ZnSlashMenu extends ZincElement {
68
77
  /** Hides the insertion key (the item's value) normally shown against each item. */
69
78
  @property({attribute: 'hide-keys', type: Boolean}) hideKeys = false;
70
79
 
80
+ /** Hides the pinned footer of keyboard hints. */
81
+ @property({attribute: 'hide-hints', type: Boolean}) hideHints = false;
82
+
83
+ /**
84
+ * Remembers the items chosen here and lists the most recent of them first, under their own heading.
85
+ * The key scopes the list to where the menu is used, so each place keeps its own history in
86
+ * `localStorage`. Leave unset to offer no recently used section.
87
+ */
88
+ @property({attribute: 'recent-key'}) recentKey = '';
89
+
90
+ /** The most recently used items to list. */
91
+ @property({attribute: 'max-recent', type: Number}) maxRecent = 3;
92
+
93
+ /** The heading shown above the recently used items. */
94
+ @property({attribute: 'recent-heading'}) recentHeading = 'Recently used';
95
+
71
96
  /** The element or caret rect the panel is positioned against. */
72
97
  @property({attribute: false}) anchor: Element | VirtualElement | null = null;
73
98
 
@@ -78,11 +103,41 @@ export default class ZnSlashMenu extends ZincElement {
78
103
  @property({type: Number}) distance = 4;
79
104
 
80
105
  @state() private activeIndex = 0;
106
+ @state() private recentKeys: string[] = [];
81
107
 
82
- private get visibleItems(): SlashMenuItem[] {
108
+ /** How many recently used items the last update listed, to spot the list appearing or reordering. */
109
+ private recentCount = 0;
110
+
111
+ private get listItems(): SlashMenuItem[] {
83
112
  return this.maxItems > 0 ? this.items.slice(0, this.maxItems) : this.items;
84
113
  }
85
114
 
115
+ /**
116
+ * The remembered items that are in the current list, newest first. Only offered without a query —
117
+ * once the user is searching, the ranked matches are the better answer.
118
+ */
119
+ private get recentItems(): SlashMenuItem[] {
120
+ if (!this.recentKey || this.maxRecent < 1 || this.query.trim() !== '') return [];
121
+
122
+ const available = new Map<string, SlashMenuItem>();
123
+ for (const item of this.items) {
124
+ const key = slashItemKey(item);
125
+ if (!item.disabled && !available.has(key)) available.set(key, item);
126
+ }
127
+
128
+ const recent = this.recentKeys
129
+ .map(key => available.get(key))
130
+ .filter((item): item is SlashMenuItem => item !== undefined)
131
+ .slice(0, this.maxRecent);
132
+
133
+ // A section holding everything on offer is nothing but a second copy of the list
134
+ return recent.length < this.items.length ? recent : [];
135
+ }
136
+
137
+ private get visibleItems(): SlashMenuItem[] {
138
+ return [...this.recentItems, ...this.listItems];
139
+ }
140
+
86
141
  /** The item that Enter would insert. */
87
142
  get activeItem(): SlashMenuItem | undefined {
88
143
  return this.visibleItems[this.activeIndex];
@@ -96,6 +151,12 @@ export default class ZnSlashMenu extends ZincElement {
96
151
  this.open = false;
97
152
  }
98
153
 
154
+ /** Forgets the items remembered under `recent-key`. */
155
+ clearRecent() {
156
+ clearRecentSlashItems(this.recentKey);
157
+ this.recentKeys = [];
158
+ }
159
+
99
160
  /** Sets the active item by index, wrapping at both ends and skipping disabled items. */
100
161
  setActiveIndex(index: number) {
101
162
  const items = this.visibleItems;
@@ -198,6 +259,8 @@ export default class ZnSlashMenu extends ZincElement {
198
259
  private selectItem(item: SlashMenuItem) {
199
260
  if (item.disabled) return;
200
261
 
262
+ if (this.recentKey) this.recentKeys = recordRecentSlashItem(this.recentKey, item);
263
+
201
264
  this.dispatchEvent(new CustomEvent(SLASH_ITEM_SELECT, {
202
265
  bubbles: true,
203
266
  cancelable: true,
@@ -211,17 +274,17 @@ export default class ZnSlashMenu extends ZincElement {
211
274
  if (!active) return;
212
275
 
213
276
  const items = this.visibleItems;
214
- const panel = this.panel;
277
+ const list = this.list;
215
278
 
216
- // The heading and footer scroll with the list, and `nearest` stops at the item's own box — so
217
- // landing on the first or last item goes all the way to the panel's edge to bring them back
218
- if (panel && items.slice(0, this.activeIndex).every(item => item.disabled)) {
219
- panel.scrollTop = 0;
279
+ // Group headings scroll with the items, and `nearest` stops at the item's own box — so landing
280
+ // on the first or last item goes all the way to the list's edge to bring them back
281
+ if (list && items.slice(0, this.activeIndex).every(item => item.disabled)) {
282
+ list.scrollTop = 0;
220
283
  return;
221
284
  }
222
285
 
223
- if (panel && items.slice(this.activeIndex + 1).every(item => item.disabled)) {
224
- panel.scrollTop = panel.scrollHeight;
286
+ if (list && items.slice(this.activeIndex + 1).every(item => item.disabled)) {
287
+ list.scrollTop = list.scrollHeight;
225
288
  return;
226
289
  }
227
290
 
@@ -244,9 +307,20 @@ export default class ZnSlashMenu extends ZincElement {
244
307
  protected willUpdate(changed: PropertyValues) {
245
308
  super.willUpdate(changed);
246
309
 
310
+ // Another field may share the key, so the list is re-read each time the menu is shown
311
+ if (changed.has('recentKey') || (changed.has('open') && this.open)) {
312
+ this.recentKeys = readRecentSlashItems(this.recentKey);
313
+ }
314
+
247
315
  // A genuinely new result set starts on its first selectable item. Re-resolving the same query
248
316
  // hands over an equal-but-new array, which must not move the user's place in the list.
249
- if (changed.has('items') && !sameItems(changed.get('items') as SlashMenuItem[] | undefined, this.items)) {
317
+ const isNewList = changed.has('items')
318
+ && !sameItems(changed.get('items') as SlashMenuItem[] | undefined, this.items);
319
+
320
+ // Items above the list shift every index below them, so the place is given up either way
321
+ const recentCount = this.recentItems.length;
322
+ if (isNewList || recentCount !== this.recentCount) {
323
+ this.recentCount = recentCount;
250
324
  this.activeIndex = this.visibleItems.findIndex(item => !item.disabled);
251
325
  }
252
326
  }
@@ -292,7 +366,9 @@ export default class ZnSlashMenu extends ZincElement {
292
366
  @mousedown=${this.handleItemMouseDown}>
293
367
  ${showIcons
294
368
  ? html`
295
- <span class="slash-menu__icon">
369
+ <span
370
+ part="icon"
371
+ class=${classMap({'slash-menu__icon': true, 'slash-menu__icon--empty': !item.icon})}>
296
372
  ${item.icon ? html`
297
373
  <zn-icon src=${item.icon} size="16"></zn-icon>` : ''}
298
374
  </span>`
@@ -309,41 +385,72 @@ export default class ZnSlashMenu extends ZincElement {
309
385
 
310
386
  private renderItems() {
311
387
  const items = this.visibleItems;
388
+ const recentCount = this.recentItems.length;
312
389
  const showIcons = items.some(item => item.icon);
313
390
  let lastGroup: string | undefined;
314
391
 
315
392
  return items.map((item, index) => {
316
- const group = item.group;
393
+ const group = index < recentCount ? this.recentHeading : item.group;
317
394
  const heading = group && group !== lastGroup
318
395
  ? html`
319
396
  <div part="group-heading" class="slash-menu__group-heading">${group}</div>`
320
397
  : '';
321
398
  lastGroup = group;
322
399
 
323
- return html`${heading}${this.renderItem(item, index, showIcons)}`;
400
+ // The recently used section needs closing off; a heading of its own does that for the items
401
+ // below it, and where they have none, a rule does it instead
402
+ const divider = index === recentCount && recentCount > 0 && !heading
403
+ ? html`
404
+ <div part="divider" class="slash-menu__divider"></div>`
405
+ : '';
406
+
407
+ return html`${divider}${heading}${this.renderItem(item, index, showIcons)}`;
324
408
  });
325
409
  }
326
410
 
411
+ private renderHint(keys: (TemplateResult | string)[], label: string) {
412
+ return html`
413
+ <span part="hint" class="slash-menu__hint">
414
+ ${keys.map(key => html`
415
+ <kbd part="hint-key" class="slash-menu__key">${key}</kbd>`)}
416
+ ${label}
417
+ </span>`;
418
+ }
419
+
420
+ private renderHints() {
421
+ const key = (icon: string) => html`
422
+ <zn-icon src="${icon}@lu" size="12"></zn-icon>`;
423
+
424
+ return html`
425
+ <div part="hints" class="slash-menu__hints">
426
+ ${this.renderHint([key('arrow-up'), key('arrow-down')], 'navigate')}
427
+ ${this.renderHint([key('corner-down-left')], 'select')}
428
+ ${this.renderHint(['esc'], 'dismiss')}
429
+ </div>`;
430
+ }
431
+
327
432
  render() {
328
- const hidden = this.items.length - this.visibleItems.length;
433
+ const hidden = this.items.length - this.listItems.length;
329
434
 
330
435
  return html`
331
436
  <div
332
437
  part="panel"
333
438
  class="slash-menu__panel"
334
439
  popover="manual"
335
- role="listbox"
336
- aria-hidden=${this.open ? 'false' : 'true'}
337
- aria-label=${this.query ? `Matches for ${this.query}` : this.heading}>
338
- <div part="heading" class="slash-menu__heading">
339
- ${this.query ? html`Matching <strong>${this.query}</strong>` : this.heading}
440
+ aria-hidden=${this.open ? 'false' : 'true'}>
441
+ <div
442
+ part="list"
443
+ class="slash-menu__list"
444
+ role="listbox"
445
+ aria-label=${this.query ? `Matches for ${this.query}` : this.heading}>
446
+ ${this.items.length
447
+ ? this.renderItems()
448
+ : html`
449
+ <div class="slash-menu__empty">${this.emptyText}</div>`}
450
+ ${hidden > 0 ? html`
451
+ <div part="footer" class="slash-menu__footer">${hidden} more — keep typing to narrow</div>` : ''}
340
452
  </div>
341
- ${this.items.length
342
- ? this.renderItems()
343
- : html`
344
- <div class="slash-menu__empty">${this.emptyText}</div>`}
345
- ${hidden > 0 ? html`
346
- <div part="footer" class="slash-menu__footer">${hidden} more — keep typing to narrow</div>` : ''}
453
+ ${this.hideHints ? '' : this.renderHints()}
347
454
  </div>`;
348
455
  }
349
456
  }
@@ -3,6 +3,8 @@
3
3
  :host {
4
4
  --slash-menu-width: 320px;
5
5
  --slash-menu-max-height: 260px;
6
+ --slash-menu-border-radius: 12px;
7
+ --slash-menu-item-border-radius: 8px;
6
8
 
7
9
  display: contents;
8
10
  }
@@ -20,24 +22,32 @@
20
22
  width: var(--slash-menu-width);
21
23
  max-width: var(--auto-size-available-width, none);
22
24
  max-height: min(var(--slash-menu-max-height), var(--auto-size-available-height, 100vh));
23
- overflow-y: auto;
24
- overscroll-behavior: none;
25
25
  z-index: var(--zn-z-index-dropdown);
26
- padding: var(--zn-spacing-2x-small, 4px) 0;
26
+ overflow: hidden;
27
27
  background: var(--zn-panel-background-color);
28
28
  border: solid var(--zn-panel-border-width, 1px) var(--zn-panel-border-color);
29
- border-radius: var(--zn-border-radius, 6px);
29
+ border-radius: var(--slash-menu-border-radius);
30
30
  box-shadow: var(--zn-shadow-medium);
31
31
  font-family: var(--zn-font-sans);
32
32
  font-size: var(--zn-font-size-medium);
33
33
  color: rgb(var(--zn-text));
34
34
  }
35
35
 
36
- .slash-menu__heading,
36
+ .slash-menu__list {
37
+ display: flex;
38
+ flex: 1 1 auto;
39
+ flex-direction: column;
40
+ gap: var(--zn-spacing-3x-small, 2px);
41
+ min-height: 0;
42
+ overflow-y: auto;
43
+ overscroll-behavior: none;
44
+ padding: var(--zn-spacing-2x-small, 4px) var(--zn-spacing-x-small, 8px);
45
+ }
46
+
37
47
  .slash-menu__group-heading,
38
48
  .slash-menu__footer,
39
49
  .slash-menu__empty {
40
- padding: var(--zn-spacing-2x-small, 4px) var(--zn-spacing-small, 12px);
50
+ padding: var(--zn-spacing-2x-small, 4px) var(--zn-spacing-x-small, 8px);
41
51
  font-size: var(--zn-font-size-x-small);
42
52
  color: var(--zn-color-neutral-500);
43
53
  user-select: none;
@@ -45,9 +55,11 @@
45
55
  }
46
56
 
47
57
  .slash-menu__group-heading {
58
+ padding-top: var(--zn-spacing-x-small, 8px);
48
59
  font-weight: var(--zn-font-weight-semibold);
49
60
  text-transform: uppercase;
50
- letter-spacing: 0.04em;
61
+ letter-spacing: 0.06em;
62
+ color: var(--zn-color-neutral-400);
51
63
  }
52
64
 
53
65
  .slash-menu__footer {
@@ -55,24 +67,72 @@
55
67
  margin-top: var(--zn-spacing-2x-small, 4px);
56
68
  }
57
69
 
70
+ .slash-menu__divider {
71
+ height: var(--zn-panel-border-width, 1px);
72
+ margin: var(--zn-spacing-2x-small, 4px) 0;
73
+ background: var(--zn-panel-border-color);
74
+ }
75
+
76
+ .slash-menu__hints {
77
+ display: flex;
78
+ flex: 0 0 auto;
79
+ align-items: center;
80
+ gap: var(--zn-spacing-small, 12px);
81
+ padding: var(--zn-spacing-x-small, 8px) var(--zn-spacing-small, 12px);
82
+ border-top: solid var(--zn-panel-border-width, 1px) var(--zn-panel-border-color);
83
+ font-size: var(--zn-font-size-x-small);
84
+ color: var(--zn-color-neutral-500);
85
+ user-select: none;
86
+ -webkit-user-select: none;
87
+ }
88
+
89
+ .slash-menu__hint {
90
+ display: flex;
91
+ align-items: center;
92
+ gap: var(--zn-spacing-2x-small, 4px);
93
+ }
94
+
95
+ .slash-menu__key {
96
+ display: inline-flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ min-width: 22px;
100
+ height: 22px;
101
+ padding: 0 var(--zn-spacing-2x-small, 4px);
102
+ border: solid var(--zn-panel-border-width, 1px) var(--zn-panel-border-color);
103
+ border-radius: var(--zn-border-radius, 6px);
104
+ background: var(--zn-panel-background-color);
105
+ font-family: var(--zn-font-sans);
106
+ font-size: var(--zn-font-size-2x-small);
107
+ line-height: 1;
108
+ color: var(--zn-color-neutral-600);
109
+ }
110
+
58
111
  .slash-menu__item {
59
112
  display: flex;
60
113
  align-items: center;
61
- gap: var(--zn-spacing-x-small, 8px);
114
+ gap: var(--zn-spacing-small, 12px);
62
115
  width: 100%;
63
- padding: var(--zn-spacing-x-small, 8px) var(--zn-spacing-small, 12px);
116
+ padding: var(--zn-spacing-2x-small, 4px) var(--zn-spacing-x-small, 8px);
64
117
  border: none;
118
+ border-radius: var(--slash-menu-item-border-radius);
65
119
  background: transparent;
66
120
  color: inherit;
67
121
  font: inherit;
68
122
  text-align: left;
69
123
  cursor: pointer;
70
124
 
71
- &--active,
72
125
  &:hover:not(&--disabled) {
73
126
  background: var(--zn-input-background-color-hover);
74
127
  }
75
128
 
129
+ // The active item is where Enter lands, so it is tinted rather than merely shaded like a hover.
130
+ // The tints throughout are translucent, so one set of them suits a light and a dark panel alike.
131
+ &--active,
132
+ &--active:hover:not(&--disabled) {
133
+ background: rgba(var(--zn-color-primary), 0.08);
134
+ }
135
+
76
136
  &--disabled {
77
137
  opacity: 0.5;
78
138
  cursor: not-allowed;
@@ -83,8 +143,25 @@
83
143
  display: flex;
84
144
  align-items: center;
85
145
  justify-content: center;
86
- flex: 0 0 16px;
87
- color: var(--zn-color-neutral-500);
146
+ flex: 0 0 auto;
147
+ width: 32px;
148
+ height: 32px;
149
+ border: solid var(--zn-panel-border-width, 1px) rgba(var(--zn-color-primary), 0.18);
150
+ border-radius: var(--slash-menu-item-border-radius);
151
+ background: rgba(var(--zn-color-primary), 0.06);
152
+ color: var(--zn-color-neutral-700);
153
+
154
+ // Icons are all-or-nothing across a list, so an item without one still holds the column
155
+ &--empty {
156
+ border-color: transparent;
157
+ background: none;
158
+ }
159
+
160
+ .slash-menu__item--active & {
161
+ border-color: rgba(var(--zn-color-primary), 0.3);
162
+ background: rgba(var(--zn-color-primary), 0.14);
163
+ color: rgb(var(--zn-color-primary));
164
+ }
88
165
  }
89
166
 
90
167
  .slash-menu__text {
@@ -95,6 +172,7 @@
95
172
  }
96
173
 
97
174
  .slash-menu__label {
175
+ font-weight: var(--zn-font-weight-semibold);
98
176
  overflow: hidden;
99
177
  text-overflow: ellipsis;
100
178
  white-space: nowrap;
@@ -81,6 +81,113 @@ describe('<zn-slash-menu>', () => {
81
81
  expect(reachedDocument, 'mousedown does not bubble to document').to.be.false;
82
82
  });
83
83
 
84
+ it('shows the keyboard hints unless they are hidden', async () => {
85
+ const el = await fixture<ZnSlashMenu>(html`
86
+ <zn-slash-menu></zn-slash-menu>`);
87
+ el.items = [{label: 'One'}];
88
+ el.show();
89
+ await el.updateComplete;
90
+
91
+ const hints = el.shadowRoot!.querySelector('[part="hints"]')!;
92
+ expect(hints).to.exist;
93
+ expect(hints.textContent).to.contain('navigate');
94
+ expect(hints.textContent).to.contain('select');
95
+ expect(hints.textContent).to.contain('dismiss');
96
+ expect(hints.querySelectorAll('kbd').length).to.equal(4);
97
+
98
+ el.hideHints = true;
99
+ await el.updateComplete;
100
+ expect(el.shadowRoot!.querySelector('[part="hints"]')).to.not.exist;
101
+ });
102
+
103
+ describe('recently used', () => {
104
+ const key = 'slash-menu-test';
105
+ const labelsOf = (el: ZnSlashMenu) =>
106
+ [...el.shadowRoot!.querySelectorAll('[data-slash-item] .slash-menu__label')].map(node => node.textContent);
107
+
108
+ const choose = async (el: ZnSlashMenu, label: string) => {
109
+ const buttons = [...el.shadowRoot!.querySelectorAll<HTMLElement>('[data-slash-item]')];
110
+ const button = buttons.find(item => item.querySelector('.slash-menu__label')?.textContent === label);
111
+ button!.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true}));
112
+ await el.updateComplete;
113
+ };
114
+
115
+ const items = [
116
+ {label: 'One', value: '1'},
117
+ {label: 'Two', value: '2'},
118
+ {label: 'Three', value: '3'}
119
+ ];
120
+
121
+ beforeEach(() => localStorage.removeItem('zn-slash-recent:' + key));
122
+
123
+ it('lists the items chosen here first, newest first, and remembers them', async () => {
124
+ const el = await fixture<ZnSlashMenu>(html`
125
+ <zn-slash-menu recent-key=${key} max-recent="2"></zn-slash-menu>`);
126
+ el.items = items;
127
+ el.show();
128
+ await el.updateComplete;
129
+
130
+ expect(labelsOf(el), 'nothing has been chosen yet').to.eql(['One', 'Two', 'Three']);
131
+
132
+ await choose(el, 'Three');
133
+ await choose(el, 'Two');
134
+
135
+ expect(labelsOf(el)).to.eql(['Two', 'Three', 'One', 'Two', 'Three']);
136
+ expect(el.shadowRoot!.querySelector('[part="group-heading"]')!.textContent).to.contain('Recently used');
137
+ expect(el.activeItem?.label, 'starts on the most recent item').to.equal('Two');
138
+
139
+ const other = await fixture<ZnSlashMenu>(html`
140
+ <zn-slash-menu recent-key=${key} max-recent="2" recent-heading="Your favourites"></zn-slash-menu>`);
141
+ other.items = items;
142
+ other.show();
143
+ await other.updateComplete;
144
+
145
+ expect(labelsOf(other), 'the list outlives the element').to.eql(['Two', 'Three', 'One', 'Two', 'Three']);
146
+ expect(other.shadowRoot!.querySelector('[part="group-heading"]')!.textContent).to.contain('Your favourites');
147
+
148
+ other.clearRecent();
149
+ await other.updateComplete;
150
+ expect(labelsOf(other)).to.eql(['One', 'Two', 'Three']);
151
+ });
152
+
153
+ it('closes the section with a rule only when the items below it have no heading', async () => {
154
+ const el = await fixture<ZnSlashMenu>(html`
155
+ <zn-slash-menu recent-key=${key}></zn-slash-menu>`);
156
+ el.items = items;
157
+ el.show();
158
+ await el.updateComplete;
159
+
160
+ await choose(el, 'Two');
161
+ expect(el.shadowRoot!.querySelector('[part="divider"]'), 'ungrouped items get a rule').to.exist;
162
+
163
+ el.items = items.map(item => ({...item, group: 'Basic blocks'}));
164
+ await el.updateComplete;
165
+
166
+ expect(el.shadowRoot!.querySelector('[part="divider"]'), 'a group heading separates them already').to.not.exist;
167
+ expect(el.shadowRoot!.querySelectorAll('[part="group-heading"]').length).to.equal(2);
168
+ });
169
+
170
+ it('stands aside while the user is searching, and stays off without a key', async () => {
171
+ const el = await fixture<ZnSlashMenu>(html`
172
+ <zn-slash-menu recent-key=${key}></zn-slash-menu>`);
173
+ el.items = items;
174
+ el.show();
175
+ await el.updateComplete;
176
+
177
+ await choose(el, 'Three');
178
+ el.query = 'o';
179
+ await el.updateComplete;
180
+
181
+ expect(labelsOf(el), 'the ranked matches are the answer to a query').to.eql(['One', 'Two', 'Three']);
182
+
183
+ el.recentKey = '';
184
+ el.query = '';
185
+ await el.updateComplete;
186
+
187
+ expect(labelsOf(el)).to.eql(['One', 'Two', 'Three']);
188
+ });
189
+ });
190
+
84
191
  it('reports how many matches were not rendered', async () => {
85
192
  const el = await fixture<ZnSlashMenu>(html`
86
193
  <zn-slash-menu max-items="2"></zn-slash-menu>`);
@@ -200,12 +200,18 @@ export default class ZnTextarea extends ZincElement implements ZincFormControl {
200
200
  /** The characters that open the slash menu. */
201
201
  @property({attribute: 'slash-trigger'}) slashTrigger = '/';
202
202
 
203
- /** The heading shown above the slash menu's items. */
203
+ /** The name the slash menu's list is announced by. */
204
204
  @property({attribute: 'slash-heading'}) slashHeading = 'Insert';
205
205
 
206
206
  /** Hides the insertion keys normally shown against the slash menu's items. */
207
207
  @property({attribute: 'slash-hide-keys', type: Boolean}) slashHideKeys = false;
208
208
 
209
+ /**
210
+ * Lists the items most recently chosen here above the rest, remembered in `localStorage` under this
211
+ * key. Share a key between the fields that should share a history; leave unset to offer no such list.
212
+ */
213
+ @property({attribute: 'slash-recent-key'}) slashRecentKey = '';
214
+
209
215
  /**
210
216
  * Resolves additional items each time the menu opens, for lists that come from elsewhere (e.g. an
211
217
  * API). Receives the current query and may return a promise. JavaScript only.
@@ -587,7 +593,11 @@ export default class ZnTextarea extends ZincElement implements ZincFormControl {
587
593
  </div>
588
594
  ${this.hasSlashMenu
589
595
  ? html`
590
- <zn-slash-menu part="slash-menu" heading=${this.slashHeading} ?hide-keys=${this.slashHideKeys}></zn-slash-menu>`
596
+ <zn-slash-menu
597
+ part="slash-menu"
598
+ heading=${this.slashHeading}
599
+ recent-key=${this.slashRecentKey}
600
+ ?hide-keys=${this.slashHideKeys}></zn-slash-menu>`
591
601
  : ''}
592
602
  <slot name="slash-menu"></slot>
593
603
  <slot name="slash-items" hidden></slot>
@@ -151,8 +151,8 @@ describe('<zn-textarea>', () => {
151
151
 
152
152
  expect(el.shadowRoot!.querySelector('zn-slash-menu'), 'no second menu is created').to.be.null;
153
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');
154
+ expect(slotted.shadowRoot!.querySelector('[part="list"]')?.getAttribute('aria-label'))
155
+ .to.equal('Replacements');
156
156
  expect(menuItemLabels(slotted), 'the author\'s max-items is respected').to.deep.equal(['Brand name']);
157
157
 
158
158
  press(el, 'Enter');