@erplora/outfitkit
Advanced tools
| import './ok-kanban'; |
@@ -39,9 +39,18 @@ import { LitElement } from 'lit'; | ||
| private lastColumnsRef; | ||
| private pointerDrag; | ||
| private suppressClick; | ||
| private suppressClickTimer; | ||
| private syncFromProp; | ||
| private onDragStart; | ||
| private onDragEnd; | ||
| private onColumnDragOver; | ||
| private onCardDragOver; | ||
| private onColumnDrop; | ||
| private onCardDrop; | ||
| private capturePointer; | ||
| private releasePointer; | ||
| private onPointerDown; | ||
| /** Elemento real bajo el puntero capturado, dentro del shadow root. */ | ||
| private elementFromPoint; | ||
| /** Actualiza columna y posición de inserción a partir de las coordenadas del puntero. */ | ||
| private updateDropTarget; | ||
| private onPointerMove; | ||
| private suppressNextCardClick; | ||
| private onPointerUp; | ||
| private onPointerCancel; | ||
| private commitMove; | ||
@@ -52,2 +61,3 @@ private onCardClick; | ||
| render(): unknown; | ||
| disconnectedCallback(): void; | ||
| } | ||
@@ -54,0 +64,0 @@ declare global { |
+147
-47
@@ -16,2 +16,3 @@ import { LitElement, css, html } from "lit"; | ||
| }; | ||
| const DRAG_THRESHOLD_PX = 5; | ||
| class OkKanban extends LitElement { | ||
@@ -28,2 +29,5 @@ constructor() { | ||
| this.lastColumnsRef = null; | ||
| this.pointerDrag = null; | ||
| this.suppressClick = false; | ||
| this.suppressClickTimer = null; | ||
| } | ||
@@ -170,2 +174,5 @@ static { | ||
| user-select: none; | ||
| position: relative; | ||
| padding-inline-end: 3.25rem; | ||
| -webkit-user-drag: none; | ||
| transition: background-color var(--ok-transition, 150ms ease), | ||
@@ -192,2 +199,23 @@ color var(--ok-transition, 150ms ease), border-color var(--ok-transition, 150ms ease), | ||
| } | ||
| /* El agarre separa las dos intenciones táctiles: deslizar sobre la tarjeta conserva el scroll | ||
| natural del tablero; deslizar desde aquí mueve la tarjeta. Solo este target bloquea el pan | ||
| nativo, de modo que el kanban sigue siendo navegable cuando una columna está llena. */ | ||
| .drag-handle { | ||
| position: absolute; | ||
| inset-block-start: 0.15rem; | ||
| inset-inline-end: 0.1rem; | ||
| display: inline-flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| width: 44px; | ||
| height: 44px; | ||
| color: var(--color-muted); | ||
| font-size: 1.15rem; | ||
| line-height: 1; | ||
| cursor: grab; | ||
| touch-action: none; | ||
| } | ||
| .drag-handle:active { | ||
| cursor: grabbing; | ||
| } | ||
| @media (prefers-reduced-motion: reduce) { | ||
@@ -248,10 +276,3 @@ .card:not(.dragging):hover, | ||
| } | ||
| // ---- Drag & drop nativo HTML5 --------------------------------------------------------------- | ||
| onDragStart(e, fromColumn, cardId) { | ||
| this.drag = { cardId, fromColumn }; | ||
| if (e.dataTransfer) { | ||
| e.dataTransfer.effectAllowed = "move"; | ||
| e.dataTransfer.setData("text/plain", cardId); | ||
| } | ||
| } | ||
| // ---- Drag & drop con Pointer Events --------------------------------------------------------- | ||
| onDragEnd() { | ||
@@ -263,40 +284,100 @@ this.drag = null; | ||
| } | ||
| // Mientras se arrastra sobre una columna: permite soltar y resalta la columna. | ||
| onColumnDragOver(e, columnId) { | ||
| if (!this.drag) return; | ||
| e.preventDefault(); | ||
| if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; | ||
| if (this.overColumn !== columnId) this.overColumn = columnId; | ||
| this.overCardId = null; | ||
| this.overAfter = false; | ||
| capturePointer(target, pointerId) { | ||
| try { | ||
| target.setPointerCapture?.(pointerId); | ||
| } catch { | ||
| } | ||
| } | ||
| // Mientras se arrastra sobre una tarjeta: calcula si la inserción va antes o después según | ||
| // la posición vertical del puntero respecto al centro de la tarjeta. | ||
| onCardDragOver(e, columnId, cardId) { | ||
| if (!this.drag) return; | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; | ||
| const target = e.currentTarget; | ||
| const rect = target.getBoundingClientRect(); | ||
| const after = e.clientY > rect.top + rect.height / 2; | ||
| if (this.overColumn !== columnId) this.overColumn = columnId; | ||
| if (this.overCardId !== cardId || this.overAfter !== after) { | ||
| this.overCardId = cardId; | ||
| this.overAfter = after; | ||
| releasePointer(state2) { | ||
| try { | ||
| if (state2.captureTarget.hasPointerCapture?.(state2.pointerId)) { | ||
| state2.captureTarget.releasePointerCapture?.(state2.pointerId); | ||
| } | ||
| } catch { | ||
| } | ||
| } | ||
| // Suelta sobre la columna (zona vacía o tras propagación): inserta al final si no hay tarjeta diana. | ||
| onColumnDrop(e, toColumn) { | ||
| if (!this.drag) return; | ||
| onPointerDown(e, fromColumn, cardId) { | ||
| if (this.pointerDrag || e.button !== 0) return; | ||
| if (e.pointerType === "touch" && !(e.target instanceof Element && e.target.closest(".drag-handle"))) { | ||
| return; | ||
| } | ||
| const captureTarget = e.currentTarget; | ||
| this.pointerDrag = { | ||
| pointerId: e.pointerId, | ||
| startX: e.clientX, | ||
| startY: e.clientY, | ||
| active: false, | ||
| captureTarget, | ||
| cardId, | ||
| fromColumn | ||
| }; | ||
| this.capturePointer(captureTarget, e.pointerId); | ||
| } | ||
| /** Elemento real bajo el puntero capturado, dentro del shadow root. */ | ||
| elementFromPoint(x, y) { | ||
| const root = this.renderRoot; | ||
| return root.elementFromPoint?.(x, y) ?? null; | ||
| } | ||
| /** Actualiza columna y posición de inserción a partir de las coordenadas del puntero. */ | ||
| updateDropTarget(e) { | ||
| const hit = this.elementFromPoint(e.clientX, e.clientY); | ||
| const column = hit?.closest("[data-column-id]") ?? null; | ||
| const columnId = column?.dataset.columnId; | ||
| if (!column || !columnId || !this.renderRoot.contains(column)) { | ||
| this.overColumn = null; | ||
| this.overCardId = null; | ||
| this.overAfter = false; | ||
| return; | ||
| } | ||
| this.overColumn = columnId; | ||
| const targetCard = hit?.closest("[data-card-id]") ?? null; | ||
| if (!targetCard || targetCard.closest("[data-column-id]") !== column) { | ||
| this.overCardId = null; | ||
| this.overAfter = false; | ||
| return; | ||
| } | ||
| const rect = targetCard.getBoundingClientRect(); | ||
| this.overCardId = targetCard.dataset.cardId ?? null; | ||
| this.overAfter = e.clientY > rect.top + rect.height / 2; | ||
| } | ||
| onPointerMove(e) { | ||
| const state2 = this.pointerDrag; | ||
| if (!state2 || state2.pointerId !== e.pointerId) return; | ||
| if (!state2.active) { | ||
| const distance = Math.hypot(e.clientX - state2.startX, e.clientY - state2.startY); | ||
| if (distance < DRAG_THRESHOLD_PX) return; | ||
| state2.active = true; | ||
| this.drag = { cardId: state2.cardId, fromColumn: state2.fromColumn }; | ||
| } | ||
| e.preventDefault(); | ||
| this.commitMove(toColumn); | ||
| this.updateDropTarget(e); | ||
| } | ||
| // Suelta sobre una tarjeta concreta: inserta antes/después de ella. | ||
| onCardDrop(e, toColumn) { | ||
| if (!this.drag) return; | ||
| suppressNextCardClick() { | ||
| this.suppressClick = true; | ||
| if (this.suppressClickTimer) clearTimeout(this.suppressClickTimer); | ||
| this.suppressClickTimer = setTimeout(() => { | ||
| this.suppressClick = false; | ||
| this.suppressClickTimer = null; | ||
| }, 0); | ||
| } | ||
| onPointerUp(e) { | ||
| const state2 = this.pointerDrag; | ||
| if (!state2 || state2.pointerId !== e.pointerId) return; | ||
| this.releasePointer(state2); | ||
| this.pointerDrag = null; | ||
| if (!state2.active) return; | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| this.commitMove(toColumn); | ||
| this.updateDropTarget(e); | ||
| const toColumn = this.overColumn; | ||
| this.suppressNextCardClick(); | ||
| if (toColumn) this.commitMove(toColumn); | ||
| else this.onDragEnd(); | ||
| } | ||
| onPointerCancel(e) { | ||
| const state2 = this.pointerDrag; | ||
| if (!state2 || state2.pointerId !== e.pointerId) return; | ||
| this.releasePointer(state2); | ||
| this.pointerDrag = null; | ||
| this.onDragEnd(); | ||
| } | ||
| // Aplica el movimiento en el modelo interno y emite `ok-card-move`. | ||
@@ -346,2 +427,8 @@ commitMove(toColumn) { | ||
| onCardClick(card) { | ||
| if (this.suppressClick) { | ||
| this.suppressClick = false; | ||
| if (this.suppressClickTimer) clearTimeout(this.suppressClickTimer); | ||
| this.suppressClickTimer = null; | ||
| return; | ||
| } | ||
| this.dispatchEvent( | ||
@@ -367,9 +454,11 @@ new CustomEvent("ok-card-click", { | ||
| class=${classes} | ||
| draggable="true" | ||
| @dragstart=${(e) => this.onDragStart(e, column.id, card.id)} | ||
| @dragend=${() => this.onDragEnd()} | ||
| @dragover=${(e) => this.onCardDragOver(e, column.id, card.id)} | ||
| @drop=${(e) => this.onCardDrop(e, column.id)} | ||
| data-card-id=${card.id} | ||
| @pointerdown=${(e) => this.onPointerDown(e, column.id, card.id)} | ||
| @click=${() => this.onCardClick(card)} | ||
| > | ||
| <span | ||
| class="drag-handle" | ||
| aria-hidden="true" | ||
| @click=${(e) => e.stopPropagation()} | ||
| >⠿</span> | ||
| <div class="card-title">${card.title}</div> | ||
@@ -387,4 +476,3 @@ ${card.subtitle ? html`<div class="card-subtitle">${card.subtitle}</div>` : ""} | ||
| class=${`column ${over ? "drag-over" : ""}`.trim()} | ||
| @dragover=${(e) => this.onColumnDragOver(e, column.id)} | ||
| @drop=${(e) => this.onColumnDrop(e, column.id)} | ||
| data-column-id=${column.id} | ||
| > | ||
@@ -407,6 +495,18 @@ <header class="column-header"> | ||
| } | ||
| return html`<div class="board"> | ||
| return html`<div | ||
| class="board" | ||
| @pointermove=${this.onPointerMove} | ||
| @pointerup=${this.onPointerUp} | ||
| @pointercancel=${this.onPointerCancel} | ||
| > | ||
| ${this.view.map((column) => this.renderColumn(column))} | ||
| </div>`; | ||
| } | ||
| disconnectedCallback() { | ||
| super.disconnectedCallback(); | ||
| if (this.pointerDrag) this.releasePointer(this.pointerDrag); | ||
| this.pointerDrag = null; | ||
| if (this.suppressClickTimer) clearTimeout(this.suppressClickTimer); | ||
| this.suppressClickTimer = null; | ||
| } | ||
| } | ||
@@ -413,0 +513,0 @@ __decorateClass([ |
+1
-1
| { | ||
| "name": "@erplora/outfitkit", | ||
| "version": "0.1.39", | ||
| "version": "0.1.40", | ||
| "description": "OutfitKit — librería de Web Components (Lit) que CONSTRUYE lo que Ionic no tiene (tree, data-table rica, inline-feedback, kpi/stat, stepper/wizard, calendar, kanban…) sobre primitivos de Ionic. Ionic es la base; OutfitKit cubre los huecos. npm + CDN, imports individuales, CSP-safe. Tema vía tokens --ok-* (fallback a --ion-*).", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+1
-1
@@ -155,3 +155,3 @@ # OutfitKit (`@erplora/outfitkit`) | ||
| | `ok-scheduler` | Timeline de recursos/turnos (filas × franjas horarias) con eventos posicionados. | `ok-event-click`, `ok-slot-click`, `ok-nav` | | ||
| | `ok-kanban` | Tablero Kanban con HTML5 drag&drop (sin libs), columnas y tarjetas reordenables. | `ok-card-move`, `ok-card-click` | | ||
| | `ok-kanban` | Tablero Kanban con Pointer Events (ratón, lápiz y agarre táctil; sin libs), columnas y tarjetas reordenables. | `ok-card-move`, `ok-card-click` | | ||
| | `ok-timeline` | Línea de tiempo vertical con estados (done/current/pending), modo alternado. | `ok-item-click` | | ||
@@ -158,0 +158,0 @@ |
Sorry, the diff of this file is too big to display
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
2137249
0.29%296
0.34%61795
0.32%