@design.estate/dees-catalog 13.0.0 → 13.0.2
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 +823 -170
- package/dist_bundle/bundle.js.map +1 -1
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.js +4 -4
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.d.ts +3 -1
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.js +75 -46
- package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.d.ts +4 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.js +72 -27
- package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.d.ts +7 -2
- package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.js +87 -45
- package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.js +26 -15
- package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.d.ts +2 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.js +73 -40
- package/dist_ts_web/elements/00group-kanban/kanban.dragcontroller.d.ts +67 -0
- package/dist_ts_web/elements/00group-kanban/kanban.dragcontroller.js +548 -0
- package/dist_ts_web/elements/00group-kanban/kanban.helpers.d.ts +3 -1
- package/dist_ts_web/elements/00group-kanban/kanban.helpers.js +3 -1
- package/dist_ts_web/elements/00group-kanban/kanban.styles.js +11 -3
- package/package.json +1 -1
- package/readme.md +19 -4
- package/scripts/check-packed-consumer.mjs +1 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.ts +3 -3
- package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.ts +61 -34
- package/ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.ts +47 -24
- package/ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.ts +47 -27
- package/ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.ts +25 -14
- package/ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.ts +64 -38
- package/ts_web/elements/00group-kanban/kanban.dragcontroller.ts +481 -0
- package/ts_web/elements/00group-kanban/kanban.helpers.ts +5 -1
- package/ts_web/elements/00group-kanban/kanban.styles.ts +10 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export const kanbanInitials = (name) => name.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2);
|
|
2
|
+
export const kanbanPriorityIcon = (priority) => priority === 'urgent' ? 'lucide:ChevronsUp' : priority === 'high' ? 'lucide:ChevronUp' : priority === 'low' ? 'lucide:ChevronDown' : 'lucide:Minus';
|
|
1
3
|
export const sortKanbanTickets = (tickets) => [...tickets].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
|
2
4
|
export const ticketLabel = (ticket) => `#${ticket.key} ${ticket.title}`;
|
|
3
5
|
export const ticketDraft = (ticket) => ({
|
|
@@ -8,4 +10,4 @@ export const kanbanEvent = (owner, name, detail) => {
|
|
|
8
10
|
owner.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
9
11
|
};
|
|
10
12
|
export const kanbanError = (error) => error instanceof Error ? error.message : String(error);
|
|
11
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
13
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoia2FuYmFuLmhlbHBlcnMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90c193ZWIvZWxlbWVudHMvMDBncm91cC1rYW5iYW4va2FuYmFuLmhlbHBlcnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBRUEsTUFBTSxDQUFDLE1BQU0sY0FBYyxHQUFHLENBQUMsSUFBWSxFQUFVLEVBQUUsQ0FBQyxJQUFJLENBQUMsSUFBSSxFQUFFLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsRUFBRSxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxFQUFFLENBQUMsQ0FBQyxLQUFLLENBQUMsQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDO0FBQzNILE1BQU0sQ0FBQyxNQUFNLGtCQUFrQixHQUFHLENBQUMsUUFBMEIsRUFBVSxFQUFFLENBQ3ZFLFFBQVEsS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLG1CQUFtQixDQUFDLENBQUMsQ0FBQyxRQUFRLEtBQUssTUFBTSxDQUFDLENBQUMsQ0FBQyxrQkFBa0IsQ0FBQyxDQUFDLENBQUMsUUFBUSxLQUFLLEtBQUssQ0FBQyxDQUFDLENBQUMsb0JBQW9CLENBQUMsQ0FBQyxDQUFDLGNBQWMsQ0FBQztBQUV0SixNQUFNLENBQUMsTUFBTSxpQkFBaUIsR0FBRyxDQUFDLE9BQWlDLEVBQW1CLEVBQUUsQ0FDdEYsQ0FBQyxHQUFHLE9BQU8sQ0FBQyxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLEVBQUUsRUFBRSxDQUFDLENBQUMsQ0FBQyxLQUFLLEdBQUcsQ0FBQyxDQUFDLEtBQUssSUFBSSxDQUFDLENBQUMsRUFBRSxDQUFDLGFBQWEsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQztBQUM3RSxNQUFNLENBQUMsTUFBTSxXQUFXLEdBQUcsQ0FBQyxNQUFxQixFQUFVLEVBQUUsQ0FBQyxJQUFJLE1BQU0sQ0FBQyxHQUFHLElBQUksTUFBTSxDQUFDLEtBQUssRUFBRSxDQUFDO0FBQy9GLE1BQU0sQ0FBQyxNQUFNLFdBQVcsR0FBRyxDQUFDLE1BQXFCLEVBQXNCLEVBQUUsQ0FBQyxDQUFDO0lBQ3pFLEtBQUssRUFBRSxNQUFNLENBQUMsS0FBSyxFQUFFLFdBQVcsRUFBRSxNQUFNLENBQUMsV0FBVyxJQUFJLEVBQUUsRUFBRSxRQUFRLEVBQUUsTUFBTSxDQUFDLFFBQVEsSUFBSSxNQUFNO0lBQy9GLFVBQVUsRUFBRSxNQUFNLENBQUMsUUFBUSxFQUFFLEVBQUUsSUFBSSxFQUFFLEVBQUUsUUFBUSxFQUFFLE1BQU0sQ0FBQyxRQUFRLElBQUksRUFBRTtDQUN2RSxDQUFDLENBQUM7QUFDSCxNQUFNLENBQUMsTUFBTSxXQUFXLEdBQUcsQ0FBSSxLQUFrQixFQUFFLElBQVksRUFBRSxNQUFTLEVBQVEsRUFBRTtJQUNsRixLQUFLLENBQUMsYUFBYSxDQUFDLElBQUksV0FBVyxDQUFDLElBQUksRUFBRSxFQUFFLE1BQU0sRUFBRSxPQUFPLEVBQUUsSUFBSSxFQUFFLFFBQVEsRUFBRSxJQUFJLEVBQUUsQ0FBQyxDQUFDLENBQUM7QUFDeEYsQ0FBQyxDQUFDO0FBQ0YsTUFBTSxDQUFDLE1BQU0sV0FBVyxHQUFHLENBQUMsS0FBYyxFQUFVLEVBQUUsQ0FBQyxLQUFLLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUMifQ==
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { css, cssManager } from '@design.estate/dees-element';
|
|
2
2
|
import { themeDefaultStyles } from '../00theme.js';
|
|
3
3
|
export const kanbanStyles = [themeDefaultStyles, cssManager.defaultStyles, css `
|
|
4
|
-
:host { display: block; min-width: 0; font-family: var(--dees-font-family); color: var(--dees-color-text-primary); font-size: var(--dees-font-
|
|
4
|
+
:host { display: block; min-width: 0; font-family: var(--dees-font-family); color: var(--dees-color-text-primary); font-size: var(--dees-font-subhead-size); line-height: 1.5; }
|
|
5
5
|
* { box-sizing: border-box; }
|
|
6
6
|
h2, h3, p { margin: 0; }
|
|
7
7
|
.muted { color: var(--dees-color-text-secondary); }
|
|
@@ -13,6 +13,14 @@ export const kanbanStyles = [themeDefaultStyles, cssManager.defaultStyles, css `
|
|
|
13
13
|
button.link { border: 0; background: transparent; font: inherit; color: var(--dees-color-link); padding: 2px 0; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
|
14
14
|
button:focus-visible, summary:focus-visible { outline: 2px solid var(--dees-color-focus-ring); outline-offset: 3px; border-radius: var(--dees-radius-sm); }
|
|
15
15
|
dees-icon { font-size: 16px; }
|
|
16
|
-
.badge { display: inline-flex; align-items: center; gap: 4px; background: var(--dees-color-
|
|
16
|
+
.badge { display: inline-flex; align-items: center; gap: 4px; background: var(--dees-color-fill-tertiary); padding: 3px 7px; border-radius: var(--dees-radius-sm); font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); }
|
|
17
|
+
.avatar { flex: 0 0 auto; width: 24px; height: 24px; border-radius: 50%; background: var(--dees-color-fill-secondary); color: var(--dees-color-text-secondary); display: inline-grid; place-items: center; font-size: 10px; font-weight: 600; line-height: 1; }
|
|
18
|
+
.priority { display: inline-flex; align-items: center; gap: 3px; font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); text-transform: capitalize; }
|
|
19
|
+
.priority[data-priority=urgent] { color: var(--dees-color-text-error); }
|
|
20
|
+
.priority[data-priority=high] { color: var(--dees-color-text-warning); }
|
|
21
|
+
.priority dees-icon { font-size: 13px; }
|
|
22
|
+
.section-title { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
23
|
+
.section-title h3 { font-size: var(--dees-font-footnote-size); font-weight: 600; }
|
|
24
|
+
.count { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 5px; border-radius: var(--dees-radius-sm); background: var(--dees-color-fill-tertiary); color: var(--dees-color-text-secondary); font-size: var(--dees-font-caption1-size); font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
17
25
|
`];
|
|
18
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
26
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoia2FuYmFuLnN0eWxlcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3RzX3dlYi9lbGVtZW50cy8wMGdyb3VwLWthbmJhbi9rYW5iYW4uc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxHQUFHLEVBQUUsVUFBVSxFQUFFLE1BQU0sNkJBQTZCLENBQUM7QUFDOUQsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sZUFBZSxDQUFDO0FBRW5ELE1BQU0sQ0FBQyxNQUFNLFlBQVksR0FBRyxDQUFDLGtCQUFrQixFQUFFLFVBQVUsQ0FBQyxhQUFhLEVBQUUsR0FBRyxDQUFBOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7O0NBc0I3RSxDQUFDLENBQUMifQ==
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@design.estate/dees-catalog",
|
|
3
|
-
"version": "13.0.
|
|
3
|
+
"version": "13.0.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
|
6
6
|
"main": "dist_ts_web/index.js",
|
package/readme.md
CHANGED
|
@@ -2642,9 +2642,15 @@ must enforce the same permissions.
|
|
|
2642
2642
|
Provider reads receive an `AbortSignal`; obsolete reads cannot overwrite a newer
|
|
2643
2643
|
selection. Reject mutations with an `Error` to show the failure and preserve the
|
|
2644
2644
|
draft. Successful callbacks supply authoritative records to the component.
|
|
2645
|
-
Moves are awaited before changing
|
|
2646
|
-
atomically. Omitting `beforeTicketId` appends.
|
|
2647
|
-
|
|
2645
|
+
Moves are awaited before changing `.tickets` or emitting `kanban-change`; the
|
|
2646
|
+
provider must assign ordering atomically. Omitting `beforeTicketId` appends.
|
|
2647
|
+
Mouse and pen dragging shows a card-sized drop slot, animates neighboring cards,
|
|
2648
|
+
and lands the preview into position. A render-only placement stays visible while
|
|
2649
|
+
saving; slow saves show a status, and failures animate back to the saved position.
|
|
2650
|
+
Escape or dropping outside the board cancels. Edge scrolling works inside scroll
|
|
2651
|
+
containers, and reduced motion removes lift, rearrangement and landing animation.
|
|
2652
|
+
Touch retains normal scrolling. The keyboard-accessible Move menu supports both
|
|
2653
|
+
within-column ordering and column selection.
|
|
2648
2654
|
|
|
2649
2655
|
Hierarchy is independent of workflow: tasks optionally belong to epics, and
|
|
2650
2656
|
subtasks belong to tasks. Each ticket has its own `columnId`; moving a parent
|
|
@@ -2671,7 +2677,8 @@ Public methods and events:
|
|
|
2671
2677
|
|
|
2672
2678
|
`dees-kanban-column` accepts `.column`, `.tickets`, `.allTickets`, `.columns`,
|
|
2673
2679
|
`.movable`, `.canCreate`, `.pendingTicketId` and `.draggingTicketId`. It composes
|
|
2674
|
-
cards and
|
|
2680
|
+
cards and forwards their open, create and Move menu requests. Standalone columns
|
|
2681
|
+
retain native dragging and emit `kanban-ticket-move` with the insertion target.
|
|
2675
2682
|
Use it through `dees-kanban` for provider handling and board-local drag ownership.
|
|
2676
2683
|
|
|
2677
2684
|
#### DeesKanbanCard
|
|
@@ -2689,6 +2696,8 @@ discussion. It accepts `.ticket`, `.tickets`, `.columns`, `.people`, `.provider`
|
|
|
2689
2696
|
`.draft`, `.comments` and `.commentDraft`. It emits `kanban-ticket-save` and
|
|
2690
2697
|
`kanban-ticket-draft` intents; `dees-kanban` owns persistence, navigation and draft
|
|
2691
2698
|
caching. `finishEditing()` returns to the saved view after a successful update.
|
|
2699
|
+
The ticket key is visible by default. Set `.showTicketKey = false` only when the
|
|
2700
|
+
containing surface already displays it, as the board's modal heading does.
|
|
2692
2701
|
|
|
2693
2702
|
#### DeesKanbanDiscussion
|
|
2694
2703
|
|
|
@@ -2733,6 +2742,12 @@ and cancels obsolete searches. References participate in editor undo and clipboa
|
|
|
2733
2742
|
operations. Changing `.documentKey` resets history so undo cannot cross tickets.
|
|
2734
2743
|
|
|
2735
2744
|
Set `.trigger`, `.label`, `.placeholder`, `.searchLabel` and `.disabled` as needed.
|
|
2745
|
+
For an integrated composer, style `::part(editor)` and set
|
|
2746
|
+
`--dees-reference-min-height` (default `84px`) or `--dees-reference-font-size`
|
|
2747
|
+
(default `var(--dees-font-body-size)`). Keep a visible focus indicator when
|
|
2748
|
+
overriding the editor's border or outline; the Kanban discussion applies it to
|
|
2749
|
+
the surrounding composer.
|
|
2750
|
+
|
|
2736
2751
|
`reference-input` emits `{ value }`, `reference-open` emits `{ id, label }`, and
|
|
2737
2752
|
`reference-submit` requests submission on Ctrl/⌘ Enter. `referenceText(value)`
|
|
2738
2753
|
returns a plain-text representation. No callback means no reference picker.
|
|
@@ -30,7 +30,7 @@ writeFileSync(join(consumer, 'package.json'), JSON.stringify({
|
|
|
30
30
|
}, null, 2));
|
|
31
31
|
writeFileSync(join(consumer, 'pnpm-workspace.yaml'), 'packages:\n - "."\n');
|
|
32
32
|
// Run the same regressions against installed JavaScript, not sources.
|
|
33
|
-
const browserTests = ['table-construction', 'datepicker-validation', 'textarea', 'composer-attachments', 'kanban'];
|
|
33
|
+
const browserTests = ['table-construction', 'datepicker-validation', 'textarea', 'composer-attachments', 'kanban', 'kanban-motion'];
|
|
34
34
|
for (const name of browserTests) {
|
|
35
35
|
const browserTest = readFileSync(join(root, `test/test.${name}.browser.ts`), 'utf8')
|
|
36
36
|
.replaceAll('../ts_web/', '@design.estate/dees-catalog/dist_ts_web/');
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@design.estate/dees-catalog',
|
|
6
|
-
version: '13.0.
|
|
6
|
+
version: '13.0.2',
|
|
7
7
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
|
8
8
|
}
|
|
@@ -35,18 +35,18 @@ export class DeesInputReference extends DeesElement {
|
|
|
35
35
|
:host { display: block; min-width: 0; color: var(--dees-color-text-primary); font-family: var(--dees-font-family); }
|
|
36
36
|
.editor { border: 1px solid var(--dees-color-border-default); border-radius: var(--dees-radius-lg); background: var(--dees-color-bg-primary); }
|
|
37
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; }
|
|
38
|
+
.ProseMirror { padding: 12px; min-height: var(--dees-reference-min-height, 84px); max-height: 260px; overflow: auto; outline: none; white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--dees-reference-font-size, var(--dees-font-body-size)); line-height: 1.6; }
|
|
39
39
|
.ProseMirror p { margin: 0; }
|
|
40
40
|
.ProseMirror p + p { margin-top: 0; }
|
|
41
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-
|
|
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-fill-tertiary); cursor: pointer; font-weight: 500; }
|
|
43
43
|
.ProseMirror [data-reference-id]:focus-visible, .ProseMirror .ProseMirror-selectednode { outline: 2px solid var(--dees-color-focus-ring); }
|
|
44
44
|
.ProseMirror[contenteditable=false] { opacity: .65; }
|
|
45
45
|
.error { color: var(--dees-color-text-error); font-size: var(--dees-font-footnote-size); }
|
|
46
46
|
`];
|
|
47
47
|
|
|
48
48
|
public render() {
|
|
49
|
-
return html`<div class="editor"></div>${this.loadError ? html`<p class="error" role="alert">${this.loadError}</p>` : ''}`;
|
|
49
|
+
return html`<div class="editor" part="editor"></div>${this.loadError ? html`<p class="error" role="alert">${this.loadError}</p>` : ''}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
public async connectedCallback(): Promise<void> {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DeesElement, customElement, property, state, html, css } from '@design.estate/dees-element';
|
|
1
|
+
import { DeesElement, customElement, property, state, html, css, directives } from '@design.estate/dees-element';
|
|
2
2
|
import type { IKanbanColumn, IKanbanComment, IKanbanCreate, IKanbanMove, IKanbanPerson, IKanbanProvider, IKanbanTicket, IKanbanTicketDraft } from '../interfaces.js';
|
|
3
3
|
import type { TReferenceContent, TReferenceSearch } from '../../00group-input/dees-input-reference/interfaces.js';
|
|
4
4
|
import type { DeesInputText } from '../../00group-input/dees-input-text/dees-input-text.js';
|
|
@@ -8,13 +8,14 @@ import { DeesKanbanTicketDetail } from '../dees-kanban-ticket-detail/dees-kanban
|
|
|
8
8
|
import { kanbanStyles } from '../kanban.styles.js';
|
|
9
9
|
import { kanbanError, kanbanEvent, sortKanbanTickets, ticketLabel } from '../kanban.helpers.js';
|
|
10
10
|
import { demoFunc } from './dees-kanban.demo.js';
|
|
11
|
+
import { KanbanDragController } from '../kanban.dragcontroller.js';
|
|
11
12
|
import '../dees-kanban-column/dees-kanban-column.js';
|
|
12
13
|
|
|
13
14
|
declare global { interface HTMLElementTagNameMap { 'dees-kanban': DeesKanban; } }
|
|
14
15
|
|
|
15
16
|
const interactionEvents = ['kanban-ticket-open', 'kanban-ticket-move', 'kanban-ticket-create', 'kanban-move-menu',
|
|
16
17
|
'kanban-ticket-save', 'kanban-ticket-draft', 'kanban-comment-draft', 'kanban-comment-added', 'kanban-comments-refresh',
|
|
17
|
-
'kanban-detail-back'
|
|
18
|
+
'kanban-detail-back'] as const;
|
|
18
19
|
|
|
19
20
|
interface IKanbanInteractionMap {
|
|
20
21
|
'kanban-ticket-open': { ticketId: string };
|
|
@@ -27,8 +28,6 @@ interface IKanbanInteractionMap {
|
|
|
27
28
|
'kanban-comment-added': { comment: IKanbanComment; submittedBody: TReferenceContent };
|
|
28
29
|
'kanban-comments-refresh': { ticketId: string };
|
|
29
30
|
'kanban-detail-back': Record<string, never>;
|
|
30
|
-
'kanban-drag-start': { ticketId: string };
|
|
31
|
-
'kanban-drag-end': Record<string, never>;
|
|
32
31
|
}
|
|
33
32
|
type TKanbanInteraction = { [K in keyof IKanbanInteractionMap]: { type: K; detail: IKanbanInteractionMap[K] } }[keyof IKanbanInteractionMap];
|
|
34
33
|
|
|
@@ -47,7 +46,6 @@ export class DeesKanban extends DeesElement {
|
|
|
47
46
|
@state() private accessor epicId = '';
|
|
48
47
|
@state() private accessor operationError = '';
|
|
49
48
|
@state() private accessor pendingTicketId = '';
|
|
50
|
-
@state() private accessor draggingTicketId = '';
|
|
51
49
|
@state() private accessor openingTicket = false;
|
|
52
50
|
private modal?: DeesModal;
|
|
53
51
|
private modalOpening?: Promise<DeesModal>;
|
|
@@ -61,24 +59,40 @@ export class DeesKanban extends DeesElement {
|
|
|
61
59
|
private commentDrafts = new Map<string, TReferenceContent>();
|
|
62
60
|
private ticketDrafts = new Map<string, IKanbanTicketDraft>();
|
|
63
61
|
private resolvedTickets = new Map<string, IKanbanTicket>();
|
|
62
|
+
private activeMove?: symbol;
|
|
63
|
+
private readonly dragController = new KanbanDragController(this, input => this.persistMove(input), () => !this.pendingTicketId);
|
|
64
64
|
|
|
65
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:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.
|
|
71
|
-
.filters
|
|
72
|
-
.
|
|
73
|
-
|
|
66
|
+
:host { padding: 20px 24px; background: var(--dees-color-bg-canvas); min-height: 100%; }
|
|
67
|
+
.heading { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
|
68
|
+
.identity { display: flex; gap: 12px; align-items: baseline; flex: 1; min-width: 220px; flex-wrap: wrap; }
|
|
69
|
+
h2 { font-size: var(--dees-font-title3-size); font-weight: 600; letter-spacing: -.02em; line-height: 1.3; overflow-wrap: anywhere; }
|
|
70
|
+
.totals { font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); white-space: nowrap; }
|
|
71
|
+
.filters { display: flex; align-items: center; gap: 8px; }
|
|
72
|
+
.filters dees-input-text { width: 230px; min-width: 140px; }
|
|
73
|
+
.filters dees-input-dropdown { width: 180px; min-width: 140px; }
|
|
74
|
+
.actions { display: flex; gap: 8px; margin-left: auto; }
|
|
75
|
+
.columns { display: flex; gap: 20px; overflow-x: auto; padding: 3px 2px 16px; align-items: stretch; }
|
|
76
|
+
@media (max-width: 1100px) { .filters { order: 3; flex-basis: 100%; } .filters dees-input-text { flex: 1; max-width: 320px; } }
|
|
77
|
+
@media (max-width: 600px) {
|
|
78
|
+
:host { padding: 12px 16px; }
|
|
79
|
+
.heading { gap: 12px 8px; }
|
|
80
|
+
.identity { min-width: 100%; gap: 6px 10px; }
|
|
81
|
+
.actions { margin-left: 0; }
|
|
82
|
+
.filters { flex-wrap: wrap; }
|
|
83
|
+
.filters dees-input-text, .filters dees-input-dropdown { flex: 1; width: auto; min-width: 130px; }
|
|
84
|
+
.columns { margin: 0 -16px; padding: 3px 16px 16px; scroll-snap-type: x proximity; scroll-padding-inline: 16px; gap: 14px; }
|
|
85
|
+
dees-kanban-column { scroll-snap-align: start; flex: 0 0 86%; min-width: 0; max-width: none; }
|
|
86
|
+
}
|
|
74
87
|
`];
|
|
75
88
|
|
|
76
89
|
public async connectedCallback(): Promise<void> {
|
|
77
90
|
const connection = super.connectedCallback();
|
|
78
91
|
for (const name of interactionEvents) this.addEventListener(name, this.handleInteraction);
|
|
92
|
+
this.dragController.connect();
|
|
79
93
|
await connection;
|
|
80
94
|
}
|
|
81
|
-
public updated(): void { this.syncDetail(); }
|
|
95
|
+
public updated(): void { this.syncDetail(); this.dragController.sync(); }
|
|
82
96
|
public render() {
|
|
83
97
|
const epics = sortKanbanTickets(this.tickets.filter(ticket => ticket.kind === 'epic'));
|
|
84
98
|
const query = this.query.trim().toLocaleLowerCase();
|
|
@@ -86,25 +100,27 @@ export class DeesKanban extends DeesElement {
|
|
|
86
100
|
(!query || [ticket.key, ticket.title, ticket.assignee?.name, ...ticket.labels ?? [],
|
|
87
101
|
...this.tickets.filter(child => child.parentId === ticket.id).map(child => `${child.key} ${child.title}`)].join(' ').toLocaleLowerCase().includes(query)));
|
|
88
102
|
const epic = epics.find(ticket => ticket.id === this.epicId);
|
|
89
|
-
return html`<header class="heading"><h2>${this.heading}</h2><span class="
|
|
90
|
-
|
|
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}
|
|
103
|
+
return html`<header class="heading"><div class="identity"><h2>${this.heading}</h2><span class="totals">${tasks.length} tasks · ${epics.length} epics</span></div>
|
|
104
|
+
<div class="filters"><dees-input-text label="Search tickets" .labelPosition=${'none'} placeholder="Search tickets…" .value=${this.query}
|
|
95
105
|
@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' }}
|
|
106
|
+
<dees-input-dropdown label="Epic" .labelPosition=${'none'} .value=${{ key: this.epicId, option: epic?.title ?? 'All epics' }}
|
|
97
107
|
.options=${[{ key: '', option: 'All epics' }, ...epics.map(ticket => ({ key: ticket.id, option: ticket.title }))]}
|
|
98
108
|
@selectedOption=${(event: CustomEvent<{ key: string }>) => { this.epicId = event.detail.key; }}></dees-input-dropdown>
|
|
99
|
-
${epic ? html`<dees-button type="ghost"
|
|
109
|
+
${epic ? html`<dees-button type="ghost" text="Open epic" @clicked=${() => { void this.openTicket(epic.id); }}></dees-button>` : ''}
|
|
100
110
|
</div>
|
|
111
|
+
${this.provider.createTicket ? html`<div class="actions"><dees-button type="secondary" text="New epic" .disabled=${!this.columns.length}
|
|
112
|
+
@clicked=${() => { void this.createTicket({ kind: 'epic', columnId: this.columns[0].id }); }}></dees-button>
|
|
113
|
+
<dees-button type="accent" icon="lucide:Plus" text="New task" .disabled=${!this.columns.length}
|
|
114
|
+
@clicked=${() => { void this.createTicket({ kind: 'task', columnId: this.columns[0].id, parentId: this.epicId || undefined }); }}></dees-button></div>` : ''}</header>
|
|
101
115
|
${this.loading || this.openingTicket ? html`<p class="caption" role="status">${this.loading ? 'Loading board…' : 'Opening ticket…'}</p>` : ''}
|
|
102
116
|
${this.error || this.operationError ? html`<p class="error" role="alert">${this.error || this.operationError}</p>` : ''}
|
|
103
117
|
${!this.columns.length ? html`<p class="empty">No columns configured.</p>` : html`<div class="columns" aria-label=${this.heading}>
|
|
104
|
-
${this.columns.
|
|
118
|
+
${directives.repeat(this.columns, column => column.id, column => html`<dees-kanban-column .column=${column} .columns=${this.columns}
|
|
119
|
+
.pointerManaged=${true}
|
|
105
120
|
.tickets=${tasks.filter(ticket => ticket.columnId === column.id)} .allTickets=${this.tickets}
|
|
106
|
-
.movable=${Boolean(this.provider.moveTicket)
|
|
107
|
-
.
|
|
121
|
+
.movable=${Boolean(this.provider.moveTicket)} .canCreate=${Boolean(this.provider.createTicket)}
|
|
122
|
+
.movesDisabled=${Boolean(this.pendingTicketId)}
|
|
123
|
+
.pendingTicketId=${this.pendingTicketId}></dees-kanban-column>`)}</div>`}`;
|
|
108
124
|
}
|
|
109
125
|
|
|
110
126
|
private findTicket(id: string): IKanbanTicket | undefined { return this.tickets.find(ticket => ticket.id === id) ?? this.resolvedTickets.get(id); }
|
|
@@ -131,6 +147,7 @@ export class DeesKanban extends DeesElement {
|
|
|
131
147
|
this.selectedId = id;
|
|
132
148
|
if (!this.detail) {
|
|
133
149
|
this.detail = new DeesKanbanTicketDetail();
|
|
150
|
+
this.detail.showTicketKey = false;
|
|
134
151
|
for (const name of interactionEvents) this.detail.addEventListener(name, this.handleInteraction);
|
|
135
152
|
}
|
|
136
153
|
this.detail.ticket = ticket;
|
|
@@ -138,7 +155,7 @@ export class DeesKanban extends DeesElement {
|
|
|
138
155
|
this.syncDetail();
|
|
139
156
|
if (!this.modal && !this.modalOpening) {
|
|
140
157
|
this.modalOpening = DeesModal.createAndShow({ heading: ticket.key, width: 1000, maxWidth: 1040,
|
|
141
|
-
mobileFullscreen: true, content: html`${this.detail}`, menuOptions: [] });
|
|
158
|
+
mobileFullscreen: true, contentPadding: 0, content: html`${this.detail}`, menuOptions: [] });
|
|
142
159
|
const modal = await this.modalOpening;
|
|
143
160
|
this.modalOpening = undefined;
|
|
144
161
|
if (!this.isConnected) { await modal.destroy(); return; }
|
|
@@ -213,18 +230,28 @@ export class DeesKanban extends DeesElement {
|
|
|
213
230
|
|
|
214
231
|
/** Provider commits the move and supplies the new ordering. Hierarchy is never part of a move request. */
|
|
215
232
|
public async moveTicket(input: IKanbanMove): Promise<void> {
|
|
233
|
+
if (this.dragController.busy) return;
|
|
234
|
+
await this.persistMove(input, true);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async persistMove(input: IKanbanMove, animate = false): Promise<boolean> {
|
|
216
238
|
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; }
|
|
239
|
+
if (!ticket || !this.provider.moveTicket || ticket.permissions?.move === false || this.pendingTicketId || input.beforeTicketId === ticket.id) return false;
|
|
240
|
+
if (!this.columns.some(column => column.id === input.columnId)) { this.reportError(new Error('Unknown destination column.')); return false; }
|
|
219
241
|
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
|
-
|
|
242
|
+
if (input.beforeTicketId && (!before || before.columnId !== input.columnId || before.kind !== ticket.kind)) { this.reportError(new Error('Invalid insertion target.')); return false; }
|
|
243
|
+
const operation = this.activeMove = Symbol();
|
|
244
|
+
this.pendingTicketId = ticket.id; this.operationError = '';
|
|
222
245
|
try {
|
|
223
246
|
const changed = await this.provider.moveTicket(input);
|
|
247
|
+
if (this.activeMove !== operation) return false;
|
|
224
248
|
if (!changed.some(item => item.id === ticket.id)) throw new Error('The move provider did not return the moved ticket.');
|
|
249
|
+
const positions = animate ? this.dragController.capture() : undefined;
|
|
225
250
|
this.mergeTickets(changed);
|
|
226
|
-
|
|
227
|
-
|
|
251
|
+
if (positions) { await this.updateComplete; await this.dragController.animateFrom(positions); }
|
|
252
|
+
return true;
|
|
253
|
+
} catch (error) { if (this.activeMove === operation) this.reportError(error); return false; }
|
|
254
|
+
finally { if (this.activeMove === operation) { this.activeMove = undefined; this.pendingTicketId = ''; this.syncDetail(); } }
|
|
228
255
|
}
|
|
229
256
|
|
|
230
257
|
private async saveTicket(ticketId: string, draft: IKanbanTicketDraft): Promise<void> {
|
|
@@ -301,13 +328,13 @@ export class DeesKanban extends DeesElement {
|
|
|
301
328
|
}
|
|
302
329
|
case 'kanban-comments-refresh': void this.refreshComments(detail.ticketId); break;
|
|
303
330
|
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
331
|
}
|
|
307
332
|
};
|
|
308
333
|
|
|
309
334
|
public async disconnectedCallback(): Promise<void> {
|
|
310
335
|
for (const name of interactionEvents) this.removeEventListener(name, this.handleInteraction);
|
|
336
|
+
this.dragController.disconnect();
|
|
337
|
+
if (this.activeMove) { this.activeMove = undefined; this.pendingTicketId = ''; }
|
|
311
338
|
this.openAbort?.abort(); this.commentsAbort?.abort();
|
|
312
339
|
void this.modal?.destroy();
|
|
313
340
|
await super.disconnectedCallback();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DeesElement, customElement, property, html, css } from '@design.estate/dees-element';
|
|
2
2
|
import type { IKanbanTicket, IKanbanColumn } from '../interfaces.js';
|
|
3
3
|
import { kanbanStyles } from '../kanban.styles.js';
|
|
4
|
-
import { kanbanEvent } from '../kanban.helpers.js';
|
|
4
|
+
import { kanbanEvent, kanbanInitials, kanbanPriorityIcon } from '../kanban.helpers.js';
|
|
5
5
|
import '../../00group-button/dees-button/dees-button.js';
|
|
6
6
|
import '../../00group-utility/dees-icon/dees-icon.js';
|
|
7
7
|
|
|
@@ -14,44 +14,67 @@ export class DeesKanbanCard extends DeesElement {
|
|
|
14
14
|
@property({ attribute: false }) accessor subtasks: IKanbanTicket[] = [];
|
|
15
15
|
@property({ attribute: false }) accessor columns: IKanbanColumn[] = [];
|
|
16
16
|
@property({ type: Boolean }) accessor movable = false;
|
|
17
|
+
@property({ type: Boolean }) accessor moveDisabled = false;
|
|
17
18
|
@property({ type: Boolean }) accessor pending = false;
|
|
19
|
+
@property({ type: Boolean }) accessor saving = false;
|
|
20
|
+
/** The assembled board owns pointer previews; standalone cards retain native drag events. */
|
|
21
|
+
@property({ attribute: false }) accessor pointerManaged = false;
|
|
18
22
|
public static styles = [...kanbanStyles, css`
|
|
19
|
-
article { padding:
|
|
23
|
+
article { padding: 12px; border: 1px solid var(--dees-color-border-subtle); border-radius: var(--dees-radius-xl); corner-shape: var(--dees-corner-shape); background: var(--dees-color-bg-primary); box-shadow: var(--dees-shadow-xs); }
|
|
20
24
|
article:hover { border-color: var(--dees-color-border-strong); }
|
|
21
|
-
article[aria-busy=true] { opacity: .65; }
|
|
22
|
-
|
|
23
|
-
.
|
|
24
|
-
.
|
|
25
|
-
.
|
|
26
|
-
.
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
.
|
|
25
|
+
article[aria-busy=true] > :not(.meta), article[aria-busy=true] .meta > :not(.saving) { opacity: .65; }
|
|
26
|
+
article[data-movable=true] { cursor: grab; }
|
|
27
|
+
.meta { display: flex; align-items: center; gap: 8px; min-height: 24px; font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); }
|
|
28
|
+
.key { flex-shrink: 0; font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
29
|
+
.link.epic { min-width: 0; color: var(--dees-color-text-secondary); font-size: var(--dees-font-caption1-size); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
30
|
+
.link.epic:hover { color: var(--dees-color-link); text-decoration: underline; }
|
|
31
|
+
.move { flex-shrink: 0; margin-left: auto; }
|
|
32
|
+
.saving { margin-left: auto; font-size: var(--dees-font-caption1-size); white-space: nowrap; }
|
|
33
|
+
.move::part(button) { width: 24px; height: 24px; min-height: 24px; }
|
|
34
|
+
.title { display: block; width: 100%; border: 0; background: transparent; padding: 4px 0 0; color: inherit; text-align: left; cursor: pointer; font: inherit; font-weight: 600; line-height: 22px; overflow-wrap: anywhere; }
|
|
35
|
+
.title:hover { color: var(--dees-color-link); }
|
|
36
|
+
.footer { margin-top: 10px; gap: 6px; }
|
|
37
|
+
.badge { padding: 1px 6px; }
|
|
38
|
+
.assignee { display: flex; align-items: center; gap: 5px; margin-left: auto; min-width: 0; color: var(--dees-color-text-secondary); font-size: var(--dees-font-caption1-size); }
|
|
39
|
+
.assignee-name { max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
40
|
+
.avatar { width: 20px; height: 20px; font-size: 9px; }
|
|
41
|
+
details { border-top: 1px solid var(--dees-color-border-subtle); margin-top: 10px; }
|
|
42
|
+
summary { cursor: pointer; display: flex; align-items: center; gap: 6px; padding: 9px 0 0; font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); list-style: none; }
|
|
43
|
+
summary::-webkit-details-marker { display: none; }
|
|
44
|
+
summary .disclosure { margin-left: auto; }
|
|
45
|
+
details[open] summary .disclosure { transform: rotate(90deg); }
|
|
46
|
+
.subtask { display: flex; gap: 7px; align-items: center; padding-top: 8px; font-size: var(--dees-font-footnote-size); }
|
|
47
|
+
.subtask dees-icon { flex-shrink: 0; color: var(--dees-color-text-secondary); font-size: 14px; }
|
|
48
|
+
.subtask .link { color: var(--dees-color-text-primary); }
|
|
49
|
+
.subtask .link:hover { color: var(--dees-color-link); }
|
|
50
|
+
@media (pointer: coarse) { .move::part(button) { width: 32px; height: 32px; min-height: 32px; } summary { min-height: 36px; } }
|
|
30
51
|
`];
|
|
31
52
|
private open(ticketId: string): void { kanbanEvent(this, 'kanban-ticket-open', { ticketId }); }
|
|
32
53
|
public render() {
|
|
33
54
|
const ticket = this.ticket;
|
|
34
55
|
if (!ticket) return html``;
|
|
35
56
|
const completed = this.subtasks.filter(task => this.columns.some(column => column.id === task.columnId && column.done)).length;
|
|
36
|
-
return html`<article aria-busy=${String(this.pending)}
|
|
57
|
+
return html`<article aria-busy=${String(this.pending)} data-movable=${String(this.movable && !this.moveDisabled && !this.pending)}
|
|
58
|
+
draggable=${String(this.movable && !this.moveDisabled && !this.pending && !this.pointerManaged)}
|
|
37
59
|
@dragstart=${(event: DragEvent) => {
|
|
38
|
-
if (!this.movable || this.pending || !event.dataTransfer) { event.preventDefault(); return; }
|
|
60
|
+
if (this.pointerManaged || !this.movable || this.moveDisabled || this.pending || !event.dataTransfer) { event.preventDefault(); return; }
|
|
39
61
|
event.dataTransfer.setData('application/x-dees-kanban-ticket', ticket.id);
|
|
40
62
|
event.dataTransfer.effectAllowed = 'move';
|
|
41
63
|
kanbanEvent(this, 'kanban-drag-start', { ticketId: ticket.id });
|
|
42
|
-
}} @dragend=${() => kanbanEvent(this, 'kanban-drag-end', {})}>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
${
|
|
46
|
-
<button class="title" @click=${() => this.open(ticket.id)}>${ticket.title}</button>
|
|
47
|
-
${ticket.labels?.length ? html`<div class="row labels">${ticket.labels.map(label => html`<span class="badge">${label}</span>`)}</div>` : ''}
|
|
48
|
-
<div class="row caption"><span class="avatar" title=${ticket.assignee?.name ?? 'Unassigned'}>${ticket.assignee?.name.split(/\s+/).map(part => part[0]).join('').slice(0, 2) ?? '–'}</span>
|
|
49
|
-
<span>${ticket.assignee?.name ?? 'Unassigned'}</span><span class="spacer"></span>
|
|
50
|
-
${this.movable ? html`<dees-button size="icon" type="ghost" icon="lucide:Ellipsis" text="Move ticket" .disabled=${this.pending}
|
|
64
|
+
}} @dragend=${() => { if (!this.pointerManaged) kanbanEvent(this, 'kanban-drag-end', {}); }}>
|
|
65
|
+
<div class="meta"><span class="key">${ticket.key}</span>
|
|
66
|
+
${this.epic ? html`<button class="link epic" title=${this.epic.title} @click=${() => this.open(this.epic!.id)}>${this.epic.title}</button>` : ''}
|
|
67
|
+
${this.saving ? html`<span class="saving" role="status">Saving…</span>` : this.movable ? html`<dees-button class="move" size="icon" type="ghost" icon="lucide:Ellipsis" text="Move ticket" .disabled=${this.pending || this.moveDisabled}
|
|
51
68
|
@clicked=${(event: Event) => kanbanEvent(this, 'kanban-move-menu', { ticketId: ticket.id, anchor: event.currentTarget as HTMLElement })}></dees-button>` : ''}
|
|
52
69
|
</div>
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
<button class="title" @click=${() => this.open(ticket.id)}>${ticket.title}</button>
|
|
71
|
+
<div class="row footer">
|
|
72
|
+
${ticket.priority && ticket.priority !== 'none' ? html`<span class="priority" data-priority=${ticket.priority}><dees-icon .icon=${kanbanPriorityIcon(ticket.priority)} iconSize="13" aria-hidden="true"></dees-icon>${ticket.priority}</span>` : ''}
|
|
73
|
+
${ticket.labels?.map(label => html`<span class="badge">${label}</span>`)}
|
|
74
|
+
<span class="assignee" title=${ticket.assignee?.name ?? 'Unassigned'}><span class="avatar" aria-hidden="true">${ticket.assignee ? kanbanInitials(ticket.assignee.name) : '–'}</span><span class="assignee-name">${ticket.assignee?.name ?? 'Unassigned'}</span></span>
|
|
75
|
+
</div>
|
|
76
|
+
${this.subtasks.length ? html`<details><summary><dees-icon icon="lucide:ListChecks" iconSize="14" aria-hidden="true"></dees-icon>${completed}/${this.subtasks.length} subtasks complete<dees-icon class="disclosure" icon="lucide:ChevronRight" iconSize="14" aria-hidden="true"></dees-icon></summary>
|
|
77
|
+
${this.subtasks.map(task => html`<div class="subtask"><dees-icon .icon=${this.columns.some(column => column.id === task.columnId && column.done) ? 'lucide:CircleCheck' : 'lucide:Circle'} iconSize="14" aria-hidden="true"></dees-icon>
|
|
55
78
|
<button class="link" @click=${() => this.open(task.id)}>${task.title}</button></div>`)}</details>` : ''}
|
|
56
79
|
</article>`;
|
|
57
80
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { DeesElement, customElement, property, state, html, css } from '@design.estate/dees-element';
|
|
1
|
+
import { DeesElement, customElement, property, state, html, css, directives } from '@design.estate/dees-element';
|
|
2
2
|
import type { IKanbanColumn, IKanbanTicket } from '../interfaces.js';
|
|
3
|
+
import type { IKanbanMotion } from '../kanban.dragcontroller.js';
|
|
3
4
|
import { kanbanStyles } from '../kanban.styles.js';
|
|
4
5
|
import { kanbanEvent, sortKanbanTickets } from '../kanban.helpers.js';
|
|
5
6
|
import '../dees-kanban-card/dees-kanban-card.js';
|
|
@@ -13,48 +14,67 @@ export class DeesKanbanColumn extends DeesElement {
|
|
|
13
14
|
@property({ attribute: false }) accessor allTickets: IKanbanTicket[] = [];
|
|
14
15
|
@property({ attribute: false }) accessor columns: IKanbanColumn[] = [];
|
|
15
16
|
@property({ type: Boolean }) accessor movable = false;
|
|
17
|
+
@property({ type: Boolean }) accessor movesDisabled = false;
|
|
16
18
|
@property({ type: Boolean }) accessor canCreate = false;
|
|
17
19
|
@property({ type: String }) accessor pendingTicketId = '';
|
|
18
20
|
@property({ type: String }) accessor draggingTicketId = '';
|
|
19
|
-
@
|
|
20
|
-
@state() private accessor
|
|
21
|
+
@property({ attribute: false }) accessor pointerManaged = false;
|
|
22
|
+
@state() private accessor nativeDropBefore: string | undefined;
|
|
23
|
+
@state() private accessor nativeOver = false;
|
|
24
|
+
/** Internal render projection; provider-owned ticket data remains unchanged until saved. */
|
|
25
|
+
@property({ attribute: false }) accessor motion: IKanbanMotion | undefined;
|
|
21
26
|
public static styles = [...kanbanStyles, css`
|
|
22
|
-
:host { display: flex; flex-direction: column; width:
|
|
23
|
-
section { min-height: 220px; height: 100%; border-radius: var(--dees-radius-
|
|
24
|
-
header { display: flex; align-items: center; gap: 8px; padding:
|
|
25
|
-
h3 { font-size: var(--dees-font-
|
|
26
|
-
|
|
27
|
-
.
|
|
27
|
+
:host { display: flex; flex-direction: column; min-width: 272px; max-width: 360px; flex: 1 0 272px; }
|
|
28
|
+
section { min-height: 220px; height: 100%; border-radius: var(--dees-radius-xl); transition: background var(--dees-transition-default) var(--dees-ease-out); }
|
|
29
|
+
header { display: flex; align-items: center; gap: 8px; min-height: 40px; margin-bottom: 10px; padding: 0 2px 8px; border-bottom: 1px solid var(--dees-color-border-subtle); }
|
|
30
|
+
h3 { font-size: var(--dees-font-footnote-size); font-weight: 600; }
|
|
31
|
+
header dees-button::part(button) { width: 24px; height: 24px; min-height: 24px; }
|
|
32
|
+
.status { width: 8px; height: 8px; border: 1.5px solid var(--dees-color-text-secondary); border-radius: 50%; flex-shrink: 0; }
|
|
33
|
+
.status.done { background: var(--dees-color-accent-success); border-color: var(--dees-color-accent-success); }
|
|
34
|
+
.stack { display: grid; gap: 8px; min-height: 120px; align-content: start; }
|
|
28
35
|
.target { position: relative; }
|
|
29
|
-
.
|
|
30
|
-
.
|
|
31
|
-
|
|
36
|
+
.native-before::before { content: ''; position: absolute; top: -5px; left: 0; right: 0; height: 2px; background: var(--dees-color-accent-primary); border-radius: 2px; }
|
|
37
|
+
.placeholder { border-radius: var(--dees-radius-xl); background: var(--dees-color-fill-tertiary); box-shadow: inset 0 0 0 1px var(--dees-color-border-default); }
|
|
38
|
+
.end-target { position: relative; min-height: 32px; margin-top: 8px; }
|
|
39
|
+
section.over { background: var(--dees-color-row-hover); }
|
|
40
|
+
@media (prefers-reduced-motion: reduce) { section { transition: none; } }
|
|
41
|
+
@media (pointer: coarse) { header dees-button::part(button) { width: 32px; height: 32px; min-height: 32px; } }
|
|
32
42
|
`];
|
|
33
|
-
public updated(): void { if (!this.draggingTicketId) this.
|
|
43
|
+
public updated(): void { if (!this.draggingTicketId) this.nativeOver = false; }
|
|
34
44
|
public render() {
|
|
35
45
|
if (!this.column) return html``;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
const motion = this.motion;
|
|
47
|
+
const tickets = sortKanbanTickets(this.tickets).filter(ticket => ticket.id !== motion?.move.ticketId);
|
|
48
|
+
const active = motion?.move.columnId === this.column.id;
|
|
49
|
+
if (motion && active) {
|
|
50
|
+
const index = tickets.findIndex(ticket => ticket.id === motion.move.beforeTicketId);
|
|
51
|
+
tickets.splice(index < 0 ? tickets.length : index, 0, motion.ticket);
|
|
52
|
+
}
|
|
53
|
+
return html`<section class=${active && motion?.placeholder || this.nativeOver ? 'over' : ''} aria-label=${this.column.title}
|
|
54
|
+
@dragover=${(event: DragEvent) => { if (!this.pointerManaged && this.draggingTicketId && this.movable && !this.movesDisabled) { event.preventDefault(); this.nativeOver = true; if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; } }}
|
|
55
|
+
@dragleave=${(event: DragEvent) => { if (!(event.relatedTarget instanceof Node) || !(event.currentTarget as Node).contains(event.relatedTarget)) this.nativeOver = false; }}
|
|
39
56
|
@drop=${(event: DragEvent) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
57
|
+
if (this.pointerManaged) return;
|
|
58
|
+
event.preventDefault(); this.nativeOver = false;
|
|
59
|
+
if (this.movable && !this.movesDisabled && this.draggingTicketId && event.dataTransfer?.getData('application/x-dees-kanban-ticket') === this.draggingTicketId)
|
|
60
|
+
kanbanEvent(this, 'kanban-ticket-move', { ticketId: this.draggingTicketId, columnId: this.column!.id, beforeTicketId: this.nativeDropBefore });
|
|
43
61
|
}}>
|
|
44
|
-
<header><h3>${this.column.title}</h3><span class="count">${
|
|
62
|
+
<header><span class="status ${this.column.done ? 'done' : ''}" aria-hidden="true"></span><h3>${this.column.title}</h3><span class="count">${tickets.length}</span><span class="spacer"></span>
|
|
45
63
|
${this.canCreate ? html`<dees-button type="ghost" size="icon" icon="lucide:Plus" text=${`Add task to ${this.column.title}`}
|
|
46
64
|
@clicked=${() => kanbanEvent(this, 'kanban-ticket-create', { kind: 'task', columnId: this.column!.id })}></dees-button>` : ''}</header>
|
|
47
|
-
<div class="stack">${
|
|
48
|
-
<div class="target ${this.
|
|
49
|
-
@dragover=${() => { this.
|
|
50
|
-
|
|
65
|
+
<div class="stack">${directives.repeat(tickets, ticket => ticket.id, ticket => html`
|
|
66
|
+
<div class="target ${this.nativeOver && this.nativeDropBefore === ticket.id ? 'native-before' : ''}" data-ticket-id=${ticket.id}
|
|
67
|
+
@dragover=${() => { if (!this.pointerManaged) this.nativeDropBefore = ticket.id; }}>
|
|
68
|
+
${motion?.move.ticketId === ticket.id && motion.placeholder ? html`<div class="placeholder" style=${`height: ${motion.height}px`} aria-label="Drop ticket here"></div>` : html`<dees-kanban-card .ticket=${ticket} .columns=${this.columns}
|
|
51
69
|
.epic=${this.allTickets.find(item => item.id === ticket.parentId && item.kind === 'epic')}
|
|
52
70
|
.subtasks=${sortKanbanTickets(this.allTickets.filter(item => item.parentId === ticket.id && item.kind === 'subtask'))}
|
|
53
|
-
.movable=${this.movable && ticket.permissions?.move !== false} .pending=${this.pendingTicketId === ticket.id}
|
|
71
|
+
.movable=${this.movable && ticket.permissions?.move !== false} .pending=${motion?.move.ticketId === ticket.id ? motion.dimmed : this.pendingTicketId === ticket.id}
|
|
72
|
+
.moveDisabled=${this.movesDisabled}
|
|
73
|
+
.saving=${motion?.move.ticketId === ticket.id && motion.saving} .pointerManaged=${this.pointerManaged}></dees-kanban-card>`}
|
|
54
74
|
</div>`)}
|
|
55
|
-
${!
|
|
75
|
+
${!tickets.length ? html`<div class="empty" @dragover=${() => { this.nativeDropBefore = undefined; }}>No tasks</div>` : ''}
|
|
56
76
|
</div>
|
|
57
|
-
<div class="end-target ${this.
|
|
77
|
+
<div class="end-target ${this.nativeOver && !this.nativeDropBefore ? 'native-before' : ''}" @dragover=${() => { this.nativeDropBefore = undefined; }}></div>
|
|
58
78
|
</section>`;
|
|
59
79
|
}
|
|
60
80
|
}
|