@design.estate/dees-catalog 12.4.0 → 12.5.0
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_bundle/bundle.js +2455 -1133
- package/dist_bundle/bundle.js.map +1 -1
- package/dist_ts_web/00_commitinfo_data.js +3 -3
- package/dist_ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.d.ts +13 -0
- package/dist_ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.js +76 -7
- package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.d.ts +42 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.d.ts +1 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.js +8 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.js +347 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/document.d.ts +5 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/document.js +45 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/index.d.ts +2 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/index.js +3 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/interfaces.d.ts +16 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/interfaces.js +2 -0
- package/dist_ts_web/elements/00group-input/index.d.ts +1 -0
- package/dist_ts_web/elements/00group-input/index.js +2 -1
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.d.ts +58 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.d.ts +1 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.js +9 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.js +536 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.d.ts +20 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.js +157 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.d.ts +23 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.js +192 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.d.ts +24 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.js +211 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.d.ts +35 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.js +269 -0
- package/dist_ts_web/elements/00group-kanban/index.d.ts +6 -0
- package/dist_ts_web/elements/00group-kanban/index.js +7 -0
- package/dist_ts_web/elements/00group-kanban/interfaces.d.ts +84 -0
- package/dist_ts_web/elements/00group-kanban/interfaces.js +2 -0
- package/dist_ts_web/elements/00group-kanban/kanban.demodata.d.ts +7 -0
- package/dist_ts_web/elements/00group-kanban/kanban.demodata.js +60 -0
- package/dist_ts_web/elements/00group-kanban/kanban.helpers.d.ts +6 -0
- package/dist_ts_web/elements/00group-kanban/kanban.helpers.js +11 -0
- package/dist_ts_web/elements/00group-kanban/kanban.styles.d.ts +1 -0
- package/dist_ts_web/elements/00group-kanban/kanban.styles.js +18 -0
- package/dist_ts_web/elements/index.d.ts +1 -0
- package/dist_ts_web/elements/index.js +2 -1
- package/dist_ts_web/services/DeesServiceLibLoader.d.ts +1 -0
- package/dist_ts_web/services/DeesServiceLibLoader.js +2 -1
- package/package.json +1 -1
- package/readme.md +154 -0
- package/scripts/check-packed-consumer.mjs +1 -1
- package/ts_web/00_commitinfo_data.ts +3 -3
- package/ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.ts +40 -5
- package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.ts +9 -0
- package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.ts +221 -0
- package/ts_web/elements/00group-input/dees-input-reference/document.ts +38 -0
- package/ts_web/elements/00group-input/dees-input-reference/index.ts +2 -0
- package/ts_web/elements/00group-input/dees-input-reference/interfaces.ts +8 -0
- package/ts_web/elements/00group-input/index.ts +1 -0
- package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.ts +9 -0
- package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.ts +315 -0
- package/ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.ts +58 -0
- package/ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.ts +60 -0
- package/ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.ts +81 -0
- package/ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.ts +114 -0
- package/ts_web/elements/00group-kanban/index.ts +6 -0
- package/ts_web/elements/00group-kanban/interfaces.ts +67 -0
- package/ts_web/elements/00group-kanban/kanban.demodata.ts +56 -0
- package/ts_web/elements/00group-kanban/kanban.helpers.ts +13 -0
- package/ts_web/elements/00group-kanban/kanban.styles.ts +18 -0
- package/ts_web/elements/index.ts +1 -0
- package/ts_web/services/DeesServiceLibLoader.ts +2 -0
|
@@ -19,6 +19,9 @@ declare global {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export interface IDropdownSearchOption { key: string; option: string; payload?: unknown; }
|
|
23
|
+
export type TDropdownSearch = (query: string, signal: AbortSignal) => Promise<IDropdownSearchOption[]>;
|
|
24
|
+
|
|
22
25
|
@customElement('dees-input-dropdown-popup')
|
|
23
26
|
export class DeesInputDropdownPopup extends DeesElement {
|
|
24
27
|
@property({ type: Array })
|
|
@@ -39,6 +42,13 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
39
42
|
@property({ type: String })
|
|
40
43
|
accessor selectedKey = '';
|
|
41
44
|
|
|
45
|
+
/** Optional remote search. Results are authoritative and are not filtered again locally. */
|
|
46
|
+
@property({ attribute: false }) accessor searchOptions: TDropdownSearch | undefined;
|
|
47
|
+
@property({ type: String }) accessor searchLabel = 'Search options';
|
|
48
|
+
@state() accessor loading = false;
|
|
49
|
+
@state() accessor searchError = '';
|
|
50
|
+
private searchAbort?: AbortController;
|
|
51
|
+
|
|
42
52
|
@state()
|
|
43
53
|
accessor filteredOptions: { option: string; key: string; payload?: any }[] = [];
|
|
44
54
|
|
|
@@ -212,9 +222,9 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
212
222
|
<div class="search">
|
|
213
223
|
<input
|
|
214
224
|
type="text"
|
|
215
|
-
placeholder
|
|
225
|
+
placeholder=${this.searchLabel}
|
|
216
226
|
role="combobox"
|
|
217
|
-
aria-label
|
|
227
|
+
aria-label=${this.searchLabel}
|
|
218
228
|
aria-expanded="true"
|
|
219
229
|
aria-controls="dropdown-options"
|
|
220
230
|
aria-activedescendant=${this.filteredOptions.length ? `dropdown-option-${this.highlightedIndex}` : ''}
|
|
@@ -227,8 +237,8 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
227
237
|
: null}
|
|
228
238
|
<div class="options-container" id="dropdown-options" role="listbox" aria-label=${this.ownerComponent?.getAttribute('label') || 'Options'}
|
|
229
239
|
tabindex=${this.enableSearch ? '-1' : '0'} aria-activedescendant=${this.filteredOptions.length ? `dropdown-option-${this.highlightedIndex}` : ''}>
|
|
230
|
-
${this.filteredOptions.length === 0
|
|
231
|
-
? html`<div class="no-options"
|
|
240
|
+
${this.loading || this.searchError || this.filteredOptions.length === 0
|
|
241
|
+
? html`<div class="no-options" role="status">${this.loading ? 'Searching…' : this.searchError || 'No options found'}</div>`
|
|
232
242
|
: this.filteredOptions.map((option, index) => {
|
|
233
243
|
const isHighlighted = this.highlightedIndex === index;
|
|
234
244
|
return html`
|
|
@@ -263,6 +273,8 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
263
273
|
}
|
|
264
274
|
|
|
265
275
|
public async show(): Promise<void> {
|
|
276
|
+
this.loading = false;
|
|
277
|
+
this.searchError = '';
|
|
266
278
|
this.filteredOptions = this.options;
|
|
267
279
|
this.highlightedIndex = Math.max(0, this.options.findIndex((option) => option.key === this.selectedKey));
|
|
268
280
|
this.searchValue = '';
|
|
@@ -287,6 +299,7 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
287
299
|
// Add scroll/resize listeners for repositioning
|
|
288
300
|
window.addEventListener('scroll', this.handleScrollOrResize, { capture: true, passive: true });
|
|
289
301
|
window.addEventListener('resize', this.handleScrollOrResize, { passive: true });
|
|
302
|
+
if (this.searchOptions) void this.searchRemote();
|
|
290
303
|
}
|
|
291
304
|
|
|
292
305
|
public async hide(): Promise<void> {
|
|
@@ -295,6 +308,7 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
295
308
|
return;
|
|
296
309
|
}
|
|
297
310
|
this.isDestroying = true;
|
|
311
|
+
this.searchAbort?.abort();
|
|
298
312
|
|
|
299
313
|
// Remove scroll/resize listeners
|
|
300
314
|
window.removeEventListener('scroll', this.handleScrollOrResize, { capture: true } as EventListenerOptions);
|
|
@@ -331,6 +345,7 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
331
345
|
|
|
332
346
|
public updateOptions(options: { option: string; key: string; payload?: any }[]): void {
|
|
333
347
|
this.options = options;
|
|
348
|
+
if (this.searchOptions) return;
|
|
334
349
|
// Re-filter with current search value
|
|
335
350
|
if (this.searchValue) {
|
|
336
351
|
const searchLower = this.searchValue.toLowerCase();
|
|
@@ -354,6 +369,7 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
354
369
|
private handleSearch = (event: Event): void => {
|
|
355
370
|
const searchTerm = (event.target as HTMLInputElement).value;
|
|
356
371
|
this.searchValue = searchTerm;
|
|
372
|
+
if (this.searchOptions) { void this.searchRemote(); return; }
|
|
357
373
|
const searchLower = searchTerm.toLowerCase();
|
|
358
374
|
this.filteredOptions = this.options.filter((option) =>
|
|
359
375
|
option.option.toLowerCase().includes(searchLower)
|
|
@@ -361,7 +377,25 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
361
377
|
this.highlightedIndex = 0;
|
|
362
378
|
};
|
|
363
379
|
|
|
380
|
+
private async searchRemote(): Promise<void> {
|
|
381
|
+
this.searchAbort?.abort();
|
|
382
|
+
const controller = this.searchAbort = new AbortController();
|
|
383
|
+
this.loading = true;
|
|
384
|
+
this.searchError = '';
|
|
385
|
+
this.filteredOptions = [];
|
|
386
|
+
this.highlightedIndex = 0;
|
|
387
|
+
try {
|
|
388
|
+
const options = await this.searchOptions!(this.searchValue, controller.signal);
|
|
389
|
+
if (!controller.signal.aborted) this.filteredOptions = options;
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (!controller.signal.aborted) this.searchError = error instanceof Error ? error.message : 'Search failed. Try again.';
|
|
392
|
+
} finally {
|
|
393
|
+
if (!controller.signal.aborted) this.loading = false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
364
397
|
private handleSearchKeydown = (event: KeyboardEvent): void => {
|
|
398
|
+
if (event.isComposing) return;
|
|
365
399
|
const key = event.key;
|
|
366
400
|
const maxIndex = this.filteredOptions.length - 1;
|
|
367
401
|
|
|
@@ -370,7 +404,7 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
370
404
|
this.highlightedIndex = this.highlightedIndex + 1 > maxIndex ? 0 : this.highlightedIndex + 1;
|
|
371
405
|
} else if (key === 'ArrowUp') {
|
|
372
406
|
event.preventDefault();
|
|
373
|
-
this.highlightedIndex = this.highlightedIndex - 1 < 0 ? maxIndex : this.highlightedIndex - 1;
|
|
407
|
+
this.highlightedIndex = Math.max(0, this.highlightedIndex - 1 < 0 ? maxIndex : this.highlightedIndex - 1);
|
|
374
408
|
} else if (key === 'Enter') {
|
|
375
409
|
event.preventDefault();
|
|
376
410
|
if (this.filteredOptions[this.highlightedIndex]) {
|
|
@@ -391,6 +425,7 @@ export class DeesInputDropdownPopup extends DeesElement {
|
|
|
391
425
|
};
|
|
392
426
|
|
|
393
427
|
async disconnectedCallback() {
|
|
428
|
+
this.searchAbort?.abort();
|
|
394
429
|
window.removeEventListener('scroll', this.handleScrollOrResize, { capture: true } as EventListenerOptions);
|
|
395
430
|
window.removeEventListener('resize', this.handleScrollOrResize);
|
|
396
431
|
zIndexRegistry.unregister(this);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { html } from '@design.estate/dees-element';
|
|
2
|
+
import { demoPage, demoSection } from '../../../demos/templates.js';
|
|
3
|
+
|
|
4
|
+
export const demoFunc = () => demoPage('Reference composer', 'Type #_ to search at the cursor. Choose a ticket and continue writing.',
|
|
5
|
+
demoSection('Ticket references', 'References preserve IDs through editing, undo and copy/paste.', html`
|
|
6
|
+
<dees-input-reference .searchReferences=${async (query: string) => [
|
|
7
|
+
{ id: 'ticket-1', label: '#CAT-101 Keyboard navigation' }, { id: 'ticket-2', label: '#CAT-102 Ticket detail' },
|
|
8
|
+
].filter(item => item.label.toLowerCase().includes(query.toLowerCase()))}></dees-input-reference>
|
|
9
|
+
`));
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { DeesElement, customElement, property, state, html, css, cssManager } from '@design.estate/dees-element';
|
|
2
|
+
import type { Editor } from '@tiptap/core';
|
|
3
|
+
import { DeesServiceLibLoader } from '../../../services/DeesServiceLibLoader.js';
|
|
4
|
+
import { themeDefaultStyles } from '../../00theme.js';
|
|
5
|
+
import { DeesInputDropdownPopup } from '../dees-input-dropdown/dees-input-dropdown-popup.js';
|
|
6
|
+
import type { IInputReference, TReferenceContent, TReferenceSearch } from './interfaces.js';
|
|
7
|
+
import { referenceContent, referenceDocument } from './document.js';
|
|
8
|
+
import { demoFunc } from './dees-input-reference.demo.js';
|
|
9
|
+
|
|
10
|
+
declare global { interface HTMLElementTagNameMap { 'dees-input-reference': DeesInputReference; } }
|
|
11
|
+
|
|
12
|
+
@customElement('dees-input-reference')
|
|
13
|
+
export class DeesInputReference extends DeesElement {
|
|
14
|
+
public static demo = demoFunc;
|
|
15
|
+
public static demoGroups = ['Input', 'Kanban'];
|
|
16
|
+
@property({ attribute: false }) accessor value: TReferenceContent = [];
|
|
17
|
+
@property({ attribute: false }) accessor searchReferences: TReferenceSearch | undefined;
|
|
18
|
+
@property({ type: String }) accessor trigger = '#_';
|
|
19
|
+
@property({ type: String }) accessor label = 'Message';
|
|
20
|
+
@property({ type: String }) accessor placeholder = 'Write a comment…';
|
|
21
|
+
@property({ type: String }) accessor searchLabel = 'Search tickets';
|
|
22
|
+
/** Changing documents resets editor history, so undo cannot cross conversation boundaries. */
|
|
23
|
+
@property({ type: String }) accessor documentKey = '';
|
|
24
|
+
@property({ type: Boolean }) accessor disabled = false;
|
|
25
|
+
@state() private accessor loadError = '';
|
|
26
|
+
private editor?: Editor;
|
|
27
|
+
private initializing?: Promise<void>;
|
|
28
|
+
private connection = 0;
|
|
29
|
+
private popup?: DeesInputDropdownPopup;
|
|
30
|
+
private popupOpening?: Promise<void>;
|
|
31
|
+
private referenceRange?: { from: number; to: number };
|
|
32
|
+
private serializedValue = '';
|
|
33
|
+
|
|
34
|
+
public static styles = [themeDefaultStyles, cssManager.defaultStyles, css`
|
|
35
|
+
:host { display: block; min-width: 0; color: var(--dees-color-text-primary); font-family: var(--dees-font-family); }
|
|
36
|
+
.editor { border: 1px solid var(--dees-color-border-default); border-radius: var(--dees-radius-lg); background: var(--dees-color-bg-primary); }
|
|
37
|
+
.editor:focus-within { outline: 2px solid var(--dees-color-focus-ring); outline-offset: 1px; }
|
|
38
|
+
.ProseMirror { padding: 12px; min-height: 84px; max-height: 260px; overflow: auto; outline: none; white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--dees-font-body-size); line-height: 1.6; }
|
|
39
|
+
.ProseMirror p { margin: 0; }
|
|
40
|
+
.ProseMirror p + p { margin-top: 0; }
|
|
41
|
+
.ProseMirror:has(> p:only-child > br:only-child)::before { content: attr(data-placeholder); float: left; color: var(--dees-color-text-muted); pointer-events: none; height: 0; }
|
|
42
|
+
.ProseMirror [data-reference-id] { display: inline; padding: 1px 5px; border-radius: var(--dees-radius-sm); color: var(--dees-color-link); background: var(--dees-color-badge-default-bg); cursor: pointer; font-weight: 500; }
|
|
43
|
+
.ProseMirror [data-reference-id]:focus-visible, .ProseMirror .ProseMirror-selectednode { outline: 2px solid var(--dees-color-focus-ring); }
|
|
44
|
+
.ProseMirror[contenteditable=false] { opacity: .65; }
|
|
45
|
+
.error { color: var(--dees-color-text-error); font-size: var(--dees-font-footnote-size); }
|
|
46
|
+
`];
|
|
47
|
+
|
|
48
|
+
public render() {
|
|
49
|
+
return html`<div class="editor"></div>${this.loadError ? html`<p class="error" role="alert">${this.loadError}</p>` : ''}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public async connectedCallback(): Promise<void> {
|
|
53
|
+
const connection = super.connectedCallback();
|
|
54
|
+
this.connection++;
|
|
55
|
+
void this.initialize();
|
|
56
|
+
await connection;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolves after the lazy editor is mounted; also useful when focusing after opening a modal. */
|
|
60
|
+
public async ready(): Promise<void> {
|
|
61
|
+
await this.initialize();
|
|
62
|
+
if (!this.editor) throw new Error(this.loadError || 'Connect the reference editor before calling ready().');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private initialize(): Promise<void> {
|
|
66
|
+
if (this.editor) return Promise.resolve();
|
|
67
|
+
if (this.initializing) return this.initializing;
|
|
68
|
+
const connection = this.connection;
|
|
69
|
+
const operation = (async () => {
|
|
70
|
+
await this.updateComplete;
|
|
71
|
+
const { Editor, Node, StarterKit } = await DeesServiceLibLoader.getInstance().loadTiptap();
|
|
72
|
+
if (!this.isConnected || connection !== this.connection) return;
|
|
73
|
+
const referenceNode = Node.create({
|
|
74
|
+
name: 'catalogReference', group: 'inline', inline: true, atom: true, selectable: true,
|
|
75
|
+
addAttributes: () => ({
|
|
76
|
+
id: { default: '', parseHTML: element => element.getAttribute('data-reference-id') },
|
|
77
|
+
label: { default: '', parseHTML: element => element.textContent },
|
|
78
|
+
}),
|
|
79
|
+
parseHTML: () => [{ tag: 'span[data-reference-id]' }],
|
|
80
|
+
renderHTML: ({ node }) => ['span', { 'data-reference-id': String(node.attrs.id), contenteditable: 'false', role: 'link', tabindex: '0' }, String(node.attrs.label)],
|
|
81
|
+
renderText: ({ node }) => String(node.attrs.label),
|
|
82
|
+
});
|
|
83
|
+
this.editor = new Editor({
|
|
84
|
+
element: this.shadowRoot!.querySelector<HTMLElement>('.editor')!,
|
|
85
|
+
injectCSS: false,
|
|
86
|
+
editable: !this.disabled,
|
|
87
|
+
extensions: [StarterKit.configure({ blockquote: false, bold: false, bulletList: false, code: false, codeBlock: false, heading: false, horizontalRule: false, italic: false, listItem: false, orderedList: false, strike: false }), referenceNode],
|
|
88
|
+
content: referenceDocument(this.value),
|
|
89
|
+
editorProps: {
|
|
90
|
+
attributes: { role: 'textbox', 'aria-multiline': 'true', 'aria-label': this.label, 'data-placeholder': this.placeholder },
|
|
91
|
+
handleClickOn: (_view, _position, node, _nodePosition, event, direct) => {
|
|
92
|
+
if (!direct || node.type.name !== 'catalogReference') return false;
|
|
93
|
+
event.preventDefault(); this.openReference({ id: String(node.attrs.id), label: String(node.attrs.label) }); return true;
|
|
94
|
+
},
|
|
95
|
+
handleDOMEvents: {
|
|
96
|
+
keydown: (_view, event) => {
|
|
97
|
+
const target = event.target as HTMLElement;
|
|
98
|
+
if (event.key === 'Enter' && target.dataset.referenceId) {
|
|
99
|
+
event.preventDefault(); this.openReference({ id: target.dataset.referenceId, label: target.textContent ?? '' }); return true;
|
|
100
|
+
}
|
|
101
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter' && !event.isComposing && !this.disabled) {
|
|
102
|
+
event.preventDefault(); this.dispatchEvent(new CustomEvent('reference-submit', { bubbles: true, composed: true })); return true;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
},
|
|
106
|
+
compositionend: () => { queueMicrotask(() => this.detectTrigger()); return false; },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
onUpdate: ({ editor, transaction }) => {
|
|
110
|
+
this.value = referenceContent(editor.getJSON());
|
|
111
|
+
this.serializedValue = JSON.stringify(this.value);
|
|
112
|
+
this.dispatchEvent(new CustomEvent('reference-input', { detail: { value: this.value }, bubbles: true, composed: true }));
|
|
113
|
+
if (!transaction.getMeta('preventReferenceTrigger')) this.detectTrigger();
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
this.serializedValue = JSON.stringify(this.value);
|
|
117
|
+
this.loadError = '';
|
|
118
|
+
})().catch(error => { this.loadError = error instanceof Error ? error.message : 'Could not load the editor.'; })
|
|
119
|
+
.finally(() => { if (this.initializing === operation) this.initializing = undefined; });
|
|
120
|
+
this.initializing = operation;
|
|
121
|
+
return operation;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public updated(changed: Map<string, unknown>): void {
|
|
125
|
+
if (changed.has('documentKey') && changed.get('documentKey') !== undefined && this.editor) {
|
|
126
|
+
this.connection++;
|
|
127
|
+
void this.closePicker(false);
|
|
128
|
+
this.editor.destroy(); this.editor = undefined; this.initializing = undefined;
|
|
129
|
+
void this.initialize();
|
|
130
|
+
}
|
|
131
|
+
if (changed.has('disabled')) {
|
|
132
|
+
this.editor?.setEditable(!this.disabled, false);
|
|
133
|
+
if (this.disabled) void this.closePicker(false);
|
|
134
|
+
}
|
|
135
|
+
if (changed.has('value') && this.editor && JSON.stringify(this.value) !== this.serializedValue) {
|
|
136
|
+
void this.closePicker(false);
|
|
137
|
+
this.editor.commands.setContent(referenceDocument(this.value), false);
|
|
138
|
+
this.serializedValue = JSON.stringify(this.value);
|
|
139
|
+
}
|
|
140
|
+
if (changed.has('label') || changed.has('placeholder')) this.editor?.setOptions({ editorProps: {
|
|
141
|
+
...this.editor.options.editorProps,
|
|
142
|
+
attributes: { role: 'textbox', 'aria-multiline': 'true', 'aria-label': this.label, 'data-placeholder': this.placeholder },
|
|
143
|
+
} });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public focus(options?: FocusOptions): void { this.editor?.view.dom.focus(options); }
|
|
147
|
+
|
|
148
|
+
private detectTrigger(): void {
|
|
149
|
+
const editor = this.editor;
|
|
150
|
+
if (!editor || this.disabled || this.popup || !this.searchReferences || !this.trigger || editor.view.composing || !editor.isFocused) return;
|
|
151
|
+
const { from, to, $from } = editor.state.selection;
|
|
152
|
+
if (from !== to || $from.parentOffset < this.trigger.length) return;
|
|
153
|
+
if ($from.parent.textBetween($from.parentOffset - this.trigger.length, $from.parentOffset, '', '\ufffc') !== this.trigger) return;
|
|
154
|
+
this.referenceRange = { from: from - this.trigger.length, to };
|
|
155
|
+
void this.openPicker();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private positionPicker(): void {
|
|
159
|
+
if (!this.editor || !this.popup || !this.referenceRange) return;
|
|
160
|
+
const coords = this.editor.view.coordsAtPos(this.referenceRange.to);
|
|
161
|
+
this.popup.triggerRect = new DOMRect(coords.left, coords.top, 340, coords.bottom - coords.top);
|
|
162
|
+
this.popup.opensToTop = window.innerHeight - coords.bottom < 300 && coords.top > window.innerHeight - coords.bottom;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async openPicker(): Promise<void> {
|
|
166
|
+
const popup = this.popup = new DeesInputDropdownPopup();
|
|
167
|
+
popup.ownerComponent = this;
|
|
168
|
+
popup.searchLabel = this.searchLabel;
|
|
169
|
+
popup.searchOptions = async (query, signal) => (await this.searchReferences!(query, signal))
|
|
170
|
+
.map(reference => ({ key: reference.id, option: reference.label, payload: reference }));
|
|
171
|
+
this.positionPicker();
|
|
172
|
+
popup.addEventListener('reposition-request', () => this.positionPicker());
|
|
173
|
+
popup.addEventListener('close-request', () => { void this.closePicker(true); });
|
|
174
|
+
popup.addEventListener('keydown', (event: KeyboardEvent) => {
|
|
175
|
+
if (event.key === 'Tab') { event.preventDefault(); void this.closePicker(true); }
|
|
176
|
+
});
|
|
177
|
+
popup.addEventListener('option-selected', (event: Event) => {
|
|
178
|
+
const reference = (event as CustomEvent<{ payload: IInputReference }>).detail.payload;
|
|
179
|
+
const range = this.referenceRange;
|
|
180
|
+
if (range && this.editor && !this.disabled) {
|
|
181
|
+
this.editor.chain().setMeta('preventReferenceTrigger', true).insertContentAt(range, [
|
|
182
|
+
{ type: 'catalogReference', attrs: reference }, { type: 'text', text: ' ' },
|
|
183
|
+
]).run();
|
|
184
|
+
}
|
|
185
|
+
void this.closePicker(true);
|
|
186
|
+
});
|
|
187
|
+
this.popupOpening = popup.show();
|
|
188
|
+
try {
|
|
189
|
+
await this.popupOpening;
|
|
190
|
+
if (this.popup === popup && this.isConnected && !this.disabled) await popup.focusSearchInput();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.loadError = error instanceof Error ? error.message : 'Could not open search.';
|
|
193
|
+
void this.closePicker(false);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async closePicker(restoreFocus: boolean): Promise<void> {
|
|
198
|
+
const popup = this.popup;
|
|
199
|
+
const editor = this.editor;
|
|
200
|
+
const opening = this.popupOpening;
|
|
201
|
+
this.popup = undefined;
|
|
202
|
+
this.referenceRange = undefined;
|
|
203
|
+
if (!popup) return;
|
|
204
|
+
await opening?.catch(() => undefined);
|
|
205
|
+
await popup.hide();
|
|
206
|
+
if (restoreFocus && this.isConnected && !this.disabled && this.editor === editor) editor?.commands.focus();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private openReference(reference: IInputReference): void {
|
|
210
|
+
this.dispatchEvent(new CustomEvent('reference-open', { detail: reference, bubbles: true, composed: true }));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
public async disconnectedCallback(): Promise<void> {
|
|
214
|
+
this.connection++;
|
|
215
|
+
void this.closePicker(false);
|
|
216
|
+
this.editor?.destroy();
|
|
217
|
+
this.editor = undefined;
|
|
218
|
+
this.initializing = undefined;
|
|
219
|
+
await super.disconnectedCallback();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { JSONContent } from '@tiptap/core';
|
|
2
|
+
import type { TReferenceContent } from './interfaces.js';
|
|
3
|
+
|
|
4
|
+
/** The public value is editor-independent JSON: plain text (including newlines) and references. */
|
|
5
|
+
export const referenceDocument = (value: TReferenceContent): JSONContent => {
|
|
6
|
+
const paragraphs: JSONContent[] = [{ type: 'paragraph', content: [] }];
|
|
7
|
+
for (const part of value) {
|
|
8
|
+
if (part.type === 'reference') {
|
|
9
|
+
paragraphs.at(-1)!.content!.push({ type: 'catalogReference', attrs: { id: part.id, label: part.label } });
|
|
10
|
+
} else {
|
|
11
|
+
const lines = part.text.split('\n');
|
|
12
|
+
lines.forEach((text, index) => {
|
|
13
|
+
if (index) paragraphs.push({ type: 'paragraph', content: [] });
|
|
14
|
+
if (text) paragraphs.at(-1)!.content!.push({ type: 'text', text });
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { type: 'doc', content: paragraphs };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const referenceContent = (document: JSONContent): TReferenceContent => {
|
|
22
|
+
const parts: TReferenceContent = [];
|
|
23
|
+
const appendText = (text: string) => {
|
|
24
|
+
if (!text) return;
|
|
25
|
+
const last = parts.at(-1);
|
|
26
|
+
if (last?.type === 'text') last.text += text;
|
|
27
|
+
else parts.push({ type: 'text', text });
|
|
28
|
+
};
|
|
29
|
+
for (const [index, paragraph] of (document.content ?? []).entries()) {
|
|
30
|
+
if (index) appendText('\n');
|
|
31
|
+
for (const node of paragraph.content ?? []) {
|
|
32
|
+
if (node.type === 'catalogReference') parts.push({ type: 'reference', id: String(node.attrs!.id), label: String(node.attrs!.label) });
|
|
33
|
+
else if (node.type === 'hardBreak') appendText('\n');
|
|
34
|
+
else appendText(node.text ?? '');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parts;
|
|
38
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Stable reference identity; label is a human-readable snapshot, never the lookup key. */
|
|
2
|
+
export interface IInputReference { id: string; label: string; }
|
|
3
|
+
export type TReferencePart = { type: 'text'; text: string } | { type: 'reference'; id: string; label: string };
|
|
4
|
+
export type TReferenceContent = TReferencePart[];
|
|
5
|
+
export type TReferenceSearch = (query: string, signal: AbortSignal) => Promise<IInputReference[]>;
|
|
6
|
+
|
|
7
|
+
export const referenceText = (content: TReferenceContent): string =>
|
|
8
|
+
content.map(part => part.type === 'text' ? part.text : part.label).join('');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { html } from '@design.estate/dees-element';
|
|
2
|
+
import { demoApp } from '../../../demos/templates.js';
|
|
3
|
+
import { createKanbanDemo, kanbanDemoColumns, kanbanDemoPeople } from '../kanban.demodata.js';
|
|
4
|
+
|
|
5
|
+
export const demoFunc = () => {
|
|
6
|
+
const demo = createKanbanDemo();
|
|
7
|
+
return demoApp(html`<dees-kanban heading="Product workspace" .tickets=${demo.tickets} .columns=${kanbanDemoColumns}
|
|
8
|
+
.people=${kanbanDemoPeople} .provider=${demo.provider}></dees-kanban>`);
|
|
9
|
+
};
|