@kubex/zinc 1.1.79 → 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 +1095 -76
- package/dist/vscode.html-custom-data.json +186 -12
- package/dist/web-types.json +418 -24
- 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/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/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
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import '../../../dist/zn.min.js';
|
|
2
|
-
import { expect, fixture, html } from '@open-wc/testing';
|
|
2
|
+
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
|
3
3
|
import type ZnInlineEdit from './inline-edit.component';
|
|
4
4
|
import type ZnSelect from '../select/select.component';
|
|
5
|
+
import type ZnSlashMenu from '../slash-menu/slash-menu.component';
|
|
6
|
+
import type ZnTextarea from '../textarea/textarea.component';
|
|
7
|
+
|
|
8
|
+
// The auto-resizing textarea's ResizeObserver can emit a benign "loop completed with undelivered
|
|
9
|
+
// notifications" warning while the slash menu is positioned. It's not a real error — ignore it so the
|
|
10
|
+
// test runner doesn't treat it as an uncaught exception (capture phase runs before the runner's).
|
|
11
|
+
window.addEventListener('error', (e: ErrorEvent) => {
|
|
12
|
+
if (typeof e.message === 'string' && e.message.includes('ResizeObserver loop')) {
|
|
13
|
+
e.stopImmediatePropagation();
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
}
|
|
16
|
+
}, true);
|
|
5
17
|
|
|
6
18
|
describe('<zn-inline-edit>', () => {
|
|
7
19
|
it('should render a component', async () => {
|
|
@@ -483,4 +495,74 @@ describe('<zn-inline-edit>', () => {
|
|
|
483
495
|
expect(el.shadowRoot!.querySelector('.ai--editing')).to.not.exist;
|
|
484
496
|
expect(el.value).to.equal('real@example.com');
|
|
485
497
|
});
|
|
498
|
+
|
|
499
|
+
// -- Slash menu --
|
|
500
|
+
|
|
501
|
+
describe('slash menu', () => {
|
|
502
|
+
async function openSlashMenu(el: ZnInlineEdit) {
|
|
503
|
+
await el.updateComplete;
|
|
504
|
+
|
|
505
|
+
el.shadowRoot!.querySelector<HTMLElement>('.ai__left')!
|
|
506
|
+
.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
|
|
507
|
+
await el.updateComplete;
|
|
508
|
+
|
|
509
|
+
const textarea = el.shadowRoot!.querySelector<ZnTextarea>('zn-textarea')!;
|
|
510
|
+
await textarea.updateComplete;
|
|
511
|
+
|
|
512
|
+
textarea.focus();
|
|
513
|
+
textarea.input.value = '/';
|
|
514
|
+
textarea.input.setSelectionRange(1, 1);
|
|
515
|
+
textarea.input.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true}));
|
|
516
|
+
|
|
517
|
+
const menuOf = () => textarea.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu');
|
|
518
|
+
await waitUntil(() => menuOf()?.open, 'the slash menu never opened');
|
|
519
|
+
|
|
520
|
+
return {textarea, menu: menuOf()!};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
it('offers its slash items on the inner textarea', async () => {
|
|
524
|
+
const el = await fixture<ZnInlineEdit>(html`
|
|
525
|
+
<zn-inline-edit input-type="textarea"
|
|
526
|
+
slash-items="Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}"></zn-inline-edit>`);
|
|
527
|
+
const {menu} = await openSlashMenu(el);
|
|
528
|
+
|
|
529
|
+
expect(menu.items.map(item => item.label)).to.deep.equal(['Brand name', 'Support email']);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('forwards a custom trigger', async () => {
|
|
533
|
+
const el = await fixture<ZnInlineEdit>(html`
|
|
534
|
+
<zn-inline-edit input-type="textarea" slash-trigger="{{"
|
|
535
|
+
slash-items="Brand name={{BRAND_NAME}}"></zn-inline-edit>`);
|
|
536
|
+
await el.updateComplete;
|
|
537
|
+
|
|
538
|
+
const textarea = el.shadowRoot!.querySelector<ZnTextarea>('zn-textarea')!;
|
|
539
|
+
expect(textarea.slashTrigger).to.equal('{{');
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('does not submit the form when Enter chooses an item', async () => {
|
|
543
|
+
const form = await fixture<HTMLFormElement>(html`
|
|
544
|
+
<form>
|
|
545
|
+
<zn-inline-edit input-type="textarea" slash-items="Brand name={{BRAND_NAME}}"></zn-inline-edit>
|
|
546
|
+
</form>`);
|
|
547
|
+
const el = form.querySelector<ZnInlineEdit>('zn-inline-edit')!;
|
|
548
|
+
|
|
549
|
+
let submits = 0;
|
|
550
|
+
form.addEventListener('submit', event => {
|
|
551
|
+
event.preventDefault();
|
|
552
|
+
submits++;
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const {textarea} = await openSlashMenu(el);
|
|
556
|
+
textarea.input.dispatchEvent(new KeyboardEvent('keydown', {
|
|
557
|
+
key: 'Enter',
|
|
558
|
+
bubbles: true,
|
|
559
|
+
composed: true,
|
|
560
|
+
cancelable: true
|
|
561
|
+
}));
|
|
562
|
+
await el.updateComplete;
|
|
563
|
+
|
|
564
|
+
expect(el.value, 'the token should have replaced the trigger').to.equal('{{BRAND_NAME}}');
|
|
565
|
+
expect(submits, 'Enter belongs to the open menu, not the form').to.equal(0);
|
|
566
|
+
});
|
|
567
|
+
});
|
|
486
568
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ZnSlashItem from './slash-item.component';
|
|
2
|
+
|
|
3
|
+
export * from './slash-item.component';
|
|
4
|
+
export default ZnSlashItem;
|
|
5
|
+
|
|
6
|
+
ZnSlashItem.define('zn-slash-item');
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface HTMLElementTagNameMap {
|
|
10
|
+
'zn-slash-item': ZnSlashItem;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {html, unsafeCSS} from 'lit';
|
|
2
|
+
import {property} from 'lit/decorators.js';
|
|
3
|
+
import ZincElement from '../../internal/zinc-element';
|
|
4
|
+
import type {CSSResultGroup} from 'lit';
|
|
5
|
+
import type {SlashMenuItem} from '../slash-menu/slash-menu-items';
|
|
6
|
+
|
|
7
|
+
import styles from './slash-item.scss';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @summary Declares a single insertion for a slash menu. Renders nothing itself — it describes an
|
|
11
|
+
* entry for the component it is slotted into, e.g. `<zn-textarea>`'s `slash-items` slot.
|
|
12
|
+
* @documentation https://zinc.style/components/slash-item
|
|
13
|
+
* @status experimental
|
|
14
|
+
* @since 1.1
|
|
15
|
+
*
|
|
16
|
+
* @slot - The text to insert, for values that are long or span multiple lines. Ignored when the
|
|
17
|
+
* `value` attribute is set.
|
|
18
|
+
*/
|
|
19
|
+
export default class ZnSlashItem extends ZincElement {
|
|
20
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
21
|
+
|
|
22
|
+
/** The text shown in the menu. */
|
|
23
|
+
@property() label = '';
|
|
24
|
+
|
|
25
|
+
/** The text inserted into the field. Falls back to this element's text content. */
|
|
26
|
+
@property() value: string;
|
|
27
|
+
|
|
28
|
+
/** Icon shown against the item, e.g. `tag@lu`. */
|
|
29
|
+
@property() icon: string;
|
|
30
|
+
|
|
31
|
+
/** Supporting text shown under the label. */
|
|
32
|
+
@property() description: string;
|
|
33
|
+
|
|
34
|
+
/** Extra terms the item can be found by, comma separated. */
|
|
35
|
+
@property() keywords: string;
|
|
36
|
+
|
|
37
|
+
/** Heading the item is listed under. */
|
|
38
|
+
@property() group: string;
|
|
39
|
+
|
|
40
|
+
/** Overrides the item's position in the menu. Lower sorts first. */
|
|
41
|
+
@property({type: Number}) order: number;
|
|
42
|
+
|
|
43
|
+
/** Where the caret lands after insertion, as an offset into the inserted value. */
|
|
44
|
+
@property({attribute: 'caret-offset', type: Number}) caretOffset: number;
|
|
45
|
+
|
|
46
|
+
/** Identifier passed through on `zn-slash-select`, for items that do something other than insert. */
|
|
47
|
+
@property() action: string;
|
|
48
|
+
|
|
49
|
+
/** Listed, but not selectable. */
|
|
50
|
+
@property({type: Boolean, reflect: true}) disabled = false;
|
|
51
|
+
|
|
52
|
+
/** The item as the slash menu consumes it. */
|
|
53
|
+
toSlashMenuItem(): SlashMenuItem {
|
|
54
|
+
return {
|
|
55
|
+
label: this.label || this.insertValue,
|
|
56
|
+
value: this.insertValue,
|
|
57
|
+
icon: this.icon,
|
|
58
|
+
description: this.description,
|
|
59
|
+
keywords: this.keywords,
|
|
60
|
+
group: this.group,
|
|
61
|
+
order: this.order,
|
|
62
|
+
action: this.action,
|
|
63
|
+
caretOffset: this.caretOffset,
|
|
64
|
+
disabled: this.disabled
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private get insertValue(): string {
|
|
69
|
+
return this.value ?? (this.textContent ?? '').trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
render() {
|
|
73
|
+
return html`
|
|
74
|
+
<slot></slot>`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import ZnSlashMenu from './slash-menu.component';
|
|
2
|
+
|
|
3
|
+
export * from './slash-menu.component';
|
|
4
|
+
export * from './slash-menu-controller';
|
|
5
|
+
export * from './slash-menu-items';
|
|
6
|
+
export default ZnSlashMenu;
|
|
7
|
+
|
|
8
|
+
ZnSlashMenu.define('zn-slash-menu');
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
interface HTMLElementTagNameMap {
|
|
12
|
+
'zn-slash-menu': ZnSlashMenu;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import {caretRectFrom, getCaretCoordinates} from '../../utilities/caret-position';
|
|
2
|
+
import {filterSlashItems} from './slash-menu-items';
|
|
3
|
+
import {SLASH_ITEM_SELECT} from './slash-menu.component';
|
|
4
|
+
import type {CaretCoordinates, TextField} from '../../utilities/caret-position';
|
|
5
|
+
import type {ReactiveController, ReactiveControllerHost} from 'lit';
|
|
6
|
+
import type {SlashMenuItem} from './slash-menu-items';
|
|
7
|
+
import type {VirtualElement} from '../popup';
|
|
8
|
+
import type ZnSlashMenu from './slash-menu.component';
|
|
9
|
+
|
|
10
|
+
export interface SlashMenuControllerOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the menu to render results into. Called the first time the menu is needed, so the host
|
|
13
|
+
* can render it lazily; may return a promise (e.g. after awaiting `updateComplete`).
|
|
14
|
+
*/
|
|
15
|
+
menu: () => ZnSlashMenu | null | Promise<ZnSlashMenu | null>;
|
|
16
|
+
/** The available items, unfiltered. Receives the current query so lists can be resolved remotely. */
|
|
17
|
+
items: (query: string) => SlashMenuItem[] | Promise<SlashMenuItem[]>;
|
|
18
|
+
/** The characters that open the menu. Defaults to `/`. */
|
|
19
|
+
trigger?: () => string;
|
|
20
|
+
/** Called before an item is inserted. Return `false` to handle the item yourself. */
|
|
21
|
+
onSelect?: (item: SlashMenuItem, query: string) => boolean;
|
|
22
|
+
/** Called after an item's value has been written into the field. */
|
|
23
|
+
onInsert?: (item: SlashMenuItem, value: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Queries longer than this are treated as prose the user never meant as a menu search. */
|
|
27
|
+
const MAX_QUERY_LENGTH = 40;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Drives a slash menu for a plain `<textarea>` or `<input>`: watches the caret for the trigger
|
|
31
|
+
* sequence, resolves and filters items, and inserts the chosen value.
|
|
32
|
+
*
|
|
33
|
+
* The host owns the field and the menu element; this controller owns the interaction.
|
|
34
|
+
*/
|
|
35
|
+
export class SlashMenuController implements ReactiveController {
|
|
36
|
+
private readonly host: ReactiveControllerHost & HTMLElement;
|
|
37
|
+
private readonly options: SlashMenuControllerOptions;
|
|
38
|
+
private readonly caretAnchor: VirtualElement = {
|
|
39
|
+
getBoundingClientRect: () => this.caretRect()
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
private field: TextField | null = null;
|
|
43
|
+
private menu: ZnSlashMenu | null = null;
|
|
44
|
+
private triggerIndex = -1;
|
|
45
|
+
private query = '';
|
|
46
|
+
/** Trigger position the user dismissed with Escape; the menu stays shut until they move off it. */
|
|
47
|
+
private dismissedIndex = -1;
|
|
48
|
+
private resolveToken = 0;
|
|
49
|
+
private inserting = false;
|
|
50
|
+
private isOpen = false;
|
|
51
|
+
private listening = false;
|
|
52
|
+
/** Caret measurement is the expensive part of positioning, so the last one is reused. */
|
|
53
|
+
private measured?: {value: string; index: number; width: number; coordinates: CaretCoordinates};
|
|
54
|
+
|
|
55
|
+
constructor(host: ReactiveControllerHost & HTMLElement, options: SlashMenuControllerOptions) {
|
|
56
|
+
this.host = host;
|
|
57
|
+
this.options = options;
|
|
58
|
+
host.addController(this);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether the menu is currently showing. */
|
|
62
|
+
get open(): boolean {
|
|
63
|
+
return this.isOpen;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
hostConnected() {
|
|
67
|
+
this.addListeners();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
hostDisconnected() {
|
|
71
|
+
// The field is kept, so reconnecting the host resumes where it left off
|
|
72
|
+
this.removeListeners();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Starts watching a field. Safe to call repeatedly with the same field. */
|
|
76
|
+
attach(field: TextField) {
|
|
77
|
+
if (this.field === field && this.listening) return;
|
|
78
|
+
|
|
79
|
+
this.removeListeners();
|
|
80
|
+
this.field = field;
|
|
81
|
+
this.caretAnchor.contextElement = field;
|
|
82
|
+
this.addListeners();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Stops watching the current field and closes the menu. */
|
|
86
|
+
detach() {
|
|
87
|
+
this.removeListeners();
|
|
88
|
+
this.field = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private addListeners() {
|
|
92
|
+
const field = this.field;
|
|
93
|
+
if (!field || this.listening) return;
|
|
94
|
+
|
|
95
|
+
// Capture phase, so menu navigation keys are claimed before the host's own key handling
|
|
96
|
+
field.addEventListener('keydown', this.handleKeyDown, {capture: true});
|
|
97
|
+
field.addEventListener('input', this.handleInput);
|
|
98
|
+
field.addEventListener('keyup', this.handleKeyUp);
|
|
99
|
+
field.addEventListener('click', this.handleCaretMove);
|
|
100
|
+
field.addEventListener('blur', this.handleBlur);
|
|
101
|
+
field.addEventListener('scroll', this.handleScroll);
|
|
102
|
+
this.listening = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private removeListeners() {
|
|
106
|
+
const field = this.field;
|
|
107
|
+
if (!field || !this.listening) return;
|
|
108
|
+
|
|
109
|
+
this.close();
|
|
110
|
+
field.removeEventListener('keydown', this.handleKeyDown, {capture: true});
|
|
111
|
+
field.removeEventListener('input', this.handleInput);
|
|
112
|
+
field.removeEventListener('keyup', this.handleKeyUp);
|
|
113
|
+
field.removeEventListener('click', this.handleCaretMove);
|
|
114
|
+
field.removeEventListener('blur', this.handleBlur);
|
|
115
|
+
field.removeEventListener('scroll', this.handleScroll);
|
|
116
|
+
this.listening = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Closes the menu without marking the trigger as dismissed. */
|
|
120
|
+
close() {
|
|
121
|
+
this.resolveToken++;
|
|
122
|
+
this.triggerIndex = -1;
|
|
123
|
+
this.query = '';
|
|
124
|
+
|
|
125
|
+
if (this.isOpen) {
|
|
126
|
+
this.isOpen = false;
|
|
127
|
+
this.menu?.hide();
|
|
128
|
+
this.field?.setAttribute('aria-expanded', 'false');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Opens the menu at the caret, as a toolbar button or keyboard shortcut would. */
|
|
133
|
+
requestOpen() {
|
|
134
|
+
this.dismissedIndex = -1;
|
|
135
|
+
this.detect();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private caretRect(): DOMRect {
|
|
139
|
+
const field = this.field;
|
|
140
|
+
if (!field) return new DOMRect();
|
|
141
|
+
|
|
142
|
+
const index = this.triggerIndex >= 0 ? this.triggerIndex : (field.selectionStart ?? 0);
|
|
143
|
+
const width = field.clientWidth;
|
|
144
|
+
const cached = this.measured;
|
|
145
|
+
|
|
146
|
+
if (!cached || cached.index !== index || cached.width !== width || cached.value !== field.value) {
|
|
147
|
+
this.measured = {value: field.value, index, width, coordinates: getCaretCoordinates(field, index)};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return caretRectFrom(field, this.measured!.coordinates);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private readonly handleInput = () => this.detect();
|
|
154
|
+
|
|
155
|
+
private readonly handleCaretMove = () => this.detect();
|
|
156
|
+
|
|
157
|
+
private readonly handleKeyUp = (event: KeyboardEvent) => {
|
|
158
|
+
// The vertical arrows belong to the open menu, and its keydown handler stopped them from moving
|
|
159
|
+
// the caret — re-detecting here would rebuild the list and drop the user's place in it
|
|
160
|
+
if (this.isOpen && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) return;
|
|
161
|
+
|
|
162
|
+
// Typing is covered by `input`; this catches caret moves that don't change the value
|
|
163
|
+
if (event.key.startsWith('Arrow') || event.key === 'Home' || event.key === 'End') {
|
|
164
|
+
this.detect();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
private readonly handleBlur = () => this.close();
|
|
169
|
+
|
|
170
|
+
private readonly handleScroll = () => {
|
|
171
|
+
if (this.isOpen) this.menu?.reposition();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
private readonly handleKeyDown = (event: KeyboardEvent) => {
|
|
175
|
+
if (!this.isOpen || event.isComposing) return;
|
|
176
|
+
|
|
177
|
+
const claim = () => {
|
|
178
|
+
event.preventDefault();
|
|
179
|
+
event.stopPropagation();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
switch (event.key) {
|
|
183
|
+
case 'Escape':
|
|
184
|
+
claim();
|
|
185
|
+
this.dismissedIndex = this.triggerIndex;
|
|
186
|
+
this.close();
|
|
187
|
+
return;
|
|
188
|
+
|
|
189
|
+
case 'ArrowDown':
|
|
190
|
+
claim();
|
|
191
|
+
this.menu?.moveActive(1);
|
|
192
|
+
return;
|
|
193
|
+
|
|
194
|
+
case 'ArrowUp':
|
|
195
|
+
claim();
|
|
196
|
+
this.menu?.moveActive(-1);
|
|
197
|
+
return;
|
|
198
|
+
|
|
199
|
+
case 'Enter':
|
|
200
|
+
case 'Tab': {
|
|
201
|
+
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
202
|
+
|
|
203
|
+
const item = this.menu?.activeItem;
|
|
204
|
+
if (!item) {
|
|
205
|
+
this.close();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
claim();
|
|
210
|
+
this.select(item);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
private readonly handleItemSelect = (event: Event) => {
|
|
216
|
+
const {item} = (event as CustomEvent<{item: SlashMenuItem}>).detail;
|
|
217
|
+
if (item) this.select(item);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
private detect() {
|
|
221
|
+
if (this.inserting) return;
|
|
222
|
+
|
|
223
|
+
const field = this.field;
|
|
224
|
+
if (!field || field.disabled || field.readOnly) {
|
|
225
|
+
this.close();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const trigger = this.options.trigger?.() || '/';
|
|
230
|
+
const caret = field.selectionStart;
|
|
231
|
+
// Only a collapsed caret opens the menu — a selection means the user is doing something else
|
|
232
|
+
if (!trigger || caret === null || field.selectionEnd !== caret) {
|
|
233
|
+
this.close();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const value = field.value;
|
|
238
|
+
const index = value.lastIndexOf(trigger, Math.max(0, caret - trigger.length));
|
|
239
|
+
if (index === -1 || index + trigger.length > caret) {
|
|
240
|
+
this.close();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The trigger only counts at the start of a word
|
|
245
|
+
const preceding = index > 0 ? value.charAt(index - 1) : '';
|
|
246
|
+
if (preceding !== '' && !/\s/.test(preceding)) {
|
|
247
|
+
this.close();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const query = value.slice(index + trigger.length, caret);
|
|
252
|
+
if (query.length > MAX_QUERY_LENGTH || /[\r\n]/.test(query)) {
|
|
253
|
+
this.close();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (index === this.dismissedIndex) {
|
|
258
|
+
this.close();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.triggerIndex = index;
|
|
263
|
+
this.query = query;
|
|
264
|
+
void this.resolve(query);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async resolve(query: string) {
|
|
268
|
+
const token = ++this.resolveToken;
|
|
269
|
+
|
|
270
|
+
let items: SlashMenuItem[] = [];
|
|
271
|
+
try {
|
|
272
|
+
items = await this.options.items(query);
|
|
273
|
+
} catch (error: unknown) {
|
|
274
|
+
console.warn('slash menu items could not be resolved', error);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (token !== this.resolveToken) return;
|
|
278
|
+
|
|
279
|
+
const matches = filterSlashItems(items, query);
|
|
280
|
+
if (!matches.length) {
|
|
281
|
+
this.close();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const menu = await this.options.menu();
|
|
286
|
+
if (!menu || token !== this.resolveToken) return;
|
|
287
|
+
|
|
288
|
+
if (menu !== this.menu) {
|
|
289
|
+
this.menu?.removeEventListener(SLASH_ITEM_SELECT, this.handleItemSelect);
|
|
290
|
+
menu.addEventListener(SLASH_ITEM_SELECT, this.handleItemSelect);
|
|
291
|
+
this.menu = menu;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
menu.anchor = this.caretAnchor;
|
|
295
|
+
menu.query = query;
|
|
296
|
+
menu.items = matches;
|
|
297
|
+
|
|
298
|
+
if (!this.isOpen) {
|
|
299
|
+
this.isOpen = true;
|
|
300
|
+
this.dismissedIndex = -1;
|
|
301
|
+
menu.show();
|
|
302
|
+
this.field?.setAttribute('aria-expanded', 'true');
|
|
303
|
+
this.field?.setAttribute('aria-haspopup', 'listbox');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
await menu.updateComplete;
|
|
307
|
+
if (this.isOpen) menu.reposition();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private select(item: SlashMenuItem) {
|
|
311
|
+
const field = this.field;
|
|
312
|
+
const start = this.triggerIndex;
|
|
313
|
+
const end = field?.selectionStart ?? start;
|
|
314
|
+
const query = this.query;
|
|
315
|
+
const trigger = this.options.trigger?.() || '/';
|
|
316
|
+
|
|
317
|
+
if (item.disabled) return;
|
|
318
|
+
|
|
319
|
+
this.close();
|
|
320
|
+
if (!field || start < 0) return;
|
|
321
|
+
|
|
322
|
+
const cancelled = this.options.onSelect?.(item, query) === false;
|
|
323
|
+
const value = cancelled ? '' : (item.value ?? '');
|
|
324
|
+
|
|
325
|
+
// A handler may have rewritten the field already; only touch text we still recognise as ours
|
|
326
|
+
if (field.value.slice(start, end) !== trigger + query) return;
|
|
327
|
+
|
|
328
|
+
// The trigger and query are a command rather than content, so they go either way
|
|
329
|
+
this.replace(field, start, end, value, item);
|
|
330
|
+
|
|
331
|
+
if (!cancelled && value) this.options.onInsert?.(item, value);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private replace(field: TextField, start: number, end: number, value: string, item: SlashMenuItem) {
|
|
335
|
+
this.inserting = true;
|
|
336
|
+
try {
|
|
337
|
+
field.focus({preventScroll: true});
|
|
338
|
+
field.setSelectionRange(start, end);
|
|
339
|
+
|
|
340
|
+
// execCommand keeps the field's native undo history intact; setRangeText does not
|
|
341
|
+
let handled = false;
|
|
342
|
+
try {
|
|
343
|
+
handled = field.ownerDocument.execCommand('insertText', false, value);
|
|
344
|
+
} catch {
|
|
345
|
+
handled = false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!handled) {
|
|
349
|
+
field.setRangeText(value, start, end, 'end');
|
|
350
|
+
field.dispatchEvent(new Event('input', {bubbles: true, composed: true}));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const offset = Math.min(Math.max(item.caretOffset ?? value.length, 0), value.length);
|
|
354
|
+
field.setSelectionRange(start + offset, start + offset);
|
|
355
|
+
} finally {
|
|
356
|
+
this.inserting = false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
this.host.requestUpdate();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -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
|
+
}
|