@design.estate/dees-catalog 12.3.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 (81) hide show
  1. package/dist_bundle/bundle.js +2483 -1137
  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-appui/dees-appui-mainmenu/dees-appui-mainmenu.d.ts +5 -0
  5. package/dist_ts_web/elements/00group-form/dees-form/dees-form.d.ts +2 -1
  6. package/dist_ts_web/elements/00group-form/dees-form/dees-form.js +2 -1
  7. package/dist_ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.d.ts +13 -0
  8. package/dist_ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.js +76 -7
  9. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.d.ts +42 -0
  10. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.d.ts +1 -0
  11. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.js +8 -0
  12. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.js +347 -0
  13. package/dist_ts_web/elements/00group-input/dees-input-reference/document.d.ts +5 -0
  14. package/dist_ts_web/elements/00group-input/dees-input-reference/document.js +45 -0
  15. package/dist_ts_web/elements/00group-input/dees-input-reference/index.d.ts +2 -0
  16. package/dist_ts_web/elements/00group-input/dees-input-reference/index.js +3 -0
  17. package/dist_ts_web/elements/00group-input/dees-input-reference/interfaces.d.ts +16 -0
  18. package/dist_ts_web/elements/00group-input/dees-input-reference/interfaces.js +2 -0
  19. package/dist_ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.d.ts +12 -2
  20. package/dist_ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js +11 -1
  21. package/dist_ts_web/elements/00group-input/index.d.ts +1 -0
  22. package/dist_ts_web/elements/00group-input/index.js +2 -1
  23. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.d.ts +58 -0
  24. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.d.ts +1 -0
  25. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.js +9 -0
  26. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.js +536 -0
  27. package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.d.ts +20 -0
  28. package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.js +157 -0
  29. package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.d.ts +23 -0
  30. package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.js +192 -0
  31. package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.d.ts +24 -0
  32. package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.js +211 -0
  33. package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.d.ts +35 -0
  34. package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.js +269 -0
  35. package/dist_ts_web/elements/00group-kanban/index.d.ts +6 -0
  36. package/dist_ts_web/elements/00group-kanban/index.js +7 -0
  37. package/dist_ts_web/elements/00group-kanban/interfaces.d.ts +84 -0
  38. package/dist_ts_web/elements/00group-kanban/interfaces.js +2 -0
  39. package/dist_ts_web/elements/00group-kanban/kanban.demodata.d.ts +7 -0
  40. package/dist_ts_web/elements/00group-kanban/kanban.demodata.js +60 -0
  41. package/dist_ts_web/elements/00group-kanban/kanban.helpers.d.ts +6 -0
  42. package/dist_ts_web/elements/00group-kanban/kanban.helpers.js +11 -0
  43. package/dist_ts_web/elements/00group-kanban/kanban.styles.d.ts +1 -0
  44. package/dist_ts_web/elements/00group-kanban/kanban.styles.js +18 -0
  45. package/dist_ts_web/elements/00group-simple/dees-simple-appdash/dees-simple-appdash.js +7 -2
  46. package/dist_ts_web/elements/00group-workspace/dees-workspace/dees-workspace.js +3 -3
  47. package/dist_ts_web/elements/00group-workspace/dees-workspace/styles.js +10 -2
  48. package/dist_ts_web/elements/index.d.ts +1 -0
  49. package/dist_ts_web/elements/index.js +2 -1
  50. package/dist_ts_web/services/DeesServiceLibLoader.d.ts +1 -0
  51. package/dist_ts_web/services/DeesServiceLibLoader.js +2 -1
  52. package/package.json +1 -1
  53. package/readme.md +154 -0
  54. package/scripts/check-packed-consumer.mjs +1 -1
  55. package/ts_web/00_commitinfo_data.ts +3 -3
  56. package/ts_web/elements/00group-appui/dees-appui-mainmenu/dees-appui-mainmenu.ts +6 -0
  57. package/ts_web/elements/00group-form/dees-form/dees-form.ts +2 -1
  58. package/ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.ts +40 -5
  59. package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.ts +9 -0
  60. package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.ts +221 -0
  61. package/ts_web/elements/00group-input/dees-input-reference/document.ts +38 -0
  62. package/ts_web/elements/00group-input/dees-input-reference/index.ts +2 -0
  63. package/ts_web/elements/00group-input/dees-input-reference/interfaces.ts +8 -0
  64. package/ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.ts +11 -1
  65. package/ts_web/elements/00group-input/index.ts +1 -0
  66. package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.ts +9 -0
  67. package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.ts +315 -0
  68. package/ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.ts +58 -0
  69. package/ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.ts +60 -0
  70. package/ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.ts +81 -0
  71. package/ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.ts +114 -0
  72. package/ts_web/elements/00group-kanban/index.ts +6 -0
  73. package/ts_web/elements/00group-kanban/interfaces.ts +67 -0
  74. package/ts_web/elements/00group-kanban/kanban.demodata.ts +56 -0
  75. package/ts_web/elements/00group-kanban/kanban.helpers.ts +13 -0
  76. package/ts_web/elements/00group-kanban/kanban.styles.ts +18 -0
  77. package/ts_web/elements/00group-simple/dees-simple-appdash/dees-simple-appdash.ts +6 -1
  78. package/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts +2 -2
  79. package/ts_web/elements/00group-workspace/dees-workspace/styles.ts +9 -1
  80. package/ts_web/elements/index.ts +1 -0
  81. package/ts_web/services/DeesServiceLibLoader.ts +2 -0
