@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.
Files changed (31) hide show
  1. package/dist_bundle/bundle.js +823 -170
  2. package/dist_bundle/bundle.js.map +1 -1
  3. package/dist_ts_web/00_commitinfo_data.js +1 -1
  4. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.js +4 -4
  5. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.d.ts +3 -1
  6. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.js +75 -46
  7. package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.d.ts +4 -0
  8. package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.js +72 -27
  9. package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.d.ts +7 -2
  10. package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.js +87 -45
  11. package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.js +26 -15
  12. package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.d.ts +2 -0
  13. package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.js +73 -40
  14. package/dist_ts_web/elements/00group-kanban/kanban.dragcontroller.d.ts +67 -0
  15. package/dist_ts_web/elements/00group-kanban/kanban.dragcontroller.js +548 -0
  16. package/dist_ts_web/elements/00group-kanban/kanban.helpers.d.ts +3 -1
  17. package/dist_ts_web/elements/00group-kanban/kanban.helpers.js +3 -1
  18. package/dist_ts_web/elements/00group-kanban/kanban.styles.js +11 -3
  19. package/package.json +1 -1
  20. package/readme.md +19 -4
  21. package/scripts/check-packed-consumer.mjs +1 -1
  22. package/ts_web/00_commitinfo_data.ts +1 -1
  23. package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.ts +3 -3
  24. package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.ts +61 -34
  25. package/ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.ts +47 -24
  26. package/ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.ts +47 -27
  27. package/ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.ts +25 -14
  28. package/ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.ts +64 -38
  29. package/ts_web/elements/00group-kanban/kanban.dragcontroller.ts +481 -0
  30. package/ts_web/elements/00group-kanban/kanban.helpers.ts +5 -1
  31. 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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoia2FuYmFuLmhlbHBlcnMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90c193ZWIvZWxlbWVudHMvMDBncm91cC1rYW5iYW4va2FuYmFuLmhlbHBlcnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBRUEsTUFBTSxDQUFDLE1BQU0saUJBQWlCLEdBQUcsQ0FBQyxPQUFpQyxFQUFtQixFQUFFLENBQ3RGLENBQUMsR0FBRyxPQUFPLENBQUMsQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxFQUFFLEVBQUUsQ0FBQyxDQUFDLENBQUMsS0FBSyxHQUFHLENBQUMsQ0FBQyxLQUFLLElBQUksQ0FBQyxDQUFDLEVBQUUsQ0FBQyxhQUFhLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUM7QUFDN0UsTUFBTSxDQUFDLE1BQU0sV0FBVyxHQUFHLENBQUMsTUFBcUIsRUFBVSxFQUFFLENBQUMsSUFBSSxNQUFNLENBQUMsR0FBRyxJQUFJLE1BQU0sQ0FBQyxLQUFLLEVBQUUsQ0FBQztBQUMvRixNQUFNLENBQUMsTUFBTSxXQUFXLEdBQUcsQ0FBQyxNQUFxQixFQUFzQixFQUFFLENBQUMsQ0FBQztJQUN6RSxLQUFLLEVBQUUsTUFBTSxDQUFDLEtBQUssRUFBRSxXQUFXLEVBQUUsTUFBTSxDQUFDLFdBQVcsSUFBSSxFQUFFLEVBQUUsUUFBUSxFQUFFLE1BQU0sQ0FBQyxRQUFRLElBQUksTUFBTTtJQUMvRixVQUFVLEVBQUUsTUFBTSxDQUFDLFFBQVEsRUFBRSxFQUFFLElBQUksRUFBRSxFQUFFLFFBQVEsRUFBRSxNQUFNLENBQUMsUUFBUSxJQUFJLEVBQUU7Q0FDdkUsQ0FBQyxDQUFDO0FBQ0gsTUFBTSxDQUFDLE1BQU0sV0FBVyxHQUFHLENBQUksS0FBa0IsRUFBRSxJQUFZLEVBQUUsTUFBUyxFQUFRLEVBQUU7SUFDbEYsS0FBSyxDQUFDLGFBQWEsQ0FBQyxJQUFJLFdBQVcsQ0FBQyxJQUFJLEVBQUUsRUFBRSxNQUFNLEVBQUUsT0FBTyxFQUFFLElBQUksRUFBRSxRQUFRLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQyxDQUFDO0FBQ3hGLENBQUMsQ0FBQztBQUNGLE1BQU0sQ0FBQyxNQUFNLFdBQVcsR0FBRyxDQUFDLEtBQWMsRUFBVSxFQUFFLENBQUMsS0FBSyxZQUFZLEtBQUssQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQyxDQUFDLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDIn0=
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-body-size); }
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-badge-default-bg); padding: 3px 7px; border-radius: var(--dees-radius-sm); font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); }
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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoia2FuYmFuLnN0eWxlcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3RzX3dlYi9lbGVtZW50cy8wMGdyb3VwLWthbmJhbi9rYW5iYW4uc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxHQUFHLEVBQUUsVUFBVSxFQUFFLE1BQU0sNkJBQTZCLENBQUM7QUFDOUQsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sZUFBZSxDQUFDO0FBRW5ELE1BQU0sQ0FBQyxNQUFNLFlBQVksR0FBRyxDQUFDLGtCQUFrQixFQUFFLFVBQVUsQ0FBQyxhQUFhLEVBQUUsR0FBRyxDQUFBOzs7Ozs7Ozs7Ozs7OztDQWM3RSxDQUFDLENBQUMifQ==
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.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 the board; the provider must assign ordering
2646
- atomically. Omitting `beforeTicketId` appends. Cards support drag-and-drop and a
2647
- keyboard-accessible Move menu with within-column ordering and column selection.
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 emits `kanban-ticket-move` with the destination and insertion target.
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.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-badge-default-bg); cursor: pointer; font-weight: 500; }
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', 'kanban-drag-start', 'kanban-drag-end'] as const;
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: 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; } }
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="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}
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" size="sm" text="Open epic" @clicked=${() => { void this.openTicket(epic.id); }}></dees-button>` : ''}
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.map(column => html`<dees-kanban-column .column=${column} .columns=${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) && !this.pendingTicketId} .canCreate=${Boolean(this.provider.createTicket)}
107
- .pendingTicketId=${this.pendingTicketId} .draggingTicketId=${this.draggingTicketId}></dees-kanban-column>`)}</div>`}`;
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
- this.pendingTicketId = ticket.id; this.operationError = ''; this.draggingTicketId = '';
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
- } catch (error) { this.reportError(error); }
227
- finally { this.pendingTicketId = ''; this.syncDetail(); }
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: 14px; border: 1px solid var(--dees-color-border-default); border-radius: var(--dees-radius-lg); background: var(--dees-color-bg-primary); box-shadow: var(--dees-shadow-xs); }
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
- .title { display: block; width: 100%; border: 0; background: transparent; padding: 8px 0 12px; color: inherit; text-align: left; cursor: pointer; font: inherit; font-weight: 550; line-height: 1.45; overflow-wrap: anywhere; }
23
- .key { font-variant-numeric: tabular-nums; }
24
- .link.epic { margin-bottom: 5px; max-width: 100%; font-size: var(--dees-font-caption1-size); }
25
- .labels { margin-bottom: 10px; }
26
- .priority[data-priority=urgent], .priority[data-priority=high] { color: var(--dees-color-text-warning); }
27
- .avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--dees-color-badge-default-bg); display: inline-grid; place-items: center; font-size: 10px; font-weight: 600; }
28
- summary { cursor: pointer; padding: 10px 0 2px; font-size: var(--dees-font-footnote-size); color: var(--dees-color-text-secondary); }
29
- .subtask { display: flex; gap: 6px; align-items: baseline; padding-top: 8px; font-size: var(--dees-font-footnote-size); }
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)} draggable=${this.movable && !this.pending ? 'true' : 'false'}
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
- ${this.epic ? html`<button class="link epic" @click=${() => this.open(this.epic!.id)}>${this.epic.title}</button>` : ''}
44
- <div class="row caption"><span class="key">${ticket.key}</span><span class="spacer"></span>
45
- ${ticket.priority && ticket.priority !== 'none' ? html`<span class="priority" data-priority=${ticket.priority}>${ticket.priority}</span>` : ''}</div>
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
- ${this.subtasks.length ? html`<details><summary>${completed}/${this.subtasks.length} subtasks complete</summary>
54
- ${this.subtasks.map(task => html`<div class="subtask"><span>${this.columns.some(column => column.id === task.columnId && column.done) ? '✓' : '○'}</span>
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
- @state() private accessor dropBefore: string | undefined;
20
- @state() private accessor draggingOver = false;
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: 300px; min-width: 270px; flex: 1 0 270px; }
23
- section { min-height: 220px; height: 100%; border-radius: var(--dees-radius-lg); background: var(--dees-color-bg-secondary); padding: 10px; }
24
- header { display: flex; align-items: center; gap: 8px; padding: 3px 3px 12px; }
25
- h3 { font-size: var(--dees-font-body-size); font-weight: 600; }
26
- .count { font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); }
27
- .stack { display: grid; gap: 10px; min-height: 80px; align-content: start; }
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
- .target.before::before, .end-target.before::before { content: ''; display: block; height: 3px; background: var(--dees-color-accent-primary); border-radius: 2px; margin-bottom: 7px; }
30
- .end-target { min-height: 20px; padding-top: 10px; }
31
- section.over { outline: 2px solid var(--dees-color-focus-ring); outline-offset: 1px; }
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.draggingOver = false; }
43
+ public updated(): void { if (!this.draggingTicketId) this.nativeOver = false; }
34
44
  public render() {
35
45
  if (!this.column) return html``;
36
- return html`<section class=${this.draggingOver ? 'over' : ''} aria-label=${this.column.title}
37
- @dragover=${(event: DragEvent) => { if (this.draggingTicketId && this.movable) { event.preventDefault(); this.draggingOver = true; if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; } }}
38
- @dragleave=${(event: DragEvent) => { if (!(event.relatedTarget instanceof Node) || !(event.currentTarget as Node).contains(event.relatedTarget)) this.draggingOver = false; }}
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
- event.preventDefault(); this.draggingOver = false;
41
- if (this.movable && this.draggingTicketId && event.dataTransfer?.getData('application/x-dees-kanban-ticket') === this.draggingTicketId)
42
- kanbanEvent(this, 'kanban-ticket-move', { ticketId: this.draggingTicketId, columnId: this.column!.id, beforeTicketId: this.dropBefore });
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">${this.tickets.length}</span><span class="spacer"></span>
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">${sortKanbanTickets(this.tickets).map(ticket => html`
48
- <div class="target ${this.draggingOver && this.dropBefore === ticket.id ? 'before' : ''}"
49
- @dragover=${() => { this.dropBefore = ticket.id; }}>
50
- <dees-kanban-card .ticket=${ticket} .columns=${this.columns}
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}></dees-kanban-card>
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
- ${!this.tickets.length ? html`<div class="empty" @dragover=${() => { this.dropBefore = undefined; }}>No tasks</div>` : ''}
75
+ ${!tickets.length ? html`<div class="empty" @dragover=${() => { this.nativeDropBefore = undefined; }}>No tasks</div>` : ''}
56
76
  </div>
57
- <div class="end-target ${this.draggingOver && !this.dropBefore ? 'before' : ''}" @dragover=${() => { this.dropBefore = undefined; }}></div>
77
+ <div class="end-target ${this.nativeOver && !this.nativeDropBefore ? 'native-before' : ''}" @dragover=${() => { this.nativeDropBefore = undefined; }}></div>
58
78
  </section>`;
59
79
  }
60
80
  }