@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.
Files changed (67) hide show
  1. package/dist_bundle/bundle.js +2455 -1133
  2. package/dist_bundle/bundle.js.map +1 -1
  3. package/dist_ts_web/00_commitinfo_data.js +3 -3
  4. package/dist_ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.d.ts +13 -0
  5. package/dist_ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.js +76 -7
  6. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.d.ts +42 -0
  7. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.d.ts +1 -0
  8. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.js +8 -0
  9. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.js +347 -0
  10. package/dist_ts_web/elements/00group-input/dees-input-reference/document.d.ts +5 -0
  11. package/dist_ts_web/elements/00group-input/dees-input-reference/document.js +45 -0
  12. package/dist_ts_web/elements/00group-input/dees-input-reference/index.d.ts +2 -0
  13. package/dist_ts_web/elements/00group-input/dees-input-reference/index.js +3 -0
  14. package/dist_ts_web/elements/00group-input/dees-input-reference/interfaces.d.ts +16 -0
  15. package/dist_ts_web/elements/00group-input/dees-input-reference/interfaces.js +2 -0
  16. package/dist_ts_web/elements/00group-input/index.d.ts +1 -0
  17. package/dist_ts_web/elements/00group-input/index.js +2 -1
  18. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.d.ts +58 -0
  19. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.d.ts +1 -0
  20. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.js +9 -0
  21. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.js +536 -0
  22. package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.d.ts +20 -0
  23. package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.js +157 -0
  24. package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.d.ts +23 -0
  25. package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.js +192 -0
  26. package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.d.ts +24 -0
  27. package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.js +211 -0
  28. package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.d.ts +35 -0
  29. package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.js +269 -0
  30. package/dist_ts_web/elements/00group-kanban/index.d.ts +6 -0
  31. package/dist_ts_web/elements/00group-kanban/index.js +7 -0
  32. package/dist_ts_web/elements/00group-kanban/interfaces.d.ts +84 -0
  33. package/dist_ts_web/elements/00group-kanban/interfaces.js +2 -0
  34. package/dist_ts_web/elements/00group-kanban/kanban.demodata.d.ts +7 -0
  35. package/dist_ts_web/elements/00group-kanban/kanban.demodata.js +60 -0
  36. package/dist_ts_web/elements/00group-kanban/kanban.helpers.d.ts +6 -0
  37. package/dist_ts_web/elements/00group-kanban/kanban.helpers.js +11 -0
  38. package/dist_ts_web/elements/00group-kanban/kanban.styles.d.ts +1 -0
  39. package/dist_ts_web/elements/00group-kanban/kanban.styles.js +18 -0
  40. package/dist_ts_web/elements/index.d.ts +1 -0
  41. package/dist_ts_web/elements/index.js +2 -1
  42. package/dist_ts_web/services/DeesServiceLibLoader.d.ts +1 -0
  43. package/dist_ts_web/services/DeesServiceLibLoader.js +2 -1
  44. package/package.json +1 -1
  45. package/readme.md +154 -0
  46. package/scripts/check-packed-consumer.mjs +1 -1
  47. package/ts_web/00_commitinfo_data.ts +3 -3
  48. package/ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.ts +40 -5
  49. package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.ts +9 -0
  50. package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.ts +221 -0
  51. package/ts_web/elements/00group-input/dees-input-reference/document.ts +38 -0
  52. package/ts_web/elements/00group-input/dees-input-reference/index.ts +2 -0
  53. package/ts_web/elements/00group-input/dees-input-reference/interfaces.ts +8 -0
  54. package/ts_web/elements/00group-input/index.ts +1 -0
  55. package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.ts +9 -0
  56. package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.ts +315 -0
  57. package/ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.ts +58 -0
  58. package/ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.ts +60 -0
  59. package/ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.ts +81 -0
  60. package/ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.ts +114 -0
  61. package/ts_web/elements/00group-kanban/index.ts +6 -0
  62. package/ts_web/elements/00group-kanban/interfaces.ts +67 -0
  63. package/ts_web/elements/00group-kanban/kanban.demodata.ts +56 -0
  64. package/ts_web/elements/00group-kanban/kanban.helpers.ts +13 -0
  65. package/ts_web/elements/00group-kanban/kanban.styles.ts +18 -0
  66. package/ts_web/elements/index.ts +1 -0
  67. 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="Search options..."
225
+ placeholder=${this.searchLabel}
216
226
  role="combobox"
217
- aria-label="Search options"
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">No options found</div>`
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,2 @@
1
+ export * from './interfaces.js';
2
+ export * from './dees-input-reference.js';
@@ -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('');
@@ -19,3 +19,4 @@ export * from './dees-input-toggle/index.js';
19
19
  export * from './dees-input-typelist/index.js';
20
20
  export * from './dees-input-wysiwyg.js';
21
21
  export * from './profilepicture/dees-input-profilepicture.js';
22
+ export * from './dees-input-reference/index.js';
@@ -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
+ };