@@ -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('');
@@ -85,7 +85,17 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
85
85
  public blockOperations: WysiwygBlockOperations;
86
86
  private inputHandler: WysiwygInputHandler;
87
87
  private keyboardHandler: WysiwygKeyboardHandler;
88
- private dragDropHandler: WysiwygDragDropHandler;
88
+ /**
89
+ * Block drag and drop, public alongside `blockOperations` because the drag *interaction* has
90
+ * no other seam: `dragState`, the drop indicator and the global drag-over listener all live in
91
+ * this handler, and a native HTML5 drag sequence cannot be synthesised from script, so a host
92
+ * or a test that exercises dragging has to drive `handleDragStart` / `handleDragEnd` itself.
93
+ * Reordering a block programmatically needs none of that: `blockOperations.moveBlock(blockId,
94
+ * targetIndex)` is the public path for that, on the public `blocks` accessor. The remaining
95
+ * handlers stay private because everything they own is reachable through ordinary input and
96
+ * keyboard events.
97
+ */
98
+ public dragDropHandler: WysiwygDragDropHandler;
89
99
  private history: WysiwygHistory;
90
100
 
91
101
  public static styles = [
@@ -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
+ };
@@ -0,0 +1,315 @@
1
+ import { DeesElement, customElement, property, state, html, css } from '@design.estate/dees-element';
2
+ import type { IKanbanColumn, IKanbanComment, IKanbanCreate, IKanbanMove, IKanbanPerson, IKanbanProvider, IKanbanTicket, IKanbanTicketDraft } from '../interfaces.js';
3
+ import type { TReferenceContent, TReferenceSearch } from '../../00group-input/dees-input-reference/interfaces.js';
4
+ import type { DeesInputText } from '../../00group-input/dees-input-text/dees-input-text.js';
5
+ import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
6
+ import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
7
+ import { DeesKanbanTicketDetail } from '../dees-kanban-ticket-detail/dees-kanban-ticket-detail.js';
8
+ import { kanbanStyles } from '../kanban.styles.js';
9
+ import { kanbanError, kanbanEvent, sortKanbanTickets, ticketLabel } from '../kanban.helpers.js';
10
+ import { demoFunc } from './dees-kanban.demo.js';
11
+ import '../dees-kanban-column/dees-kanban-column.js';
12
+
13
+ declare global { interface HTMLElementTagNameMap { 'dees-kanban': DeesKanban; } }
14
+
15
+ const interactionEvents = ['kanban-ticket-open', 'kanban-ticket-move', 'kanban-ticket-create', 'kanban-move-menu',
16
+ 'kanban-ticket-save', 'kanban-ticket-draft', 'kanban-comment-draft', 'kanban-comment-added', 'kanban-comments-refresh',
17
+ 'kanban-detail-back', 'kanban-drag-start', 'kanban-drag-end'] as const;
18
+
19
+ interface IKanbanInteractionMap {
20
+ 'kanban-ticket-open': { ticketId: string };
21
+ 'kanban-ticket-move': IKanbanMove;
22
+ 'kanban-ticket-create': Omit<IKanbanCreate, 'title'>;
23
+ 'kanban-move-menu': { ticketId: string; anchor: HTMLElement };
24
+ 'kanban-ticket-save': { ticketId: string; draft: IKanbanTicketDraft };
25
+ 'kanban-ticket-draft': { ticketId: string; draft?: IKanbanTicketDraft };
26
+ 'kanban-comment-draft': { ticketId: string; body: TReferenceContent };
27
+ 'kanban-comment-added': { comment: IKanbanComment; submittedBody: TReferenceContent };
28
+ 'kanban-comments-refresh': { ticketId: string };
29
+ 'kanban-detail-back': Record<string, never>;
30
+ 'kanban-drag-start': { ticketId: string };
31
+ 'kanban-drag-end': Record<string, never>;
32
+ }
33
+ type TKanbanInteraction = { [K in keyof IKanbanInteractionMap]: { type: K; detail: IKanbanInteractionMap[K] } }[keyof IKanbanInteractionMap];
34
+
35
+ @customElement('dees-kanban')
36
+ export class DeesKanban extends DeesElement {
37
+ public static demo = demoFunc;
38
+ public static demoGroups = ['Kanban'];
39
+ @property({ type: String }) accessor heading = 'Board';
40
+ @property({ attribute: false }) accessor tickets: IKanbanTicket[] = [];
41
+ @property({ attribute: false }) accessor columns: IKanbanColumn[] = [];
42
+ @property({ attribute: false }) accessor people: IKanbanPerson[] = [];
43
+ @property({ attribute: false }) accessor provider: IKanbanProvider = {};
44
+ @property({ type: Boolean }) accessor loading = false;
45
+ @property({ type: String }) accessor error = '';
46
+ @state() private accessor query = '';
47
+ @state() private accessor epicId = '';
48
+ @state() private accessor operationError = '';
49
+ @state() private accessor pendingTicketId = '';
50
+ @state() private accessor draggingTicketId = '';
51
+ @state() private accessor openingTicket = false;
52
+ private modal?: DeesModal;
53
+ private modalOpening?: Promise<DeesModal>;
54
+ private detail?: DeesKanbanTicketDetail;
55
+ private selectedId = '';
56
+ private history: string[] = [];
57
+ private openAbort?: AbortController;
58
+ private commentsAbort?: AbortController;
59
+ private commentCache = new Map<string, IKanbanComment[]>();
60
+ private commentRevisions = new Map<string, number>();
61
+ private commentDrafts = new Map<string, TReferenceContent>();
62
+ private ticketDrafts = new Map<string, IKanbanTicketDraft>();
63
+ private resolvedTickets = new Map<string, IKanbanTicket>();
64
+
65
+ public static styles = [...kanbanStyles, css`
66
+ :host { padding: 24px; background: var(--dees-color-bg-canvas); min-height: 100%; }
67
+ .heading { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; }
68
+ h2 { font-size: var(--dees-font-title1-size); font-weight: 650; letter-spacing: -.02em; }
69
+ .filters { display: flex; align-items: end; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
70
+ .filters dees-input-text { flex: 1; min-width: 200px; max-width: 360px; }
71
+ .filters dees-input-dropdown { min-width: 200px; max-width: 320px; flex: 1; }
72
+ .columns { display: flex; gap: 16px; overflow-x: auto; padding: 3px 3px 16px; align-items: stretch; }
73
+ @media (max-width: 600px) { :host { padding: 16px; } .filters { gap: 10px; } .columns { scroll-snap-type: x proximity; } dees-kanban-column { scroll-snap-align: start; flex-basis: 85vw; } }
74
+ `];
75
+
76
+ public async connectedCallback(): Promise<void> {
77
+ const connection = super.connectedCallback();
78
+ for (const name of interactionEvents) this.addEventListener(name, this.handleInteraction);
79
+ await connection;
80
+ }
81
+ public updated(): void { this.syncDetail(); }
82
+ public render() {
83
+ const epics = sortKanbanTickets(this.tickets.filter(ticket => ticket.kind === 'epic'));
84
+ const query = this.query.trim().toLocaleLowerCase();
85
+ const tasks = this.tickets.filter(ticket => ticket.kind === 'task' && (!this.epicId || ticket.parentId === this.epicId) &&
86
+ (!query || [ticket.key, ticket.title, ticket.assignee?.name, ...ticket.labels ?? [],
87
+ ...this.tickets.filter(child => child.parentId === ticket.id).map(child => `${child.key} ${child.title}`)].join(' ').toLocaleLowerCase().includes(query)));
88
+ const epic = epics.find(ticket => ticket.id === this.epicId);
89
+ return html`<header class="heading"><h2>${this.heading}</h2><span class="caption">${tasks.length} tasks · ${epics.length} epics</span><span class="spacer"></span>
90
+ ${this.provider.createTicket ? html`<dees-button type="ghost" size="sm" text="New epic" .disabled=${!this.columns.length}
91
+ @clicked=${() => { void this.createTicket({ kind: 'epic', columnId: this.columns[0].id }); }}></dees-button>
92
+ <dees-button type="accent" size="sm" icon="lucide:Plus" text="New task" .disabled=${!this.columns.length}
93
+ @clicked=${() => { void this.createTicket({ kind: 'task', columnId: this.columns[0].id, parentId: this.epicId || undefined }); }}></dees-button>` : ''}</header>
94
+ <div class="filters"><dees-input-text label="Search tickets" placeholder="Title, ticket number, assignee…" .value=${this.query}
95
+ @input=${(event: Event) => { this.query = String((event.currentTarget as DeesInputText).value ?? ''); }}></dees-input-text>
96
+ <dees-input-dropdown label="Epic" .value=${{ key: this.epicId, option: epic?.title ?? 'All epics' }}
97
+ .options=${[{ key: '', option: 'All epics' }, ...epics.map(ticket => ({ key: ticket.id, option: ticket.title }))]}
98
+ @selectedOption=${(event: CustomEvent<{ key: string }>) => { this.epicId = event.detail.key; }}></dees-input-dropdown>
99
+ ${epic ? html`<dees-button type="ghost" size="sm" text="Open epic" @clicked=${() => { void this.openTicket(epic.id); }}></dees-button>` : ''}
100
+ </div>
101
+ ${this.loading || this.openingTicket ? html`<p class="caption" role="status">${this.loading ? 'Loading board…' : 'Opening ticket…'}</p>` : ''}
102
+ ${this.error || this.operationError ? html`<p class="error" role="alert">${this.error || this.operationError}</p>` : ''}
103
+ ${!this.columns.length ? html`<p class="empty">No columns configured.</p>` : html`<div class="columns" aria-label=${this.heading}>
104
+ ${this.columns.map(column => html`<dees-kanban-column .column=${column} .columns=${this.columns}
105
+ .tickets=${tasks.filter(ticket => ticket.columnId === column.id)} .allTickets=${this.tickets}
106
+ .movable=${Boolean(this.provider.moveTicket) && !this.pendingTicketId} .canCreate=${Boolean(this.provider.createTicket)}
107
+ .pendingTicketId=${this.pendingTicketId} .draggingTicketId=${this.draggingTicketId}></dees-kanban-column>`)}</div>`}`;
108
+ }
109
+
110
+ private findTicket(id: string): IKanbanTicket | undefined { return this.tickets.find(ticket => ticket.id === id) ?? this.resolvedTickets.get(id); }
111
+ private searchTickets: TReferenceSearch = async (query, signal) => {
112
+ const tickets = this.provider.searchTickets ? await this.provider.searchTickets(query, signal) :
113
+ this.tickets.filter(ticket => ticketLabel(ticket).toLocaleLowerCase().includes(query.toLocaleLowerCase()));
114
+ if (!signal.aborted) for (const ticket of tickets) this.resolvedTickets.set(ticket.id, ticket);
115
+ return tickets.map(ticket => ({ id: ticket.id, label: ticketLabel(ticket) }));
116
+ };
117
+
118
+ /** Open a task, epic, subtask or remotely resolved linked ticket in the board's detail modal. */
119
+ public async openTicket(id: string, addHistory = true): Promise<void> {
120
+ this.openAbort?.abort();
121
+ const controller = this.openAbort = new AbortController();
122
+ this.openingTicket = true;
123
+ this.operationError = '';
124
+ try {
125
+ const ticket = this.provider.getTicket ? await this.provider.getTicket(id, controller.signal) : this.findTicket(id);
126
+ if (controller.signal.aborted || !this.isConnected) return;
127
+ if (!ticket || ticket.id !== id) throw new Error('This ticket is unavailable or you do not have access.');
128
+ this.resolvedTickets.set(ticket.id, ticket);
129
+ this.tickets = this.tickets.map(item => item.id === ticket.id ? ticket : item);
130
+ if (addHistory && this.selectedId && this.selectedId !== id && this.modal) this.history.push(this.selectedId);
131
+ this.selectedId = id;
132
+ if (!this.detail) {
133
+ this.detail = new DeesKanbanTicketDetail();
134
+ for (const name of interactionEvents) this.detail.addEventListener(name, this.handleInteraction);
135
+ }
136
+ this.detail.ticket = ticket;
137
+ this.detail.error = '';
138
+ this.syncDetail();
139
+ if (!this.modal && !this.modalOpening) {
140
+ this.modalOpening = DeesModal.createAndShow({ heading: ticket.key, width: 1000, maxWidth: 1040,
141
+ mobileFullscreen: true, content: html`${this.detail}`, menuOptions: [] });
142
+ const modal = await this.modalOpening;
143
+ this.modalOpening = undefined;
144
+ if (!this.isConnected) { await modal.destroy(); return; }
145
+ this.modal = modal;
146
+ modal.addEventListener('modal-close', () => {
147
+ if (this.modal !== modal) return;
148
+ this.modal = undefined; this.selectedId = ''; this.history = [];
149
+ this.commentsAbort?.abort(); this.openAbort?.abort();
150
+ }, { once: true });
151
+ } else if (this.modalOpening) await this.modalOpening;
152
+ this.syncDetail();
153
+ if (controller.signal.aborted) return;
154
+ kanbanEvent(this, 'kanban-ticket-opened', { ticketId: id });
155
+ await this.refreshComments(id);
156
+ } catch (error) {
157
+ if (!controller.signal.aborted) this.reportError(error);
158
+ } finally { if (this.openAbort === controller) this.openingTicket = false; }
159
+ }
160
+
161
+ private syncDetail(): void {
162
+ if (!this.detail || !this.selectedId) return;
163
+ const ticket = this.resolvedTickets.get(this.selectedId) ?? this.findTicket(this.selectedId);
164
+ if (!ticket) return;
165
+ // Host-fed updates take precedence for records on this board.
166
+ this.detail.ticket = this.tickets.find(item => item.id === this.selectedId) ?? ticket;
167
+ this.detail.tickets = [...this.tickets, ...[...this.resolvedTickets.values()].filter(item => !this.tickets.some(known => known.id === item.id))];
168
+ this.detail.columns = this.columns; this.detail.people = this.people;
169
+ this.detail.provider = this.provider; this.detail.searchTickets = this.searchTickets;
170
+ this.detail.comments = this.commentCache.get(this.selectedId) ?? [];
171
+ this.detail.commentDraft = this.commentDrafts.get(this.selectedId) ?? [];
172
+ this.detail.draft = this.ticketDrafts.get(this.selectedId);
173
+ this.detail.canGoBack = this.history.length > 0;
174
+ this.detail.saving = this.pendingTicketId === this.selectedId;
175
+ if (this.modal) this.modal.heading = this.detail.ticket.key;
176
+ }
177
+
178
+ /** Replace a ticket's discussion with an authoritative snapshot (for realtime updates). */
179
+ public setComments(ticketId: string, comments: IKanbanComment[]): void {
180
+ if (comments.some(comment => comment.ticketId !== ticketId)) throw new Error('Comment ticket IDs must match.');
181
+ this.commentCache.set(ticketId, [...comments]);
182
+ this.commentRevisions.set(ticketId, (this.commentRevisions.get(ticketId) ?? 0) + 1);
183
+ this.syncDetail();
184
+ }
185
+
186
+ public async refreshComments(ticketId = this.selectedId): Promise<void> {
187
+ this.commentsAbort?.abort();
188
+ const controller = this.commentsAbort = new AbortController();
189
+ if (!ticketId || !this.provider.loadComments) { if (this.detail) { this.detail.loadingComments = false; this.detail.commentsError = ''; } return; }
190
+ const revision = this.commentRevisions.get(ticketId) ?? 0;
191
+ const existingIds = new Set((this.commentCache.get(ticketId) ?? []).map(comment => comment.id));
192
+ if (this.detail && ticketId === this.selectedId) { this.detail.loadingComments = true; this.detail.commentsError = ''; }
193
+ try {
194
+ const comments = await this.provider.loadComments(ticketId, controller.signal);
195
+ if (!controller.signal.aborted && (this.commentRevisions.get(ticketId) ?? 0) === revision) {
196
+ const postedWhileLoading = (this.commentCache.get(ticketId) ?? []).filter(comment => !existingIds.has(comment.id) && !comments.some(item => item.id === comment.id));
197
+ this.setComments(ticketId, [...comments, ...postedWhileLoading]);
198
+ }
199
+ } catch (error) {
200
+ if (!controller.signal.aborted && this.detail && ticketId === this.selectedId) this.detail.commentsError = kanbanError(error);
201
+ } finally {
202
+ if (!controller.signal.aborted && this.detail && ticketId === this.selectedId) this.detail.loadingComments = false;
203
+ }
204
+ }
205
+
206
+ private mergeTickets(tickets: IKanbanTicket[], addToBoard = false): void {
207
+ for (const ticket of tickets) this.resolvedTickets.set(ticket.id, ticket);
208
+ this.tickets = this.tickets.map(ticket => tickets.find(item => item.id === ticket.id) ?? ticket);
209
+ if (addToBoard) this.tickets = [...this.tickets, ...tickets.filter(ticket => !this.tickets.some(item => item.id === ticket.id))];
210
+ this.syncDetail();
211
+ kanbanEvent(this, 'kanban-change', { tickets });
212
+ }
213
+
214
+ /** Provider commits the move and supplies the new ordering. Hierarchy is never part of a move request. */
215
+ public async moveTicket(input: IKanbanMove): Promise<void> {
216
+ const ticket = this.findTicket(input.ticketId);
217
+ if (!ticket || !this.provider.moveTicket || ticket.permissions?.move === false || this.pendingTicketId || input.beforeTicketId === ticket.id) return;
218
+ if (!this.columns.some(column => column.id === input.columnId)) { this.reportError(new Error('Unknown destination column.')); return; }
219
+ const before = input.beforeTicketId ? this.findTicket(input.beforeTicketId) : undefined;
220
+ if (input.beforeTicketId && (!before || before.columnId !== input.columnId || before.kind !== ticket.kind)) { this.reportError(new Error('Invalid insertion target.')); return; }
221
+ this.pendingTicketId = ticket.id; this.operationError = ''; this.draggingTicketId = '';
222
+ try {
223
+ const changed = await this.provider.moveTicket(input);
224
+ if (!changed.some(item => item.id === ticket.id)) throw new Error('The move provider did not return the moved ticket.');
225
+ this.mergeTickets(changed);
226
+ } catch (error) { this.reportError(error); }
227
+ finally { this.pendingTicketId = ''; this.syncDetail(); }
228
+ }
229
+
230
+ private async saveTicket(ticketId: string, draft: IKanbanTicketDraft): Promise<void> {
231
+ const ticket = this.findTicket(ticketId);
232
+ if (!ticket || !this.provider.updateTicket || ticket.permissions?.edit === false || this.pendingTicketId || !draft.title.trim()) return;
233
+ const parent = draft.parentId ? this.findTicket(draft.parentId) : undefined;
234
+ if ((ticket.kind === 'subtask' && parent?.kind !== 'task') || (ticket.kind === 'task' && draft.parentId && parent?.kind !== 'epic')) {
235
+ this.reportError(new Error('Choose a valid parent ticket.')); return;
236
+ }
237
+ const assignee = this.people.find(person => person.id === draft.assigneeId) ?? (ticket.assignee?.id === draft.assigneeId ? ticket.assignee : undefined);
238
+ if (draft.assigneeId && !assignee) { this.reportError(new Error('Choose an available assignee.')); return; }
239
+ this.pendingTicketId = ticketId; this.operationError = ''; if (this.detail) this.detail.error = '';
240
+ try {
241
+ const saved = await this.provider.updateTicket({ ticketId, changes: { title: draft.title.trim(), description: draft.description,
242
+ priority: draft.priority, assignee: assignee ?? null, parentId: ticket.kind === 'epic' ? null : draft.parentId || null } });
243
+ if (saved.id !== ticketId) throw new Error('The update provider returned a different ticket.');
244
+ this.ticketDrafts.delete(ticketId);
245
+ this.mergeTickets([saved]);
246
+ if (this.selectedId === ticketId) this.detail?.finishEditing();
247
+ } catch (error) { this.reportError(error); }
248
+ finally { this.pendingTicketId = ''; this.syncDetail(); }
249
+ }
250
+
251
+ private async createTicket(input: Omit<IKanbanCreate, 'title'>): Promise<void> {
252
+ if (!this.provider.createTicket || !this.columns.some(column => column.id === input.columnId)) return;
253
+ const parent = input.parentId ? this.findTicket(input.parentId) : undefined;
254
+ if (parent?.permissions?.createChild === false) return;
255
+ if ((input.kind === 'subtask' && parent?.kind !== 'task') || (input.kind === 'task' && parent && parent.kind !== 'epic')) return;
256
+ const parentId = input.parentId ?? (input.kind === 'task' ? this.epicId || undefined : undefined);
257
+ await DeesModal.prompt({ heading: `New ${input.kind}`, label: 'Title', confirmLabel: 'Create', onConfirm: async title => {
258
+ const ticket = await this.provider.createTicket!({ ...input, parentId, title });
259
+ this.mergeTickets([ticket], true);
260
+ } });
261
+ }
262
+
263
+ private async openMoveMenu(ticketId: string, anchor: HTMLElement): Promise<void> {
264
+ const ticket = this.findTicket(ticketId);
265
+ if (!ticket || !this.provider.moveTicket || ticket.permissions?.move === false || this.pendingTicketId) return;
266
+ const siblings = sortKanbanTickets(this.tickets.filter(item => item.columnId === ticket.columnId && item.kind === ticket.kind));
267
+ const index = siblings.findIndex(item => item.id === ticket.id);
268
+ const rect = anchor.getBoundingClientRect();
269
+ await DeesContextmenu.openContextMenuWithOptions(new MouseEvent('click', { clientX: rect.right, clientY: rect.bottom }), [
270
+ { name: 'Move up', disabled: index <= 0, action: async () => { if (index > 0) await this.moveTicket({ ticketId, columnId: ticket.columnId, beforeTicketId: siblings[index - 1].id }); } },
271
+ { name: 'Move down', disabled: index < 0 || index >= siblings.length - 1, action: async () => { if (index >= 0 && index < siblings.length - 1) await this.moveTicket({ ticketId, columnId: ticket.columnId, beforeTicketId: siblings[index + 2]?.id }); } },
272
+ { divider: true }, ...this.columns.filter(column => column.id !== ticket.columnId).map(column => ({ name: `Move to ${column.title}`, action: async () => { await this.moveTicket({ ticketId, columnId: column.id }); } })),
273
+ ], { ownerElement: anchor });
274
+ }
275
+
276
+ private reportError(error: unknown): void {
277
+ this.operationError = kanbanError(error);
278
+ if (this.detail && this.modal) this.detail.error = this.operationError;
279
+ kanbanEvent(this, 'kanban-error', { error, message: this.operationError });
280
+ }
281
+
282
+ private handleInteraction = (event: Event): void => {
283
+ event.stopPropagation();
284
+ const { type, detail } = event as unknown as TKanbanInteraction;
285
+ switch (type) {
286
+ case 'kanban-ticket-open': void this.openTicket(detail.ticketId); break;
287
+ case 'kanban-ticket-move': void this.moveTicket(detail); break;
288
+ case 'kanban-ticket-create': void this.createTicket(detail).catch(error => this.reportError(error)); break;
289
+ case 'kanban-move-menu': void this.openMoveMenu(detail.ticketId, detail.anchor).catch(error => this.reportError(error)); break;
290
+ case 'kanban-ticket-save': void this.saveTicket(detail.ticketId, detail.draft); break;
291
+ case 'kanban-ticket-draft':
292
+ if (detail.draft) this.ticketDrafts.set(detail.ticketId, detail.draft); else this.ticketDrafts.delete(detail.ticketId);
293
+ this.syncDetail(); break;
294
+ case 'kanban-comment-draft': this.commentDrafts.set(detail.ticketId, detail.body); break;
295
+ case 'kanban-comment-added': {
296
+ const { comment, submittedBody } = detail;
297
+ const comments = this.commentCache.get(comment.ticketId) ?? [];
298
+ this.commentCache.set(comment.ticketId, [...comments.filter(item => item.id !== comment.id), comment]);
299
+ if (JSON.stringify(this.commentDrafts.get(comment.ticketId) ?? []) === JSON.stringify(submittedBody)) this.commentDrafts.delete(comment.ticketId);
300
+ this.syncDetail(); kanbanEvent(this, 'kanban-comment-saved', { comment }); break;
301
+ }
302
+ case 'kanban-comments-refresh': void this.refreshComments(detail.ticketId); break;
303
+ case 'kanban-detail-back': { const previous = this.history.pop(); if (previous) void this.openTicket(previous, false); break; }
304
+ case 'kanban-drag-start': this.draggingTicketId = detail.ticketId; break;
305
+ case 'kanban-drag-end': this.draggingTicketId = ''; break;
306
+ }
307
+ };
308
+
309
+ public async disconnectedCallback(): Promise<void> {
310
+ for (const name of interactionEvents) this.removeEventListener(name, this.handleInteraction);
311
+ this.openAbort?.abort(); this.commentsAbort?.abort();
312
+ void this.modal?.destroy();
313
+ await super.disconnectedCallback();
314
+ }
315
+ }