@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.
- package/dist/custom-elements.json +1149 -130
- package/dist/vscode.html-custom-data.json +175 -1
- package/dist/web-types.json +396 -2
- package/dist/zn.d.ts +384 -0
- package/dist/zn.min.js +350 -295
- package/docs/pages/components/inline-edit.md +21 -0
- package/docs/pages/components/rating.md +2 -1
- package/docs/pages/components/slash-item.md +126 -0
- package/docs/pages/components/slash-menu.md +168 -0
- package/docs/pages/components/textarea.md +148 -0
- package/docs/pages/components/translations.md +34 -0
- package/package.json +1 -1
- package/src/components/inline-edit/inline-edit.component.ts +31 -0
- package/src/components/inline-edit/inline-edit.test.ts +83 -1
- package/src/components/rating/rating.component.ts +10 -1
- package/src/components/rating/rating.scss +6 -9
- package/src/components/slash-item/index.ts +12 -0
- package/src/components/slash-item/slash-item.component.ts +76 -0
- package/src/components/slash-item/slash-item.scss +5 -0
- package/src/components/slash-menu/index.ts +14 -0
- package/src/components/slash-menu/slash-menu-controller.ts +361 -0
- package/src/components/slash-menu/slash-menu-items.ts +122 -0
- package/src/components/slash-menu/slash-menu.component.ts +305 -0
- package/src/components/slash-menu/slash-menu.scss +120 -0
- package/src/components/slash-menu/slash-menu.test.ts +154 -0
- package/src/components/textarea/textarea.component.ts +143 -0
- package/src/components/textarea/textarea.test.ts +310 -2
- package/src/components/translations/translations.component.ts +31 -0
- package/src/components/translations/translations.test.ts +89 -1
- package/src/events/events.ts +2 -0
- package/src/events/zn-slash-insert.ts +9 -0
- package/src/events/zn-slash-select.ts +9 -0
- package/src/utilities/caret-position.ts +118 -0
- package/src/zinc.ts +3 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export interface SlashMenuItem {
|
|
2
|
+
/** The text shown in the menu. */
|
|
3
|
+
label: string;
|
|
4
|
+
/** The text inserted into the field. Omit for items handled entirely by the `zn-slash-select` event. */
|
|
5
|
+
value?: string;
|
|
6
|
+
/** Icon shown against the item, e.g. `tag@lu`. */
|
|
7
|
+
icon?: string;
|
|
8
|
+
/** Supporting text shown under the label. */
|
|
9
|
+
description?: string;
|
|
10
|
+
/** Extra terms the item can be found by. */
|
|
11
|
+
keywords?: string | string[];
|
|
12
|
+
/** Heading the item is listed under. Items without a group are listed first, in source order. */
|
|
13
|
+
group?: string;
|
|
14
|
+
/** Overrides the position of the item within its match band. Lower sorts first. */
|
|
15
|
+
order?: number;
|
|
16
|
+
/** Identifier passed through on `zn-slash-select`, for items that do something other than insert text. */
|
|
17
|
+
action?: string;
|
|
18
|
+
/** Where the caret lands after insertion, as an offset into `value`. Defaults to the end. */
|
|
19
|
+
caretOffset?: number;
|
|
20
|
+
/** Listed, but not selectable. */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const presets = new Map<string, SlashMenuItem[]>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Registers a named, reusable set of insertions, so a list defined once (e.g. the merge fields
|
|
28
|
+
* allowed in legal copy) can be referenced from markup with `slash-preset="<name>"`.
|
|
29
|
+
*/
|
|
30
|
+
export function registerSlashMenuPreset(name: string, items: SlashMenuItem[]) {
|
|
31
|
+
presets.set(name.trim(), items);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Removes a preset registered with `registerSlashMenuPreset`. */
|
|
35
|
+
export function unregisterSlashMenuPreset(name: string) {
|
|
36
|
+
presets.delete(name.trim());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The names of every registered preset. */
|
|
40
|
+
export function slashMenuPresetNames(): string[] {
|
|
41
|
+
return [...presets.keys()];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolves one or more preset names (comma separated, or an array) to their items. */
|
|
45
|
+
export function getSlashMenuPreset(names: string | string[]): SlashMenuItem[] {
|
|
46
|
+
const list = Array.isArray(names) ? names : names.split(',');
|
|
47
|
+
|
|
48
|
+
return list.reduce<SlashMenuItem[]>((items, name) => {
|
|
49
|
+
const preset = presets.get(name.trim());
|
|
50
|
+
return preset ? [...items, ...preset] : items;
|
|
51
|
+
}, []);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isItemLike(value: unknown): value is SlashMenuItem {
|
|
55
|
+
return typeof value === 'object' && value !== null && typeof (value as SlashMenuItem).label === 'string';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses the `slash-items` attribute. Accepts a JSON array of items, or the shorthand
|
|
60
|
+
* `Label={{TOKEN}}, Other={{OTHER}}` (the label may be omitted to use the token as its own label).
|
|
61
|
+
*/
|
|
62
|
+
export function parseSlashItems(value: string | null | undefined): SlashMenuItem[] {
|
|
63
|
+
const raw = value?.trim();
|
|
64
|
+
if (!raw) return [];
|
|
65
|
+
|
|
66
|
+
if (raw.startsWith('[')) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed: unknown = JSON.parse(raw);
|
|
69
|
+
return Array.isArray(parsed) ? parsed.filter(isItemLike) : [];
|
|
70
|
+
} catch {
|
|
71
|
+
console.warn('slash-items could not be parsed as JSON', raw);
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return raw
|
|
77
|
+
.split(',')
|
|
78
|
+
.map(entry => entry.trim())
|
|
79
|
+
.filter(entry => entry !== '')
|
|
80
|
+
.map(entry => {
|
|
81
|
+
const separator = entry.indexOf('=');
|
|
82
|
+
if (separator === -1) return {label: entry, value: entry};
|
|
83
|
+
|
|
84
|
+
const label = entry.slice(0, separator).trim();
|
|
85
|
+
const insert = entry.slice(separator + 1).trim();
|
|
86
|
+
return {label: label || insert, value: insert};
|
|
87
|
+
})
|
|
88
|
+
.filter(item => item.label !== '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function keywordsOf(item: SlashMenuItem): string[] {
|
|
92
|
+
if (!item.keywords) return [];
|
|
93
|
+
const keywords = Array.isArray(item.keywords) ? item.keywords : item.keywords.split(',');
|
|
94
|
+
return keywords.map(keyword => keyword.trim().toLowerCase()).filter(keyword => keyword !== '');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Lower bands sort first, so a label match always beats an incidental hit in a
|
|
98
|
+
// description or in the token being inserted.
|
|
99
|
+
function scoreItem(item: SlashMenuItem, query: string): number {
|
|
100
|
+
const label = item.label.toLowerCase();
|
|
101
|
+
if (label.startsWith(query)) return 0;
|
|
102
|
+
if (label.split(/\s+/).some(word => word.startsWith(query))) return 1;
|
|
103
|
+
if (label.includes(query)) return 2;
|
|
104
|
+
if (keywordsOf(item).some(keyword => keyword.includes(query))) return 3;
|
|
105
|
+
if (item.value?.toLowerCase().includes(query)) return 4;
|
|
106
|
+
if (item.description?.toLowerCase().includes(query)) return 5;
|
|
107
|
+
return -1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Filters and ranks items against a query. An empty query keeps every item in its declared order. */
|
|
111
|
+
export function filterSlashItems(items: SlashMenuItem[], query: string): SlashMenuItem[] {
|
|
112
|
+
const needle = query.trim().toLowerCase();
|
|
113
|
+
|
|
114
|
+
return items
|
|
115
|
+
.map((item, index) => ({item, index, score: needle ? scoreItem(item, needle) : 0}))
|
|
116
|
+
.filter(entry => entry.score !== -1)
|
|
117
|
+
.sort((a, b) =>
|
|
118
|
+
a.score - b.score ||
|
|
119
|
+
(a.item.order ?? a.index) - (b.item.order ?? b.index) ||
|
|
120
|
+
a.index - b.index)
|
|
121
|
+
.map(entry => entry.item);
|
|
122
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import {autoUpdate, computePosition, flip, offset, shift, size} from '@floating-ui/dom';
|
|
2
|
+
import {classMap} from 'lit/directives/class-map.js';
|
|
3
|
+
import {html, unsafeCSS} from 'lit';
|
|
4
|
+
import {property, query, state} from 'lit/decorators.js';
|
|
5
|
+
import ZincElement from '../../internal/zinc-element';
|
|
6
|
+
import ZnIcon from '../icon';
|
|
7
|
+
import type {CSSResultGroup, PropertyValues} from 'lit';
|
|
8
|
+
import type {Placement, VirtualElement} from '@floating-ui/dom';
|
|
9
|
+
import type {SlashMenuItem} from './slash-menu-items';
|
|
10
|
+
|
|
11
|
+
import styles from './slash-menu.scss';
|
|
12
|
+
|
|
13
|
+
export const SLASH_ITEM_SELECT = 'zn-slash-item-select';
|
|
14
|
+
|
|
15
|
+
function sameItems(a: SlashMenuItem[] | undefined, b: SlashMenuItem[]): boolean {
|
|
16
|
+
return !!a && a.length === b.length && a.every((item, i) =>
|
|
17
|
+
item.label === b[i].label && item.value === b[i].value && item.disabled === b[i].disabled);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @summary A keyboard-driven list of insertions, anchored to the caret of the field that opened it.
|
|
22
|
+
* @documentation https://zinc.style/components/slash-menu
|
|
23
|
+
* @status experimental
|
|
24
|
+
* @since 1.1
|
|
25
|
+
*
|
|
26
|
+
* @dependency zn-icon
|
|
27
|
+
*
|
|
28
|
+
* @event zn-slash-item-select - Emitted when an item is chosen. Does not cross shadow boundaries; the
|
|
29
|
+
* component driving the menu (e.g. `zn-textarea`) re-emits it as `zn-slash-select`.
|
|
30
|
+
*
|
|
31
|
+
* @csspart panel - The floating panel that holds the list.
|
|
32
|
+
* @csspart heading - The panel's heading.
|
|
33
|
+
* @csspart item - An item in the list.
|
|
34
|
+
* @csspart group-heading - A group heading between items.
|
|
35
|
+
* @csspart footer - The truncation footer, shown when not every match fits.
|
|
36
|
+
*
|
|
37
|
+
* @cssproperty --slash-menu-width - The width of the panel.
|
|
38
|
+
* @cssproperty --slash-menu-max-height - The maximum height of the panel before it scrolls.
|
|
39
|
+
*/
|
|
40
|
+
export default class ZnSlashMenu extends ZincElement {
|
|
41
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
42
|
+
static dependencies = {
|
|
43
|
+
'zn-icon': ZnIcon
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
@query('.slash-menu__panel') private panel: HTMLElement;
|
|
47
|
+
|
|
48
|
+
private stopAutoUpdate?: () => void;
|
|
49
|
+
|
|
50
|
+
/** Whether the menu is showing. */
|
|
51
|
+
@property({type: Boolean, reflect: true}) open = false;
|
|
52
|
+
|
|
53
|
+
/** The items to list. Already filtered — the menu displays what it is given. */
|
|
54
|
+
@property({type: Array}) items: SlashMenuItem[] = [];
|
|
55
|
+
|
|
56
|
+
/** The query the items were matched against, shown in the heading. */
|
|
57
|
+
@property() query = '';
|
|
58
|
+
|
|
59
|
+
/** The heading shown when there is no query. */
|
|
60
|
+
@property() heading = 'Insert';
|
|
61
|
+
|
|
62
|
+
/** Shown in place of the list when there are no items. */
|
|
63
|
+
@property({attribute: 'empty-text'}) emptyText = 'No matches';
|
|
64
|
+
|
|
65
|
+
/** The most items to render at once. Remaining matches are reported in the footer. */
|
|
66
|
+
@property({attribute: 'max-items', type: Number}) maxItems = 25;
|
|
67
|
+
|
|
68
|
+
/** The element or caret rect the panel is positioned against. */
|
|
69
|
+
@property({attribute: false}) anchor: Element | VirtualElement | null = null;
|
|
70
|
+
|
|
71
|
+
/** The preferred placement of the panel. */
|
|
72
|
+
@property() placement: Placement = 'bottom-start';
|
|
73
|
+
|
|
74
|
+
/** The gap between the caret and the panel. */
|
|
75
|
+
@property({type: Number}) distance = 4;
|
|
76
|
+
|
|
77
|
+
@state() private activeIndex = 0;
|
|
78
|
+
|
|
79
|
+
private get visibleItems(): SlashMenuItem[] {
|
|
80
|
+
return this.maxItems > 0 ? this.items.slice(0, this.maxItems) : this.items;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The item that Enter would insert. */
|
|
84
|
+
get activeItem(): SlashMenuItem | undefined {
|
|
85
|
+
return this.visibleItems[this.activeIndex];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
show() {
|
|
89
|
+
this.open = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
hide() {
|
|
93
|
+
this.open = false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Sets the active item by index, wrapping at both ends and skipping disabled items. */
|
|
97
|
+
setActiveIndex(index: number) {
|
|
98
|
+
const items = this.visibleItems;
|
|
99
|
+
const selectable = items.filter(item => !item.disabled).length;
|
|
100
|
+
if (!selectable) {
|
|
101
|
+
this.activeIndex = -1;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let next = (index + items.length) % items.length;
|
|
106
|
+
// Walk in the direction of travel until a selectable item is found
|
|
107
|
+
const step = index < this.activeIndex ? -1 : 1;
|
|
108
|
+
while (items[next]?.disabled) {
|
|
109
|
+
next = (next + step + items.length) % items.length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.activeIndex = next;
|
|
113
|
+
void this.updateComplete.then(() => this.scrollActiveIntoView());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Moves the active item by `delta` places. */
|
|
117
|
+
moveActive(delta: number) {
|
|
118
|
+
this.setActiveIndex(this.activeIndex + delta);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Chooses the active item, as pressing Enter would. */
|
|
122
|
+
selectActive() {
|
|
123
|
+
const item = this.activeItem;
|
|
124
|
+
if (item) this.selectItem(item);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Recalculates the panel's position against its anchor. */
|
|
128
|
+
reposition() {
|
|
129
|
+
void this.position();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
connectedCallback() {
|
|
133
|
+
super.connectedCallback();
|
|
134
|
+
if (this.open) void this.updateComplete.then(() => this.startPositioner());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
disconnectedCallback() {
|
|
138
|
+
super.disconnectedCallback();
|
|
139
|
+
this.stopPositioner();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private startPositioner() {
|
|
143
|
+
this.stopPositioner();
|
|
144
|
+
if (!this.anchor || !this.panel) return;
|
|
145
|
+
|
|
146
|
+
// autoUpdate keeps the panel on the caret through scrolling and resizes
|
|
147
|
+
this.stopAutoUpdate = autoUpdate(this.anchor, this.panel, () => void this.position());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private stopPositioner() {
|
|
151
|
+
this.stopAutoUpdate?.();
|
|
152
|
+
this.stopAutoUpdate = undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async position() {
|
|
156
|
+
const {anchor, panel} = this;
|
|
157
|
+
if (!this.open || !anchor || !panel) return;
|
|
158
|
+
|
|
159
|
+
const {x, y} = await computePosition(anchor, panel, {
|
|
160
|
+
placement: this.placement,
|
|
161
|
+
strategy: 'fixed',
|
|
162
|
+
middleware: [
|
|
163
|
+
offset(this.distance),
|
|
164
|
+
flip({padding: 8}),
|
|
165
|
+
shift({padding: 8}),
|
|
166
|
+
size({
|
|
167
|
+
padding: 8,
|
|
168
|
+
apply: ({availableHeight}) => {
|
|
169
|
+
panel.style.setProperty('--auto-size-available-height', `${Math.max(availableHeight, 0)}px`);
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
]
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
Object.assign(panel.style, {left: `${x}px`, top: `${y}px`});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private selectItem(item: SlashMenuItem) {
|
|
179
|
+
if (item.disabled) return;
|
|
180
|
+
|
|
181
|
+
this.dispatchEvent(new CustomEvent(SLASH_ITEM_SELECT, {
|
|
182
|
+
bubbles: true,
|
|
183
|
+
cancelable: true,
|
|
184
|
+
composed: false,
|
|
185
|
+
detail: {item, query: this.query}
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private scrollActiveIntoView() {
|
|
190
|
+
const active = this.renderRoot.querySelector<HTMLElement>('[data-slash-item][aria-selected="true"]');
|
|
191
|
+
active?.scrollIntoView({block: 'nearest'});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// mousedown, not click: preventDefault keeps focus (and the caret) in the field
|
|
195
|
+
private readonly handleItemMouseDown = (event: MouseEvent) => {
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
|
|
198
|
+
const index = Number((event.currentTarget as HTMLElement).dataset.index);
|
|
199
|
+
const item = this.visibleItems[index];
|
|
200
|
+
if (item) {
|
|
201
|
+
this.activeIndex = index;
|
|
202
|
+
this.selectItem(item);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
protected willUpdate(changed: PropertyValues) {
|
|
207
|
+
super.willUpdate(changed);
|
|
208
|
+
|
|
209
|
+
// A genuinely new result set starts on its first selectable item. Re-resolving the same query
|
|
210
|
+
// hands over an equal-but-new array, which must not move the user's place in the list.
|
|
211
|
+
if (changed.has('items') && !sameItems(changed.get('items') as SlashMenuItem[] | undefined, this.items)) {
|
|
212
|
+
this.activeIndex = this.visibleItems.findIndex(item => !item.disabled);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
protected updated(changed: PropertyValues) {
|
|
217
|
+
super.updated(changed);
|
|
218
|
+
|
|
219
|
+
if (changed.has('open') || changed.has('anchor')) {
|
|
220
|
+
if (this.open) {
|
|
221
|
+
this.startPositioner();
|
|
222
|
+
} else {
|
|
223
|
+
this.stopPositioner();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (this.open) void this.position();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private renderItem(item: SlashMenuItem, index: number, showIcons: boolean) {
|
|
231
|
+
const isActive = index === this.activeIndex;
|
|
232
|
+
const token = item.value && item.value !== item.label && !item.value.includes('\n') ? item.value : '';
|
|
233
|
+
|
|
234
|
+
return html`
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
part="item"
|
|
238
|
+
class=${classMap({
|
|
239
|
+
'slash-menu__item': true,
|
|
240
|
+
'slash-menu__item--active': isActive,
|
|
241
|
+
'slash-menu__item--disabled': !!item.disabled
|
|
242
|
+
})}
|
|
243
|
+
role="option"
|
|
244
|
+
aria-selected=${isActive ? 'true' : 'false'}
|
|
245
|
+
aria-disabled=${item.disabled ? 'true' : 'false'}
|
|
246
|
+
data-slash-item
|
|
247
|
+
data-index=${index}
|
|
248
|
+
tabindex="-1"
|
|
249
|
+
@mousedown=${this.handleItemMouseDown}>
|
|
250
|
+
${showIcons
|
|
251
|
+
? html`
|
|
252
|
+
<span class="slash-menu__icon">
|
|
253
|
+
${item.icon ? html`
|
|
254
|
+
<zn-icon src=${item.icon} size="16"></zn-icon>` : ''}
|
|
255
|
+
</span>`
|
|
256
|
+
: ''}
|
|
257
|
+
<span class="slash-menu__text">
|
|
258
|
+
<span class="slash-menu__label">${item.label}</span>
|
|
259
|
+
${item.description ? html`
|
|
260
|
+
<span class="slash-menu__description">${item.description}</span>` : ''}
|
|
261
|
+
</span>
|
|
262
|
+
${token ? html`
|
|
263
|
+
<code class="slash-menu__token">${token}</code>` : ''}
|
|
264
|
+
</button>`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private renderItems() {
|
|
268
|
+
const items = this.visibleItems;
|
|
269
|
+
const showIcons = items.some(item => item.icon);
|
|
270
|
+
let lastGroup: string | undefined;
|
|
271
|
+
|
|
272
|
+
return items.map((item, index) => {
|
|
273
|
+
const group = item.group;
|
|
274
|
+
const heading = group && group !== lastGroup
|
|
275
|
+
? html`
|
|
276
|
+
<div part="group-heading" class="slash-menu__group-heading">${group}</div>`
|
|
277
|
+
: '';
|
|
278
|
+
lastGroup = group;
|
|
279
|
+
|
|
280
|
+
return html`${heading}${this.renderItem(item, index, showIcons)}`;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
render() {
|
|
285
|
+
const hidden = this.items.length - this.visibleItems.length;
|
|
286
|
+
|
|
287
|
+
return html`
|
|
288
|
+
<div
|
|
289
|
+
part="panel"
|
|
290
|
+
class="slash-menu__panel"
|
|
291
|
+
role="listbox"
|
|
292
|
+
aria-hidden=${this.open ? 'false' : 'true'}
|
|
293
|
+
aria-label=${this.query ? `Matches for ${this.query}` : this.heading}>
|
|
294
|
+
<div part="heading" class="slash-menu__heading">
|
|
295
|
+
${this.query ? html`Matching <strong>${this.query}</strong>` : this.heading}
|
|
296
|
+
</div>
|
|
297
|
+
${this.items.length
|
|
298
|
+
? this.renderItems()
|
|
299
|
+
: html`
|
|
300
|
+
<div class="slash-menu__empty">${this.emptyText}</div>`}
|
|
301
|
+
${hidden > 0 ? html`
|
|
302
|
+
<div part="footer" class="slash-menu__footer">${hidden} more — keep typing to narrow</div>` : ''}
|
|
303
|
+
</div>`;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
@use "../../wc";
|
|
2
|
+
|
|
3
|
+
:host {
|
|
4
|
+
--slash-menu-width: 320px;
|
|
5
|
+
--slash-menu-max-height: 260px;
|
|
6
|
+
|
|
7
|
+
display: contents;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
:host(:not([open])) .slash-menu__panel {
|
|
11
|
+
display: none;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.slash-menu__panel {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
position: fixed;
|
|
18
|
+
top: 0;
|
|
19
|
+
left: 0;
|
|
20
|
+
width: var(--slash-menu-width);
|
|
21
|
+
max-width: var(--auto-size-available-width, none);
|
|
22
|
+
max-height: min(var(--slash-menu-max-height), var(--auto-size-available-height, 100vh));
|
|
23
|
+
overflow-y: auto;
|
|
24
|
+
overscroll-behavior: none;
|
|
25
|
+
z-index: var(--zn-z-index-dropdown);
|
|
26
|
+
padding: var(--zn-spacing-2x-small, 4px) 0;
|
|
27
|
+
background: var(--zn-panel-background-color);
|
|
28
|
+
border: solid var(--zn-panel-border-width, 1px) var(--zn-panel-border-color);
|
|
29
|
+
border-radius: var(--zn-border-radius, 6px);
|
|
30
|
+
box-shadow: var(--zn-shadow-medium);
|
|
31
|
+
font-family: var(--zn-font-sans);
|
|
32
|
+
font-size: var(--zn-font-size-medium);
|
|
33
|
+
color: rgb(var(--zn-text));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.slash-menu__heading,
|
|
37
|
+
.slash-menu__group-heading,
|
|
38
|
+
.slash-menu__footer,
|
|
39
|
+
.slash-menu__empty {
|
|
40
|
+
padding: var(--zn-spacing-2x-small, 4px) var(--zn-spacing-small, 12px);
|
|
41
|
+
font-size: var(--zn-font-size-x-small);
|
|
42
|
+
color: var(--zn-color-neutral-500);
|
|
43
|
+
user-select: none;
|
|
44
|
+
-webkit-user-select: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.slash-menu__group-heading {
|
|
48
|
+
font-weight: var(--zn-font-weight-semibold);
|
|
49
|
+
text-transform: uppercase;
|
|
50
|
+
letter-spacing: 0.04em;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.slash-menu__footer {
|
|
54
|
+
border-top: solid var(--zn-panel-border-width, 1px) var(--zn-panel-border-color);
|
|
55
|
+
margin-top: var(--zn-spacing-2x-small, 4px);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.slash-menu__item {
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: var(--zn-spacing-x-small, 8px);
|
|
62
|
+
width: 100%;
|
|
63
|
+
padding: var(--zn-spacing-x-small, 8px) var(--zn-spacing-small, 12px);
|
|
64
|
+
border: none;
|
|
65
|
+
background: transparent;
|
|
66
|
+
color: inherit;
|
|
67
|
+
font: inherit;
|
|
68
|
+
text-align: left;
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
|
|
71
|
+
&--active,
|
|
72
|
+
&:hover:not(&--disabled) {
|
|
73
|
+
background: var(--zn-input-background-color-hover);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
&--disabled {
|
|
77
|
+
opacity: 0.5;
|
|
78
|
+
cursor: not-allowed;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.slash-menu__icon {
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
justify-content: center;
|
|
86
|
+
flex: 0 0 16px;
|
|
87
|
+
color: var(--zn-color-neutral-500);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.slash-menu__text {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
min-width: 0;
|
|
94
|
+
flex: 1 1 auto;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.slash-menu__label {
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
text-overflow: ellipsis;
|
|
100
|
+
white-space: nowrap;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.slash-menu__description {
|
|
104
|
+
font-size: var(--zn-font-size-x-small);
|
|
105
|
+
color: var(--zn-color-neutral-500);
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
text-overflow: ellipsis;
|
|
108
|
+
white-space: nowrap;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.slash-menu__token {
|
|
112
|
+
flex: 0 1 auto;
|
|
113
|
+
max-width: 45%;
|
|
114
|
+
overflow: hidden;
|
|
115
|
+
text-overflow: ellipsis;
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
font-family: var(--zn-font-mono);
|
|
118
|
+
font-size: var(--zn-font-size-x-small);
|
|
119
|
+
color: var(--zn-color-neutral-600);
|
|
120
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import '../../../dist/zn.min.js';
|
|
2
|
+
import {expect, fixture, html} from '@open-wc/testing';
|
|
3
|
+
import {
|
|
4
|
+
filterSlashItems,
|
|
5
|
+
getSlashMenuPreset,
|
|
6
|
+
parseSlashItems,
|
|
7
|
+
registerSlashMenuPreset,
|
|
8
|
+
unregisterSlashMenuPreset
|
|
9
|
+
} from './slash-menu-items';
|
|
10
|
+
import type ZnSlashMenu from './slash-menu.component';
|
|
11
|
+
|
|
12
|
+
describe('<zn-slash-menu>', () => {
|
|
13
|
+
it('should render a component', async () => {
|
|
14
|
+
const el = await fixture(html`
|
|
15
|
+
<zn-slash-menu></zn-slash-menu>`);
|
|
16
|
+
|
|
17
|
+
expect(el).to.exist;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('lists the items it is given and starts on the first one', async () => {
|
|
21
|
+
const el = await fixture<ZnSlashMenu>(html`
|
|
22
|
+
<zn-slash-menu></zn-slash-menu>`);
|
|
23
|
+
el.items = [{label: 'Brand name', value: '{{BRAND_NAME}}'}, {label: 'Legal entity', value: '{{LEGAL_ENTITY}}'}];
|
|
24
|
+
el.show();
|
|
25
|
+
await el.updateComplete;
|
|
26
|
+
|
|
27
|
+
const items = el.shadowRoot!.querySelectorAll('[data-slash-item]');
|
|
28
|
+
expect(items.length).to.equal(2);
|
|
29
|
+
expect(el.activeItem?.label).to.equal('Brand name');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('wraps when moving past either end and skips disabled items', async () => {
|
|
33
|
+
const el = await fixture<ZnSlashMenu>(html`
|
|
34
|
+
<zn-slash-menu></zn-slash-menu>`);
|
|
35
|
+
el.items = [
|
|
36
|
+
{label: 'One', value: '1'},
|
|
37
|
+
{label: 'Two', value: '2', disabled: true},
|
|
38
|
+
{label: 'Three', value: '3'}
|
|
39
|
+
];
|
|
40
|
+
await el.updateComplete;
|
|
41
|
+
|
|
42
|
+
el.moveActive(1);
|
|
43
|
+
expect(el.activeItem?.label, 'skips the disabled item').to.equal('Three');
|
|
44
|
+
|
|
45
|
+
el.moveActive(1);
|
|
46
|
+
expect(el.activeItem?.label, 'wraps to the start').to.equal('One');
|
|
47
|
+
|
|
48
|
+
el.moveActive(-1);
|
|
49
|
+
expect(el.activeItem?.label, 'wraps to the end').to.equal('Three');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('emits zn-slash-item-select when an item is clicked, without stealing focus', async () => {
|
|
53
|
+
const el = await fixture<ZnSlashMenu>(html`
|
|
54
|
+
<zn-slash-menu></zn-slash-menu>`);
|
|
55
|
+
el.items = [{label: 'Brand name', value: '{{BRAND_NAME}}'}];
|
|
56
|
+
el.show();
|
|
57
|
+
await el.updateComplete;
|
|
58
|
+
|
|
59
|
+
const selected: string[] = [];
|
|
60
|
+
el.addEventListener('zn-slash-item-select', (event: Event) => {
|
|
61
|
+
selected.push((event as CustomEvent<{item: {label: string}}>).detail.item.label);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const item = el.shadowRoot!.querySelector<HTMLButtonElement>('[data-slash-item]')!;
|
|
65
|
+
const event = new MouseEvent('mousedown', {bubbles: true, cancelable: true, composed: true});
|
|
66
|
+
item.dispatchEvent(event);
|
|
67
|
+
|
|
68
|
+
expect(selected).to.deep.equal(['Brand name']);
|
|
69
|
+
expect(event.defaultPrevented, 'mousedown is prevented so the field keeps focus').to.be.true;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('reports how many matches were not rendered', async () => {
|
|
73
|
+
const el = await fixture<ZnSlashMenu>(html`
|
|
74
|
+
<zn-slash-menu max-items="2"></zn-slash-menu>`);
|
|
75
|
+
el.items = [{label: 'One'}, {label: 'Two'}, {label: 'Three'}];
|
|
76
|
+
el.show();
|
|
77
|
+
await el.updateComplete;
|
|
78
|
+
|
|
79
|
+
expect(el.shadowRoot!.querySelectorAll('[data-slash-item]').length).to.equal(2);
|
|
80
|
+
expect(el.shadowRoot!.querySelector('[part="footer"]')?.textContent).to.contain('1 more');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('slash menu items', () => {
|
|
85
|
+
describe('parseSlashItems', () => {
|
|
86
|
+
it('parses a JSON array', () => {
|
|
87
|
+
const items = parseSlashItems('[{"label":"Brand name","value":"{{BRAND_NAME}}"}]');
|
|
88
|
+
|
|
89
|
+
expect(items).to.deep.equal([{label: 'Brand name', value: '{{BRAND_NAME}}'}]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('parses the label=value shorthand', () => {
|
|
93
|
+
const items = parseSlashItems('Brand name={{BRAND_NAME}}, {{SUPPORT_EMAIL}}');
|
|
94
|
+
|
|
95
|
+
expect(items).to.deep.equal([
|
|
96
|
+
{label: 'Brand name', value: '{{BRAND_NAME}}'},
|
|
97
|
+
{label: '{{SUPPORT_EMAIL}}', value: '{{SUPPORT_EMAIL}}'}
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns nothing for empty or unparsable input', () => {
|
|
102
|
+
expect(parseSlashItems('')).to.deep.equal([]);
|
|
103
|
+
expect(parseSlashItems(null)).to.deep.equal([]);
|
|
104
|
+
expect(parseSlashItems('[{"nope":true}')).to.deep.equal([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('filterSlashItems', () => {
|
|
109
|
+
const items = [
|
|
110
|
+
{label: 'Support email', value: '{{SUPPORT_EMAIL}}', keywords: 'contact'},
|
|
111
|
+
{label: 'Brand name', value: '{{BRAND_NAME}}', description: 'Trading name'},
|
|
112
|
+
{label: 'Jurisdiction', value: '{{BRAND_JURISDICTION}}'}
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
it('keeps declaration order for an empty query', () => {
|
|
116
|
+
expect(filterSlashItems(items, '').map(item => item.label))
|
|
117
|
+
.to.deep.equal(['Support email', 'Brand name', 'Jurisdiction']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('ranks label matches above value and keyword matches', () => {
|
|
121
|
+
expect(filterSlashItems(items, 'brand').map(item => item.label))
|
|
122
|
+
.to.deep.equal(['Brand name', 'Jurisdiction']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('matches keywords and descriptions', () => {
|
|
126
|
+
expect(filterSlashItems(items, 'contact').map(item => item.label)).to.deep.equal(['Support email']);
|
|
127
|
+
expect(filterSlashItems(items, 'trading').map(item => item.label)).to.deep.equal(['Brand name']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('honours order over declaration order', () => {
|
|
131
|
+
const ordered = filterSlashItems([{label: 'Second', order: 2}, {label: 'First', order: 1}], '');
|
|
132
|
+
|
|
133
|
+
expect(ordered.map(item => item.label)).to.deep.equal(['First', 'Second']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('drops items that match nothing', () => {
|
|
137
|
+
expect(filterSlashItems(items, 'nothing here')).to.deep.equal([]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('presets', () => {
|
|
142
|
+
it('resolves registered presets by name', () => {
|
|
143
|
+
registerSlashMenuPreset('test-legal', [{label: 'Brand name', value: '{{BRAND_NAME}}'}]);
|
|
144
|
+
registerSlashMenuPreset('test-support', [{label: 'Support email', value: '{{SUPPORT_EMAIL}}'}]);
|
|
145
|
+
|
|
146
|
+
expect(getSlashMenuPreset('test-legal, test-support').map(item => item.label))
|
|
147
|
+
.to.deep.equal(['Brand name', 'Support email']);
|
|
148
|
+
|
|
149
|
+
unregisterSlashMenuPreset('test-legal');
|
|
150
|
+
unregisterSlashMenuPreset('test-support');
|
|
151
|
+
expect(getSlashMenuPreset('test-legal')).to.deep.equal([]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|