@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.
- package/dist/custom-elements.json +995 -196
- package/dist/vscode.html-custom-data.json +74 -19
- package/dist/web-types.json +147 -37
- package/dist/zn.d.ts +335 -59
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +467 -355
- package/docs/pages/components/page-builder.md +121 -19
- package/docs/pages/components/slash-menu.md +132 -6
- package/docs/pages/components/textarea.md +16 -0
- package/package.json +1 -1
- package/scss/_root.scss +7 -1
- package/src/components/button/button.scss +5 -2
- package/src/components/inline-edit/inline-edit.component.ts +6 -1
- package/src/components/input/input.component.ts +12 -2
- package/src/components/page/page.scss +7 -2
- package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
- package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
- package/src/components/page-builder/page-builder.component.ts +483 -225
- package/src/components/page-builder/page-builder.scss +64 -10
- package/src/components/page-builder/page-builder.test.ts +656 -110
- package/src/components/page-builder/page-tree.test.ts +483 -0
- package/src/components/page-builder/page-tree.ts +329 -0
- package/src/components/page-builder/page.types.ts +75 -7
- package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
- package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
- package/src/components/settings-container/settings-container.scss +2 -1
- package/src/components/slash-item/slash-item.component.ts +1 -1
- package/src/components/slash-menu/slash-menu-items.ts +48 -0
- package/src/components/slash-menu/slash-menu.component.ts +134 -27
- package/src/components/slash-menu/slash-menu.scss +90 -12
- package/src/components/slash-menu/slash-menu.test.ts +107 -0
- package/src/components/textarea/textarea.component.ts +12 -2
- package/src/components/textarea/textarea.test.ts +2 -2
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
277
|
+
const list = this.list;
|
|
215
278
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
if (
|
|
219
|
-
|
|
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 (
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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.
|
|
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
|
-
|
|
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(--
|
|
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-
|
|
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,
|
|
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.
|
|
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-
|
|
114
|
+
gap: var(--zn-spacing-small, 12px);
|
|
62
115
|
width: 100%;
|
|
63
|
-
padding: var(--zn-spacing-
|
|
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
|
|
87
|
-
|
|
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
|
|
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
|
|
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="
|
|
155
|
-
.to.
|
|
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');
|