Sign In

@erplora/outfitkit

Package Overview
Dependencies
Maintainers
1
Versions
42
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@erplora/outfitkit - npm Package Compare versions

Comparing version
0.1.40
to
0.1.41
+1
dist/components/ok-scheduler/ok-scheduler.test.d.ts
import './ok-scheduler';
+58
-0

@@ -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';

@@ -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 {

{
"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",

@@ -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