@erplora/outfitkit
Advanced tools
| import './ok-scheduler'; |
@@ -32,2 +32,23 @@ import { LitElement } from 'lit'; | ||
| } | ||
| /** Detail of `ok-event-move`. `revert()` is how the host says «the server said no». */ | ||
| export interface OkSchedulerMoveDetail { | ||
| /** Id of the moved event. */ | ||
| id: string; | ||
| /** Resource the block was dropped on (may be the same one). */ | ||
| resourceId: string; | ||
| /** New start, `HH:MM` local wall clock — same shape as `ok-slot-click`. */ | ||
| start: string; | ||
| /** New end, `HH:MM`. The duration is preserved; the grid never resizes an event. */ | ||
| end: string; | ||
| /** Where the block came from, so the host can build an undo. */ | ||
| from: { | ||
| resourceId: string; | ||
| start: string; | ||
| end: string; | ||
| }; | ||
| /** The original event object, untouched. */ | ||
| event: OkSchedulerEvent; | ||
| /** Puts the block back where it was. Call it when the command is rejected. */ | ||
| revert: () => void; | ||
| } | ||
| export declare class OkScheduler extends LitElement { | ||
@@ -51,2 +72,12 @@ static styles: import("lit").CSSResult; | ||
| labels: Partial<OkSchedulerLabels>; | ||
| /** | ||
| * Permite mover los bloques (arrastre + teclado) emitiendo `ok-event-move`. | ||
| * OPT-IN: sin un host que escuche y persista el movimiento, mover sería mentir. | ||
| */ | ||
| movable: boolean; | ||
| /** | ||
| * Rejilla imantada del movimiento, en minutos. 15 es el intervalo de reserva estándar de una | ||
| * agenda; `slot-minutes` solo dibuja las columnas y suele ser mucho más grueso (una hora). | ||
| */ | ||
| snapMin: number; | ||
| /** Textos efectivos: defaults INGLÉS mezclados con los del consumidor. */ | ||
@@ -56,2 +87,10 @@ private get t(); | ||
| private seeded; | ||
| private drag; | ||
| private pending; | ||
| private announcement; | ||
| private heldId; | ||
| private pointerDrag; | ||
| private suppressClick; | ||
| private suppressClickTimer; | ||
| protected willUpdate(changed: Map<string, unknown>): void; | ||
| private dayKey; | ||
@@ -66,2 +105,20 @@ private parseDay; | ||
| private clickSlot; | ||
| private get dayStartMin(); | ||
| private placement; | ||
| private snapStart; | ||
| private requestMove; | ||
| private capturePointer; | ||
| private releasePointer; | ||
| private onEventPointerDown; | ||
| private endGesture; | ||
| /** Elemento real bajo el puntero capturado, dentro del shadow root. */ | ||
| private elementFromPoint; | ||
| private dropTarget; | ||
| private onPointerMove; | ||
| private readonly blockScrollWhileDragging; | ||
| connectedCallback(): void; | ||
| private suppressNextEventClick; | ||
| private onPointerUp; | ||
| private onPointerCancel; | ||
| private onEventKeyDown; | ||
| private dayLabel; | ||
@@ -73,2 +130,3 @@ private initials; | ||
| render(): unknown; | ||
| disconnectedCallback(): void; | ||
| } | ||
@@ -75,0 +133,0 @@ declare global { |
+1
-1
@@ -46,3 +46,3 @@ export { createStore, store } from './store/store.js'; | ||
| export { OkScheduler } from './components/ok-scheduler/ok-scheduler.js'; | ||
| export type { OkSchedulerResource, OkSchedulerEvent } from './components/ok-scheduler/ok-scheduler.js'; | ||
| export type { OkSchedulerResource, OkSchedulerEvent, OkSchedulerMoveDetail } from './components/ok-scheduler/ok-scheduler.js'; | ||
| export { OkMenubar } from './components/ok-menubar/ok-menubar.js'; | ||
@@ -49,0 +49,0 @@ export type { OkMenu as OkMenubarMenu, OkMenuItem as OkMenubarMenuItem } from './components/ok-menubar/ok-menubar.js'; |
+322
-11
@@ -19,2 +19,5 @@ import { LitElement, css, html } from "lit"; | ||
| }; | ||
| const DRAG_THRESHOLD_PX = 5; | ||
| const TOUCH_HOLD_MS = 400; | ||
| const TOUCH_HOLD_TOLERANCE_PX = 10; | ||
| class OkScheduler extends LitElement { | ||
@@ -31,4 +34,16 @@ constructor() { | ||
| this.labels = {}; | ||
| this.movable = false; | ||
| this.snapMin = 15; | ||
| this.cursor = /* @__PURE__ */ new Date(); | ||
| this.seeded = false; | ||
| this.drag = null; | ||
| this.pending = null; | ||
| this.announcement = ""; | ||
| this.heldId = null; | ||
| this.pointerDrag = null; | ||
| this.suppressClick = false; | ||
| this.suppressClickTimer = null; | ||
| this.blockScrollWhileDragging = (e) => { | ||
| if (this.pointerDrag?.held) e.preventDefault(); | ||
| }; | ||
| } | ||
@@ -244,2 +259,50 @@ static { | ||
| } | ||
| /* Solo cuando el host activa movable: el bloque es una superficie de arrastre. | ||
| NO se pone touch-action:none — el dedo tiene que poder hacer scroll de la rejilla desde | ||
| encima del bloque. El arrastre táctil se arma con la pulsación mantenida y a partir de ahí | ||
| el scroll se corta a mano (preventDefault del touchmove). */ | ||
| .event.movable { | ||
| cursor: grab; | ||
| } | ||
| /* Pulsación mantenida completada: el bloque "se levanta" y avisa de que ya está cogido. */ | ||
| .event.held { | ||
| transform: scale(1.03); | ||
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); | ||
| } | ||
| .event.dragging { | ||
| cursor: grabbing; | ||
| z-index: 5; | ||
| opacity: 0.92; | ||
| box-shadow: 0 6px 16px rgba(0, 0, 0, 0.32); | ||
| /* Deja pasar el hit-test al carril de debajo para saber sobre qué recurso está. */ | ||
| pointer-events: none; | ||
| transition: none; | ||
| } | ||
| .event:focus-visible { | ||
| outline: 2px solid var(--primary-color); | ||
| outline-offset: 2px; | ||
| } | ||
| /* Hueco de origen: dice de dónde salió el bloque mientras está en el aire. */ | ||
| .ghost { | ||
| position: absolute; | ||
| top: 0.25rem; | ||
| bottom: 0.25rem; | ||
| border-radius: 6px; | ||
| border: 2px dashed var(--border-color); | ||
| background: var(--hover-bg); | ||
| pointer-events: none; | ||
| z-index: 0; | ||
| } | ||
| /* Anuncio para lector de pantalla del movimiento por teclado. */ | ||
| .sr-only { | ||
| position: absolute; | ||
| width: 1px; | ||
| height: 1px; | ||
| padding: 0; | ||
| margin: -1px; | ||
| overflow: hidden; | ||
| clip: rect(0 0 0 0); | ||
| white-space: nowrap; | ||
| border: 0; | ||
| } | ||
| .event-title { | ||
@@ -270,3 +333,4 @@ font-size: 0.78rem; | ||
| .slot:active, | ||
| .event:active { | ||
| .event:active, | ||
| .event.held { | ||
| transform: none; | ||
@@ -302,2 +366,6 @@ } | ||
| } | ||
| // El refresco del host manda: descarta la posición optimista en cuanto llegan eventos nuevos. | ||
| willUpdate(changed) { | ||
| if (changed.has("events")) this.pending = null; | ||
| } | ||
| // ── Helpers de fecha ────────────────────────────────────────── | ||
@@ -361,2 +429,3 @@ // Convierte una `Date` a clave local `YYYY-MM-DD`. | ||
| e.stopPropagation(); | ||
| if (this.suppressClick) return; | ||
| this.dispatchEvent( | ||
@@ -380,2 +449,195 @@ new CustomEvent("ok-event-click", { | ||
| } | ||
| // ── Mover un bloque ─────────────────────────────────────────── | ||
| // Primer y último minuto pintables de la franja. | ||
| get dayStartMin() { | ||
| return this.startHour * 60; | ||
| } | ||
| // Dónde está un bloque AHORA: el gesto en curso y el movimiento sin confirmar mandan sobre la | ||
| // prop, para que el bloque no vuelva a saltar a su sitio viejo entre el drop y el refresco. | ||
| placement(ev) { | ||
| if (this.drag?.id === ev.id) return this.drag; | ||
| if (this.pending?.id === ev.id) return this.pending; | ||
| return { | ||
| resourceId: ev.resourceId, | ||
| startMin: this.minutesOf(ev.start), | ||
| endMin: this.minutesOf(ev.end) | ||
| }; | ||
| } | ||
| // Imanta el inicio a la rejilla y garantiza que el bloque entero cabe en la franja visible. | ||
| snapStart(startMin, durationMin) { | ||
| const step = this.snapMin > 0 ? this.snapMin : 1; | ||
| const snapped = Math.round(startMin / step) * step; | ||
| const last = this.dayStartMin + this.rangeMinutes - durationMin; | ||
| return Math.min(Math.max(snapped, this.dayStartMin), Math.max(this.dayStartMin, last)); | ||
| } | ||
| // Único sitio donde nace un movimiento (arrastre y teclado). Pinta optimista y pregunta al host. | ||
| requestMove(ev, from, to) { | ||
| if (to.resourceId === from.resourceId && to.startMin === from.startMin) return; | ||
| const move = { id: ev.id, ...to }; | ||
| this.pending = move; | ||
| const detail = { | ||
| id: ev.id, | ||
| resourceId: to.resourceId, | ||
| start: this.fmtTime(to.startMin), | ||
| end: this.fmtTime(to.endMin), | ||
| from: { | ||
| resourceId: from.resourceId, | ||
| start: this.fmtTime(from.startMin), | ||
| end: this.fmtTime(from.endMin) | ||
| }, | ||
| event: ev, | ||
| // Rechazo del host: el bloque vuelve. Se comprueba la identidad para no deshacer el | ||
| // movimiento SIGUIENTE si el revert llega tarde. | ||
| revert: () => { | ||
| if (this.pending === move) this.pending = null; | ||
| } | ||
| }; | ||
| this.dispatchEvent( | ||
| new CustomEvent("ok-event-move", { | ||
| detail, | ||
| bubbles: true, | ||
| composed: true | ||
| }) | ||
| ); | ||
| } | ||
| capturePointer(target, pointerId) { | ||
| try { | ||
| target.setPointerCapture?.(pointerId); | ||
| } catch { | ||
| } | ||
| } | ||
| releasePointer(state2) { | ||
| try { | ||
| if (state2.captureTarget.hasPointerCapture?.(state2.pointerId)) { | ||
| state2.captureTarget.releasePointerCapture?.(state2.pointerId); | ||
| } | ||
| } catch { | ||
| } | ||
| } | ||
| onEventPointerDown(e, ev) { | ||
| if (!this.movable || this.pointerDrag || e.button !== 0) return; | ||
| const captureTarget = e.currentTarget; | ||
| const lane = captureTarget.closest(".lane"); | ||
| const laneWidth = lane?.getBoundingClientRect().width ?? 0; | ||
| if (laneWidth <= 0) return; | ||
| const state2 = { | ||
| pointerId: e.pointerId, | ||
| startX: e.clientX, | ||
| startY: e.clientY, | ||
| active: false, | ||
| // Con ratón o lápiz el gesto ya está armado: el umbral en píxeles basta. | ||
| held: e.pointerType !== "touch", | ||
| holdTimer: null, | ||
| captureTarget, | ||
| laneWidth, | ||
| id: ev.id, | ||
| from: this.placement(ev) | ||
| }; | ||
| if (!state2.held) { | ||
| state2.holdTimer = setTimeout(() => { | ||
| state2.held = true; | ||
| state2.holdTimer = null; | ||
| this.heldId = state2.id; | ||
| }, TOUCH_HOLD_MS); | ||
| } | ||
| this.pointerDrag = state2; | ||
| this.capturePointer(captureTarget, e.pointerId); | ||
| } | ||
| // Suelta el candidato y apaga su temporizador (una sola puerta de salida del gesto). | ||
| endGesture(state2) { | ||
| if (state2.holdTimer) clearTimeout(state2.holdTimer); | ||
| this.releasePointer(state2); | ||
| this.pointerDrag = null; | ||
| this.heldId = null; | ||
| } | ||
| /** Elemento real bajo el puntero capturado, dentro del shadow root. */ | ||
| elementFromPoint(x, y) { | ||
| const root = this.renderRoot; | ||
| return root.elementFromPoint?.(x, y) ?? null; | ||
| } | ||
| // Traduce las coordenadas del puntero a «qué carril y qué hora», imantado a la rejilla. | ||
| dropTarget(e, state2) { | ||
| const duration = state2.from.endMin - state2.from.startMin; | ||
| const deltaMin = (e.clientX - state2.startX) / state2.laneWidth * this.rangeMinutes; | ||
| const startMin = this.snapStart(state2.from.startMin + deltaMin, duration); | ||
| const hit = this.elementFromPoint(e.clientX, e.clientY); | ||
| const lane = hit?.closest(".lane[data-resource-id]") ?? null; | ||
| const resourceId = lane?.dataset.resourceId ?? state2.from.resourceId; | ||
| return { resourceId, startMin, endMin: startMin + duration }; | ||
| } | ||
| onPointerMove(e) { | ||
| const state2 = this.pointerDrag; | ||
| if (!state2 || state2.pointerId !== e.pointerId) return; | ||
| const travelled = Math.hypot(e.clientX - state2.startX, e.clientY - state2.startY); | ||
| if (!state2.held) { | ||
| if (travelled >= TOUCH_HOLD_TOLERANCE_PX) this.endGesture(state2); | ||
| return; | ||
| } | ||
| if (!state2.active) { | ||
| if (e.pointerType !== "touch" && travelled < DRAG_THRESHOLD_PX) return; | ||
| state2.active = true; | ||
| } | ||
| e.preventDefault(); | ||
| this.drag = { id: state2.id, from: state2.from, ...this.dropTarget(e, state2) }; | ||
| } | ||
| connectedCallback() { | ||
| super.connectedCallback(); | ||
| this.addEventListener("touchmove", this.blockScrollWhileDragging, { passive: false }); | ||
| } | ||
| suppressNextEventClick() { | ||
| 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.endGesture(state2); | ||
| if (!state2.active) return; | ||
| e.preventDefault(); | ||
| const to = this.dropTarget(e, state2); | ||
| this.drag = null; | ||
| this.suppressNextEventClick(); | ||
| const ev = this.events.find((candidate) => candidate.id === state2.id); | ||
| if (ev) this.requestMove(ev, state2.from, to); | ||
| } | ||
| onPointerCancel(e) { | ||
| const state2 = this.pointerDrag; | ||
| if (!state2 || state2.pointerId !== e.pointerId) return; | ||
| this.endGesture(state2); | ||
| this.drag = null; | ||
| } | ||
| // Teclado: el arrastre es un atajo, no la única vía (tables#16 hace esto mismo en el plano de | ||
| // sala). Enter/Espacio abre el panel del módulo; las flechas mueven. | ||
| onEventKeyDown(e, ev) { | ||
| if (e.key === "Enter" || e.key === " ") { | ||
| e.preventDefault(); | ||
| this.clickEvent(ev, e); | ||
| return; | ||
| } | ||
| if (!this.movable) return; | ||
| const from = this.placement(ev); | ||
| const duration = from.endMin - from.startMin; | ||
| const step = (this.snapMin > 0 ? this.snapMin : 1) * (e.shiftKey ? 4 : 1); | ||
| let to = null; | ||
| if (e.key === "ArrowRight" || e.key === "ArrowLeft") { | ||
| const delta = e.key === "ArrowRight" ? step : -step; | ||
| to = { ...from, startMin: this.snapStart(from.startMin + delta, duration) }; | ||
| to.endMin = to.startMin + duration; | ||
| } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { | ||
| const index = this.resources.findIndex((r) => r.id === from.resourceId); | ||
| const next = index + (e.key === "ArrowDown" ? 1 : -1); | ||
| if (index === -1 || next < 0 || next >= this.resources.length) return; | ||
| to = { ...from, resourceId: this.resources[next].id }; | ||
| } | ||
| if (!to) return; | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| this.requestMove(ev, from, to); | ||
| const resource = this.resources.find((r) => r.id === to.resourceId); | ||
| this.announcement = `${ev.title} — ${this.fmtTime(to.startMin)} · ${resource?.label ?? ""}`; | ||
| } | ||
| // ── Etiquetas ───────────────────────────────────────────────── | ||
@@ -428,21 +690,38 @@ // Etiqueta del día del cursor (capitalizada vía CSS). | ||
| } | ||
| const blocks = this.events.filter((ev) => ev.resourceId === resource.id).map((ev) => { | ||
| const s = Math.max(this.minutesOf(ev.start), startMin); | ||
| const e = Math.min(this.minutesOf(ev.end), startMin + total); | ||
| const ghost = this.drag && this.drag.from.resourceId === resource.id ? (() => { | ||
| const s = Math.max(this.drag.from.startMin, startMin); | ||
| const e = Math.min(this.drag.from.endMin, startMin + total); | ||
| if (e <= s) return ""; | ||
| return html`<div | ||
| class="ghost" | ||
| style=${`left:${(s - startMin) / total * 100}%;width:${(e - s) / total * 100}%`} | ||
| ></div>`; | ||
| })() : ""; | ||
| const blocks = this.events.filter((ev) => this.placement(ev).resourceId === resource.id).map((ev) => { | ||
| const at = this.placement(ev); | ||
| const s = Math.max(at.startMin, startMin); | ||
| const e = Math.min(at.endMin, startMin + total); | ||
| if (e <= s) return ""; | ||
| const left = (s - startMin) / total * 100; | ||
| const width = (e - s) / total * 100; | ||
| const dragging = this.drag?.id === ev.id; | ||
| const held = this.heldId === ev.id; | ||
| const time = `${this.fmtTime(at.startMin)} – ${this.fmtTime(at.endMin)}`; | ||
| return html`<div | ||
| class="event" | ||
| class=${`event${this.movable ? " movable" : ""}${held ? " held" : ""}${dragging ? " dragging" : ""}`} | ||
| data-event-id=${ev.id} | ||
| style=${`left:${left}%;width:${width}%;background:${ev.color || "var(--primary-color)"}`} | ||
| title=${ev.title} | ||
| role="button" | ||
| tabindex="0" | ||
| aria-label=${`${ev.title}, ${time}, ${resource.label}`} | ||
| @click=${(domEv) => this.clickEvent(ev, domEv)} | ||
| @keydown=${(domEv) => this.onEventKeyDown(domEv, ev)} | ||
| @pointerdown=${(domEv) => this.onEventPointerDown(domEv, ev)} | ||
| > | ||
| <span class="event-title">${ev.title}</span> | ||
| <span class="event-time" | ||
| >${this.fmtTime(this.minutesOf(ev.start))} – ${this.fmtTime(this.minutesOf(ev.end))}</span | ||
| > | ||
| <span class="event-time">${time}</span> | ||
| </div>`; | ||
| }); | ||
| return html`<div class="lane">${slots}${blocks}</div>`; | ||
| return html`<div class="lane" data-resource-id=${resource.id}>${slots}${ghost}${blocks}</div>`; | ||
| } | ||
@@ -487,3 +766,9 @@ // Fila completa de un recurso: label sticky + lane. | ||
| <div class="scroll"> | ||
| <div class="grid" style=${gridStyle}> | ||
| <div | ||
| class="grid" | ||
| style=${gridStyle} | ||
| @pointermove=${this.onPointerMove} | ||
| @pointerup=${this.onPointerUp} | ||
| @pointercancel=${this.onPointerCancel} | ||
| > | ||
| <div class="head-row"> | ||
@@ -495,4 +780,12 @@ <div class="corner"></div> | ||
| </div> | ||
| </div>`; | ||
| </div> | ||
| <div class="sr-only" role="status" aria-live="polite">${this.announcement}</div>`; | ||
| } | ||
| disconnectedCallback() { | ||
| super.disconnectedCallback(); | ||
| this.removeEventListener("touchmove", this.blockScrollWhileDragging); | ||
| if (this.pointerDrag) this.endGesture(this.pointerDrag); | ||
| if (this.suppressClickTimer) clearTimeout(this.suppressClickTimer); | ||
| this.suppressClickTimer = null; | ||
| } | ||
| } | ||
@@ -524,4 +817,22 @@ __decorateClass([ | ||
| __decorateClass([ | ||
| property({ type: Boolean, reflect: true }) | ||
| ], OkScheduler.prototype, "movable"); | ||
| __decorateClass([ | ||
| property({ type: Number, attribute: "snap-minutes" }) | ||
| ], OkScheduler.prototype, "snapMin"); | ||
| __decorateClass([ | ||
| state() | ||
| ], OkScheduler.prototype, "cursor"); | ||
| __decorateClass([ | ||
| state() | ||
| ], OkScheduler.prototype, "drag"); | ||
| __decorateClass([ | ||
| state() | ||
| ], OkScheduler.prototype, "pending"); | ||
| __decorateClass([ | ||
| state() | ||
| ], OkScheduler.prototype, "announcement"); | ||
| __decorateClass([ | ||
| state() | ||
| ], OkScheduler.prototype, "heldId"); | ||
| define("ok-scheduler", OkScheduler); | ||
@@ -528,0 +839,0 @@ export { |
+1
-1
| { | ||
| "name": "@erplora/outfitkit", | ||
| "version": "0.1.40", | ||
| "version": "0.1.41", | ||
| "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
@@ -154,3 +154,3 @@ # OutfitKit (`@erplora/outfitkit`) | ||
| | `ok-calendar` | Calendario mes/agenda con eventos como chips y presets. | `ok-date-select`, `ok-event-click`, `ok-view-change`, `ok-nav` | | ||
| | `ok-scheduler` | Timeline de recursos/turnos (filas × franjas horarias) con eventos posicionados. | `ok-event-click`, `ok-slot-click`, `ok-nav` | | ||
| | `ok-scheduler` | Timeline de recursos/turnos (filas × franjas horarias) con eventos posicionados. Con `movable`, los bloques se **arrastran** a otra hora u otro recurso (Pointer Events: ratón directo, dedo tras **mantener pulsado** — el arrastre accidental en tablet es el fallo clásico del sector) y se mueven también con el teclado; el host decide si el movimiento vale y `revert()` lo deshace. | `ok-event-click`, `ok-slot-click`, `ok-nav`, `ok-event-move` | | ||
| | `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` | | ||
@@ -157,0 +157,0 @@ | `ok-timeline` | Línea de tiempo vertical con estados (done/current/pending), modo alternado. | `ok-item-click` | |
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.
2161523
1.14%297
0.34%62412
1%