@muten/shadcn 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/kit.css +27 -3
- package/package.json +1 -1
- package/registry/agenda.js +5 -1
- package/registry/agenda.muten +5 -2
- package/registry/kit/core.js +35 -5
- package/registry/modal-behavior.js +47 -1
- package/registry/modal-behavior.muten +1 -1
- package/registry/modal.muten +6 -4
package/kit.css
CHANGED
|
@@ -366,6 +366,13 @@ button.cn-item { width: 100%; font: inherit; text-align: left; cursor: pointer;
|
|
|
366
366
|
/* entering is slower than leaving: it comes up from 6 px lower at 97 %, and goes in about 0.6 of the time */
|
|
367
367
|
.cx-modal[data-mode="dialog"][data-state="open"], .cx-modal-root[data-open="true"] > .cx-modal[data-mode="dialog"] { transition: transform var(--t-modal) var(--ease-out), opacity calc(var(--t-modal) * .75) var(--ease-out); }
|
|
368
368
|
.cx-modal[data-mode="dialog"][data-state="open"] { transform: translate(-50%, -50%); opacity: 1; }
|
|
369
|
+
/* anchor = a card beside what was tapped (ModalBehavior places it); the backdrop stays to close on a tap outside, but
|
|
370
|
+
shows nothing: the agenda behind is the context */
|
|
371
|
+
.cx-modal[data-mode="anchor"] { width: min(24rem, calc(100% - 2rem)); max-height: min(38rem, calc(100dvh - 2rem)); border-radius: calc(var(--radius) * 1.4); overflow: hidden;
|
|
372
|
+
box-shadow: 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent), 0 24px 60px -20px rgb(20 16 40 / .38);
|
|
373
|
+
transform: scale(.96); opacity: 0; transition: transform calc(var(--t-modal) * .5) ease, opacity calc(var(--t-modal) * .4) ease; }
|
|
374
|
+
.cx-modal-root[data-open="true"] > .cx-modal[data-mode="anchor"] { transform: none; opacity: 1; transition: transform var(--t-pop, 180ms) var(--ease-out), opacity var(--t-pop, 180ms) var(--ease-out); }
|
|
375
|
+
.cx-modal-root:has(> .cx-modal[data-mode="anchor"]) > .cx-overlay { background: transparent; backdrop-filter: none; }
|
|
369
376
|
/* teléfono = Drawer desde abajo, arrastrable */
|
|
370
377
|
.cx-modal[data-mode="drawer"] { left: 0; right: 0; bottom: 0; margin-inline: auto; width: min(100%, 34rem); max-height: calc(100dvh - 3rem); border-radius: calc(var(--radius) * 1.8) calc(var(--radius) * 1.8) 0 0; overflow: hidden;
|
|
371
378
|
transform: translateY(100%); transition: transform 450ms var(--ease-drawer); touch-action: none; }
|
|
@@ -390,7 +397,8 @@ button.cn-item { width: 100%; font: inherit; text-align: left; cursor: pointer;
|
|
|
390
397
|
.cx-sw i { width: 22px; height: 22px; border-radius: 6px; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 15%, transparent); flex: none; }
|
|
391
398
|
|
|
392
399
|
/* ── 44 px touch target without changing the drawn size ── */
|
|
393
|
-
|
|
400
|
+
/* :where() keeps it at zero specificity: a button placed on purpose (the modal ✕, a pinned action) keeps its position */
|
|
401
|
+
@media (pointer: coarse) { :where(.cn-button) { position: relative; } .cn-button::before { content: ""; position: absolute; left: 50%; top: 50%; width: max(100%, 44px); height: max(100%, 44px); transform: translate(-50%, -50%); } }
|
|
394
402
|
|
|
395
403
|
/* ── page scaffolding for this preview (not part of the library) ── */
|
|
396
404
|
.cx-lib { display: flex; flex-direction: column; gap: 18px; }
|
|
@@ -1597,6 +1605,10 @@ p.cx-text { margin: 0; }
|
|
|
1597
1605
|
/* ═════ Agenda ═════ */
|
|
1598
1606
|
.cx-agenda { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: var(--agenda-max, none); margin: 0 auto; }
|
|
1599
1607
|
.cx-ag-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
|
|
1608
|
+
/* Today · the date · Day/List stay on ONE row: the date gives way (ellipsis), the view switch never drops below */
|
|
1609
|
+
.cx-ag-bar:not(:has(> .cx-text[data-variant="heading"])) { flex-wrap: nowrap; }
|
|
1610
|
+
.cx-ag-bar > .cx-ag-title { flex: 0 1 auto; }
|
|
1611
|
+
.cx-ag-bar > .cx-tabs, .cx-ag-bar > .cn-button { flex: none; }
|
|
1600
1612
|
.cx-ag-scroll { max-height: 520px; overflow: auto; }
|
|
1601
1613
|
.cx-ag { position: relative; display: grid; grid-template-rows: 36px 1fr; min-width: calc(56px + var(--cols) * 140px); }
|
|
1602
1614
|
.cx-ag-head { position: sticky; top: 0; z-index: 3; display: grid; grid-template-columns: 56px repeat(var(--cols), 1fr); background: var(--background); border-bottom: 1px solid var(--border); }
|
|
@@ -1616,6 +1628,11 @@ p.cx-text { margin: 0; }
|
|
|
1616
1628
|
.cx-ag-ev[data-dragging] { transition: none; cursor: grabbing; z-index: 2; box-shadow: 0 10px 24px -10px rgb(0 0 0 / .4); }
|
|
1617
1629
|
.cx-ag-ev:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
|
|
1618
1630
|
.cx-ag-ev .cx-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
|
1631
|
+
/* what the app's assistant did (event by "agent"): its mark before the name, ringed so it reads on any tone; on the
|
|
1632
|
+
phone's list, a line under the service. --cx-ag-by-ink colours that line (the app's assistant colour). */
|
|
1633
|
+
.cx-ag-by { display: inline-block; flex: none; width: 13px; height: 13px; margin-right: 4px; vertical-align: -2px; border-radius: 50%; box-shadow: 0 0 0 1.5px var(--background); }
|
|
1634
|
+
.cx-ag-byline { display: inline-flex; align-items: center; gap: 5px; margin-top: 2px; font-size: 12.5px; font-weight: 600; line-height: 1.3; color: var(--cx-ag-by-ink, var(--primary)); }
|
|
1635
|
+
.cx-ag-byline .cx-ag-by { width: 14px; height: 14px; margin: 0; box-shadow: none; }
|
|
1619
1636
|
.cx-ag-now { position: absolute; left: 56px; right: 0; height: 0; margin-top: -1px; border-top: 2px solid var(--destructive); z-index: 2; pointer-events: none; } /* the 2px border centred ON the hour, like the pill and its tail */
|
|
1620
1637
|
.cx-ag-now i { position: absolute; left: -5px; top: -6px; width: 10px; height: 10px; border-radius: 99px; background: var(--destructive); }
|
|
1621
1638
|
|
|
@@ -1816,7 +1833,10 @@ p.cx-text { margin: 0; }
|
|
|
1816
1833
|
.cx-ag-who .cn-button .cn-avatar { width: 20px; height: 20px; }
|
|
1817
1834
|
.cx-ag-who .cn-button .cn-avatar .cn-avatar-fallback { font-size: 9px; }
|
|
1818
1835
|
.cx-ag-who .cn-button[aria-checked="true"][aria-checked][aria-checked] { background: var(--selected-soft); color: var(--selected); border-color: transparent; }
|
|
1819
|
-
.cx-ag-strip { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
|
|
1836
|
+
.cx-ag-strip { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }
|
|
1837
|
+
/* a narrow phone: the seven days always fit (a long «TODAY» clips instead of pushing the week off the screen) */
|
|
1838
|
+
.cx-ag-strip .cx-ag-day { min-width: 0; padding-inline: 2px; }
|
|
1839
|
+
.cx-ag-strip .cx-ag-day small { max-width: 100%; overflow: hidden; text-overflow: clip; white-space: nowrap; }
|
|
1820
1840
|
.cx-ag-day small { font-size: 11px; color: var(--muted-foreground); text-transform: uppercase; letter-spacing: .04em; }
|
|
1821
1841
|
.cx-ag-day b { font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
1822
1842
|
/* a day with nothing booked has no count line: its name and number sit in the middle of the pill, not at its top */
|
|
@@ -4935,6 +4955,8 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
4935
4955
|
/* the pill and the line are one: a short tail from the pill to the hour column's edge, where the first day's line starts */
|
|
4936
4956
|
.cx-ag-nowtime::after { content: ""; position: absolute; left: 100%; top: 50%; width: 6px; border-top: 2px solid var(--destructive); transform: translateY(-1px); }
|
|
4937
4957
|
.cx-ag-nowtime { position: absolute; right: 4px; z-index: 3; transform: translateY(-50%); padding: 1px 5px; border-radius: 6px; background: var(--destructive); color: var(--destructive-foreground, #fff); font-size: 10px; font-weight: 600; line-height: 1.4; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
4958
|
+
/* «12:36 PM», «10:59 AM»: the longest hours are wider than the hour column; only they tighten, so they fit whole */
|
|
4959
|
+
.cx-ag-nowtime[data-long] { right: 2px; padding-inline: 3px; font-size: 9.5px; letter-spacing: -.02em; }
|
|
4938
4960
|
/* the crossed-citas alert sits under the strip, full width, with its button */
|
|
4939
4961
|
.cx-ag-top > [data-ag-alert] { margin: 0; }
|
|
4940
4962
|
/* no view picked (a range is on): the tabs show none, not the first */
|
|
@@ -5313,7 +5335,9 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
5313
5335
|
.cx-ag-ev { position: absolute; }
|
|
5314
5336
|
.cx-ag-evprice { position: absolute; top: 5px; right: 7px; font-size: 11.5px; font-weight: 650; font-variant-numeric: tabular-nums; opacity: .85; }
|
|
5315
5337
|
.cx-ag-dots { display: flex; flex-direction: row; justify-content: center; gap: 3px; min-height: 6px; }
|
|
5316
|
-
|
|
5338
|
+
/* ONE dot: «this day has appointments»; how many is in the day's name for a screen reader, and on the day itself */
|
|
5339
|
+
.cx-ag-dots > i { width: 6px; height: 6px; border-radius: 99px; flex: none; background: var(--selected, var(--primary)); }
|
|
5340
|
+
.cx-ag-day[aria-pressed="true"] .cx-ag-dots > i { background: currentColor; }
|
|
5317
5341
|
.cx-ag-ev:has(> .cx-ag-evprice) { padding-right: 62px; }
|
|
5318
5342
|
/* a narrow column (the week on a laptop) keeps the client's name: the price shows only where there is room for both */
|
|
5319
5343
|
.cx-ag-col { container-type: inline-size; }
|
package/package.json
CHANGED
package/registry/agenda.js
CHANGED
|
@@ -67,10 +67,14 @@ export function mount(el, inputs, on) {
|
|
|
67
67
|
from: Number(i.from) || 9, to: Number(i.to) || 19,
|
|
68
68
|
// the language passed (Storio passes it), else the page's (the nearest lang), English by default
|
|
69
69
|
lang: i.lang === 'en' || i.lang === 'es' ? i.lang : (langOf(el) === 'es' ? 'es' : 'en'), overlap: ['never', 'allow'].includes(i.overlap) ? i.overlap : 'auto', currency: i.currency || '', locale: i.locale || '', cents: yes(i.cents),
|
|
70
|
+
agentMark: i.agentMark || '', agentLabel: i.agentLabel || '',
|
|
70
71
|
};
|
|
71
72
|
};
|
|
72
73
|
map = data;
|
|
73
74
|
const send = (name, value) => on[name]?.(typeof value === 'string' ? value : JSON.stringify(value));
|
|
75
|
+
// WHERE A CITA IS ON SCREEN ("x,y,w,h"), so the app can open it beside itself (Modal mode "anchor"): the shown chip
|
|
76
|
+
// or row that carries its id ('' when none is visible, e.g. opened from a command)
|
|
77
|
+
const whereIs = (id) => { const box = [...el.querySelectorAll(`[data-ev="${CSS.escape(String(id))}"]`)].map((x) => x.getBoundingClientRect()).find((r) => r.width > 0 && r.height > 0); return box ? [box.left, box.top, box.width, box.height].map(Math.round).join(',') : ''; };
|
|
74
78
|
const day1 = (x) => ({ ...x, day: x.day instanceof Date ? iso(x.day) : x.day });
|
|
75
79
|
el.innerHTML = '';
|
|
76
80
|
api = kit.agenda(el, {
|
|
@@ -82,7 +86,7 @@ export function mount(el, inputs, on) {
|
|
|
82
86
|
resize: inputs.resize === 'off' ? 'off' : 'on', moveAcross: inputs.moveAcross === 'off' ? 'off' : 'on', hour12: yes(inputs.hour12), timeZone: inputs.timeZone || '', phoneStrip: inputs.phoneStrip === 'dots' ? 'dots' : '', phoneView: ['list', 'day'].includes(inputs.phoneView) ? inputs.phoneView : '', blockScope: inputs.blockScope === 'all' ? 'all' : 'each',
|
|
83
87
|
onAction: (id) => send('action', id),
|
|
84
88
|
onCreate: (x) => send('create', day1(x)),
|
|
85
|
-
onOpen: on.open ? (e) => send('open', day1({ id: e.id, resource: e.resource, day: e.day, start: e.start, dur: e.dur, status: e.status || '' })) : null,
|
|
89
|
+
onOpen: on.open ? (e) => send('open', day1({ id: e.id, resource: e.resource, day: e.day, start: e.start, dur: e.dur, status: e.status || '', at: whereIs(e.id) })) : null,
|
|
86
90
|
onBlock: on.block ? (b) => send('block', day1(b)) : null,
|
|
87
91
|
onMove: (m) => send('move', day1(m)),
|
|
88
92
|
onRange: (r) => send('range', { from: iso(r.from), to: iso(r.to), view: r.view }),
|
package/registry/agenda.muten
CHANGED
|
@@ -35,8 +35,11 @@ part AgendaDemo(resources: list, hours: list, events: list, blocks: list, action
|
|
|
35
35
|
# a appointment takes its resource plus every id in `with`. overlap "never" | "allow" overrides all capacities.
|
|
36
36
|
# resize "off" · moveAcross "off" (a backend that only moves day and time) · hour12 "yes" (3:00 PM) · timeZone (the shop's today and now) · phoneView "list" · phoneStrip "dots" (the phone's week: a dot per appointment in its colour) · blockScope "all" · placing [{…}]
|
|
37
37
|
# (the dashed appointment while the app's new-cita sheet is open) · events take tags [words] and photo; resources role, hideEmpty
|
|
38
|
+
# onOpen's json also carries `at` ("x,y,w,h", where the cita is on screen): give it to a Modal's `anchor` (mode "anchor")
|
|
39
|
+
# events take by "agent" (made by the app's assistant): agentMark (an image url) goes before their name, and agentLabel
|
|
40
|
+
# ("Booked by your assistant") under it on the phone's list and in what a screen reader says
|
|
38
41
|
# AgendaLive(resources: @team, hours: @hours, events: @citas, blocks: @blocks, free: @free, queue: @queue, closed: @closed,
|
|
39
42
|
# lang: ui.lang, currency: "USD", onOpen: openCita, onCreate: newCita, onBlock: block, onMove: moveCita, onRange: loadDays)
|
|
40
|
-
part AgendaLive(resources: list, hours: list, events: list, blocks: list, actions: list, free: list, queue: list, closed: list, lang: text = "es", currency: text = "", locale: text = "", cents: text = "", from: number = 9, to: number = 19, slot: number = 30, view: text = "day", loading: text = "", start: text = "", drag: text = "", phone: text = "", command: text = "", overlap: text = "", hour12: text = "", timeZone: text = "", phoneView: text = "", phoneStrip: text = "", fit: text = "", ranges: text = "", blockScope: text = "", resize: text = "", moveAcross: text = "", placing: list, onOpen: action, onCreate: action, onBlock: action, onMove: action, onRange: action, onAction: action) {
|
|
41
|
-
Custom Agenda inputs(resources: $resources, hours: $hours, events: $events, blocks: $blocks, actions: $actions, free: $free, queue: $queue, closed: $closed, lang: $lang, currency: $currency, locale: $locale, cents: $cents, from: $from, to: $to, slot: $slot, view: $view, loading: $loading, start: $start, drag: $drag, phone: $phone, command: $command, overlap: $overlap, hour12: $hour12, timeZone: $timeZone, phoneView: $phoneView, phoneStrip: $phoneStrip, fit: $fit, ranges: $ranges, blockScope: $blockScope, resize: $resize, moveAcross: $moveAcross, placing: $placing, sheet: "app") on(open: $onOpen, create: $onCreate, block: $onBlock, move: $onMove, range: $onRange, action: $onAction) class("cx-agenda")
|
|
43
|
+
part AgendaLive(resources: list, hours: list, events: list, blocks: list, actions: list, free: list, queue: list, closed: list, lang: text = "es", currency: text = "", locale: text = "", cents: text = "", from: number = 9, to: number = 19, slot: number = 30, view: text = "day", loading: text = "", start: text = "", drag: text = "", phone: text = "", command: text = "", overlap: text = "", hour12: text = "", agentMark: text = "", agentLabel: text = "", timeZone: text = "", phoneView: text = "", phoneStrip: text = "", fit: text = "", ranges: text = "", blockScope: text = "", resize: text = "", moveAcross: text = "", placing: list, onOpen: action, onCreate: action, onBlock: action, onMove: action, onRange: action, onAction: action) {
|
|
44
|
+
Custom Agenda inputs(resources: $resources, hours: $hours, events: $events, blocks: $blocks, actions: $actions, free: $free, queue: $queue, closed: $closed, lang: $lang, currency: $currency, locale: $locale, cents: $cents, from: $from, to: $to, slot: $slot, view: $view, loading: $loading, start: $start, drag: $drag, phone: $phone, command: $command, overlap: $overlap, hour12: $hour12, agentMark: $agentMark, agentLabel: $agentLabel, timeZone: $timeZone, phoneView: $phoneView, phoneStrip: $phoneStrip, fit: $fit, ranges: $ranges, blockScope: $blockScope, resize: $resize, moveAcross: $moveAcross, placing: $placing, sheet: "app") on(open: $onOpen, create: $onCreate, block: $onBlock, move: $onMove, range: $onRange, action: $onAction) class("cx-agenda")
|
|
42
45
|
}
|
package/registry/kit/core.js
CHANGED
|
@@ -429,6 +429,30 @@ const focusables = () => $$('button:not([disabled]), input, textarea, select, [t
|
|
|
429
429
|
};
|
|
430
430
|
addEventListener('pointerup', endDrag); addEventListener('pointercancel', endDrag);
|
|
431
431
|
|
|
432
|
+
// a finger can also pull the sheet down from ANYWHERE in it while its content sits at the top, like the phone's own
|
|
433
|
+
// sheets: the body pans vertically (touch-action: pan-y), so this listens to touch events and takes the gesture
|
|
434
|
+
// (preventDefault) only once it is a pull down with nothing above to scroll back to
|
|
435
|
+
let pull = null;
|
|
436
|
+
const scrollerOf = (el) => { for (let x = el; x && x !== mdl; x = x.parentElement) { if (x.scrollHeight > x.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(x).overflowY)) return x; } return null; };
|
|
437
|
+
mdl.addEventListener('touchstart', (e) => {
|
|
438
|
+
if (mdl.dataset.mode !== 'drawer' || e.touches.length !== 1 || drag) return;
|
|
439
|
+
if (e.target.closest('input, textarea, select, [contenteditable="true"], [role="slider"], .cx-wheel, .cx-slider')) return;
|
|
440
|
+
const t = e.touches[0]; pull = { y: t.clientY, x: t.clientX, t: performance.now(), dy: 0, live: false, sc: scrollerOf(e.target) };
|
|
441
|
+
}, { passive: true });
|
|
442
|
+
mdl.addEventListener('touchmove', (e) => {
|
|
443
|
+
if (!pull) return;
|
|
444
|
+
const t = e.touches[0], dy = t.clientY - pull.y, dx = t.clientX - pull.x;
|
|
445
|
+
if (!pull.live) {
|
|
446
|
+
if (Math.abs(dy) < 6 && Math.abs(dx) < 6) return;
|
|
447
|
+
if (dy < 0 || Math.abs(dx) > Math.abs(dy) || (pull.sc && pull.sc.scrollTop > 0)) { pull = null; return; }
|
|
448
|
+
pull.live = true; mdl.classList.add('dragging'); ov.classList.add('dragging');
|
|
449
|
+
}
|
|
450
|
+
e.preventDefault(); pull.dy = dy; mdl.style.transform = `translateY(${dy}px)`;
|
|
451
|
+
ov.style.opacity = String(Math.max(0, 1 - dy / mdl.offsetHeight));
|
|
452
|
+
}, { passive: false });
|
|
453
|
+
const endPull = () => { if (!pull) return; const d = pull; pull = null; if (!d.live) return; mdl.classList.remove('dragging'); ov.classList.remove('dragging'); const v = d.dy / (performance.now() - d.t); if (d.dy > mdl.offsetHeight * 0.3 || v > 0.5) closeModal(); else { mdl.style.transform = ''; ov.style.opacity = ''; } };
|
|
454
|
+
mdl.addEventListener('touchend', endPull); mdl.addEventListener('touchcancel', endPull);
|
|
455
|
+
|
|
432
456
|
|
|
433
457
|
// ── wheels (date and time) ──
|
|
434
458
|
const MONTHS = MON.map((m) => m[0].toUpperCase() + m.slice(1));
|
|
@@ -663,6 +687,11 @@ let wheelsNow = [];
|
|
|
663
687
|
actions = null, createSheet = false, services = [], view: startView = 'day', sheet: sheetMode = 'built-in', drag: dragOn = true,
|
|
664
688
|
resize: resizeMode = 'on', moveAcross = 'on',
|
|
665
689
|
start: startDay = null, phone: phoneMode = '', hour12 = false, timeZone = '', phoneView = '', blockScope = 'each', phoneStrip = '' } = opts;
|
|
690
|
+
// WHAT THE APP'S ASSISTANT DID: an event with by "agent" carries the app's mark (an image url) before its name, and on
|
|
691
|
+
// the phone's list a line that says so (agentLabel). The library knows no assistant: the app names it and draws it.
|
|
692
|
+
let agentMark = opts.agentMark || '', agentLabel = opts.agentLabel || '';
|
|
693
|
+
const byAgent = (e) => e.by === 'agent' && !!agentMark;
|
|
694
|
+
const agentImg = () => `<img class="cx-ag-by" src="${String(agentMark).replace(/"/g, '"')}" alt="" aria-hidden="true">`;
|
|
666
695
|
// THE SHOP'S CLOCK: with a timeZone, “today”, “now” and “the past” are the shop's, not the browser's (an owner
|
|
667
696
|
// travelling, or a phone set to another zone, still sees the shop's day)
|
|
668
697
|
const clockNow = () => { if (!timeZone) return new Date(); try { const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date()).map((x) => [x.type, x.value])); return new Date(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute); } catch { return new Date(); } };
|
|
@@ -725,7 +754,7 @@ let wheelsNow = [];
|
|
|
725
754
|
const tagsOf = (e) => (Array.isArray(e.tags) ? e.tags.filter(Boolean).join(' · ') : '');
|
|
726
755
|
const SAY = ['started', 'awaiting_payment', 'noshow']; // the statuses a cita SAYS in words (no stripes, no glow)
|
|
727
756
|
const evHtml = (e, ln, showWho, rid = null, gut = false) => { const [l, n] = ln.get(e.id) || [0, 1]; const short = e.dur < 45; const said = clash.has(e.id) ? w.clashOne : SAY.includes(e.status) && w.status[e.status] ? w.status[e.status] : ''; const others = rid ? occ(e).filter((x) => x !== rid).map(resName).filter(Boolean).join(', ') : ''; const tg = tagsOf(e);
|
|
728
|
-
const span = gut ? '(100% - 22px)' : '100%'; /* an agenda with no limit keeps a strip on the right to book beside */ return `<button class="cx-ag-ev" data-ev="${e.id}" data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}top:${y(toMin(e.start))}px;height:${(e.dur / 60) * hourPx - 3}px;left:calc(${span} * ${l / n} + 3px);width:calc(${span} / ${n} - 6px)"${clash.has(e.id) ? ' data-conflict' : ''} aria-label="${e.title}, ${clk(e.start)}, ${e.dur} ${w.minutes}${tg ? ', ' + tg : ''}${showWho ? `, ${w.with} ${resName(e.resource)}` : ''}${others ? `, ${w.with} ${others}` : ''}${e.status && w.status[e.status] ? `, ${w.status[e.status]}` : ''}${clash.has(e.id) ? `, ${w.clashOne}` : ''}">${short ? ui.text(`${e.title} · ${said || tg || clk(e.start)}`, 'label') : `${ui.text(e.title
|
|
757
|
+
const span = gut ? '(100% - 22px)' : '100%'; /* an agenda with no limit keeps a strip on the right to book beside */ return `<button class="cx-ag-ev" data-ev="${e.id}" data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}top:${y(toMin(e.start))}px;height:${(e.dur / 60) * hourPx - 3}px;left:calc(${span} * ${l / n} + 3px);width:calc(${span} / ${n} - 6px)"${clash.has(e.id) ? ' data-conflict' : ''} aria-label="${e.title}, ${clk(e.start)}, ${e.dur} ${w.minutes}${tg ? ', ' + tg : ''}${showWho ? `, ${w.with} ${resName(e.resource)}` : ''}${others ? `, ${w.with} ${others}` : ''}${e.status && w.status[e.status] ? `, ${w.status[e.status]}` : ''}${clash.has(e.id) ? `, ${w.clashOne}` : ''}${byAgent(e) && agentLabel ? `, ${agentLabel}` : ''}">${short ? ui.text(`${byAgent(e) ? agentImg() : ''}${e.title} · ${said || tg || clk(e.start)}`, 'label') : `${ui.text(`${byAgent(e) ? agentImg() : ''}${e.title}`, 'label')}${ui.text(`${said ? said + ' · ' : ''}${tg ? tg + ' · ' : ''}${clk(e.start)} · ${e.subtitle || ''}${showWho ? ' · ' + resName(e.resource) : ''}${others ? ' · ' + others : ''}`, 'caption')}`}${e.price && !short ? `<span class="cx-ag-evprice">${fmtMoney(e.price)}</span>` : ''}${dragOn && resizeMode !== 'off' ? '<span class="cx-ag-grip" aria-hidden="true"></span>' : ''}</button>`; };
|
|
729
758
|
// the hours a person does not work: hatched. With server free hours, everything that is neither free nor booked.
|
|
730
759
|
// a day the business does not open: the column hatched, “Closed” in the middle (grid) or as the list's only line
|
|
731
760
|
const shutPill = () => `<span class="cx-ag-shutpill">${ui.icon('caloff')}${ui.text(w.closed, 'label')}</span>`;
|
|
@@ -768,7 +797,7 @@ let wheelsNow = [];
|
|
|
768
797
|
return `<div class="cx-hstack cx-ag-who" role="radiogroup" aria-label="${w.whose}"><span class="cx-ag-wholabel" aria-hidden="true">${w.whose}</span>${all}${kinds.map((k) => { const rs = ordered().filter((r) => kindOf(r) === k); return `<span class="cx-ag-sep" aria-hidden="true"></span>${rs.length > 1 ? ui.button({ label: groupName(k), size: 'sm', variant: 'ghost', attrs: `role="radio" aria-checked="${who === 'kind:' + k}" data-who="kind:${k}"`, cls: 'cx-ag-group' }) : ''}${rs.map(personBtn).join('')}`; }).join('')}</div>`;
|
|
769
798
|
};
|
|
770
799
|
// the week strip: each day with how many appointments it has (a plain number), closed days dimmed
|
|
771
|
-
const strip = () => `<div class="cx-ag-strip" role="group" aria-label="${w.days}">${weekDays().map((d) => { const evs = loading ? [] : evOn(d); const n = evs.length; const shut = isClosed(d); return `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ag-day" data-ag-day="${+d}" aria-pressed="${sameDay(d, day)}" ${sameDay(d, today) ? 'data-today' : ''} ${shut ? 'data-closed' : ''} aria-label="${w.dayL[d.getDay()]} ${d.getDate()}, ${shut && !n ? w.closed : w.countOf(n)}"${loading && !shownOnce ? ' disabled aria-disabled="true"' : ''}><small>${sameDay(d, today) ? w.todayShort : w.dayS[d.getDay()]}</small><b>${d.getDate()}</b>${phoneStrip === 'dots' ? `<span class="cx-ag-dots" aria-hidden="true">${loading ? '' :
|
|
800
|
+
const strip = () => `<div class="cx-ag-strip" role="group" aria-label="${w.days}">${weekDays().map((d) => { const evs = loading ? [] : evOn(d); const n = evs.length; const shut = isClosed(d); return `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ag-day" data-ag-day="${+d}" aria-pressed="${sameDay(d, day)}" ${sameDay(d, today) ? 'data-today' : ''} ${shut ? 'data-closed' : ''} aria-label="${w.dayL[d.getDay()]} ${d.getDate()}, ${shut && !n ? w.closed : w.countOf(n)}"${loading && !shownOnce ? ' disabled aria-disabled="true"' : ''}><small>${sameDay(d, today) ? w.todayShort : w.dayS[d.getDay()]}</small><b>${d.getDate()}</b>${phoneStrip === 'dots' ? `<span class="cx-ag-dots" aria-hidden="true">${loading ? '' : (n ? '<i></i>' : '')}</span>` : `<span class="cx-ag-count" aria-hidden="true">${loading ? '<s class="skel"></s>' : n || ''}</span>`}</button>`; }).join('')}</div>`;
|
|
772
801
|
// “Ahora”: on today, the appointment in progress (started, or whose time is now), first thing, one tap to open it
|
|
773
802
|
const nowCard = () => {
|
|
774
803
|
if (!sameDay(day, today)) return '';
|
|
@@ -789,13 +818,13 @@ let wheelsNow = [];
|
|
|
789
818
|
// The dot marks where today's column starts, so day and week begin the same (pill, tail, line): in day view, or
|
|
790
819
|
// when today is the week's first column, the pill already stands there and no dot is drawn. Nothing while loading.
|
|
791
820
|
const nowLine = (c, ci, cols) => { const nowM = nowMin(); const at = cols.findIndex((k) => sameDay(k.day, today)); if (at < 0 || nowM <= from * 60 || nowM >= to * 60) return ''; const mine = sameDay(c.day, today); const gone = !mine && c.day < today; return `<div class="cx-ag-now${mine ? '' : gone ? ' cx-ag-now-past' : ' cx-ag-now-ahead'}" style="top:${y(nowM)}px" aria-hidden="true">${mine && at === ci && ci > 0 ? '<i></i>' : ''}</div>`; };
|
|
792
|
-
const grid = (cols) => { const hours = Array.from({ length: to - from }, (_, i) => from + i); const nowM = nowMin(); return `<div class="cx-ag-scroll cx-scroll"><div class="cx-ag" style="--cols:${cols.length};--h:${hourPx}px;grid-template-rows:auto auto"><div class="cx-ag-head"><span></span>${cols.map((c) => `<div class="cx-ag-colhead" ${c.today ? 'data-today' : ''} ${c.closed ? 'data-closed' : ''}>${loading ? skHead(c) : c.head}</div>`).join('')}</div><div class="cx-ag-body" style="height:${(to - from) * hourPx}px"><div class="cx-ag-times">${!loading && cols.some((k) => sameDay(k.day, today)) && nowM > from * 60 && nowM < to * 60 ? `<b class="cx-ag-nowtime" style="top:${y(nowM)}px">${clk(toHM(nowM))}</b>` : ''}${hours.map((h) => `<span style="top:${(h - from) * hourPx}px">${ui.text(hour12 ? `${((h + 11) % 12) + 1} ${h < 12 ? 'AM' : 'PM'}` : `${String(h).padStart(2, '0')}:00`, 'caption')}</span>`).join('')}</div>${cols.map((c, ci) => { const rid = c.rid === 'all' ? null : c.rid; const evs = evOn(c.day, rid); const cap = rid ? capOf(rid) : 1; const ln = lanes(evs); if (cap > 1) ln.forEach((v) => { v[1] = Math.max(v[1], Math.min(cap, 4)); }); const rids = rid ? [rid] : shown().map((r) => r.id); if (loading) return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" aria-hidden="true">${rid && !freeList ? offHtml(c.day, rid) : ''}${skelCol(ci)}</div>`;
|
|
821
|
+
const grid = (cols) => { const hours = Array.from({ length: to - from }, (_, i) => from + i); const nowM = nowMin(); return `<div class="cx-ag-scroll cx-scroll"><div class="cx-ag" style="--cols:${cols.length};--h:${hourPx}px;grid-template-rows:auto auto"><div class="cx-ag-head"><span></span>${cols.map((c) => `<div class="cx-ag-colhead" ${c.today ? 'data-today' : ''} ${c.closed ? 'data-closed' : ''}>${loading ? skHead(c) : c.head}</div>`).join('')}</div><div class="cx-ag-body" style="height:${(to - from) * hourPx}px"><div class="cx-ag-times">${!loading && cols.some((k) => sameDay(k.day, today)) && nowM > from * 60 && nowM < to * 60 ? `<b class="cx-ag-nowtime" style="top:${y(nowM)}px"${clk(toHM(nowM)).length > 7 ? ' data-long' : ''}>${clk(toHM(nowM))}</b>` : ''}${hours.map((h) => `<span style="top:${(h - from) * hourPx}px">${ui.text(hour12 ? `${((h + 11) % 12) + 1} ${h < 12 ? 'AM' : 'PM'}` : `${String(h).padStart(2, '0')}:00`, 'caption')}</span>`).join('')}</div>${cols.map((c, ci) => { const rid = c.rid === 'all' ? null : c.rid; const evs = evOn(c.day, rid); const cap = rid ? capOf(rid) : 1; const ln = lanes(evs); if (cap > 1) ln.forEach((v) => { v[1] = Math.max(v[1], Math.min(cap, 4)); }); const rids = rid ? [rid] : shown().map((r) => r.id); if (loading) return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" aria-hidden="true">${rid && !freeList ? offHtml(c.day, rid) : ''}${skelCol(ci)}</div>`;
|
|
793
822
|
const offRid = rid || (shown().length === 1 && !freeList ? shown()[0].id : null);
|
|
794
823
|
if (c.closed && !evs.length) return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" data-closed role="group" aria-label="${c.label}, ${w.closed}">${shutHtml()}${nowLine(c, ci, cols)}</div>`;
|
|
795
824
|
return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" role="group" aria-label="${c.label}">${offRid ? offHtml(c.day, offRid) : ''}${!rids.some(bookable) ? '' : freeList ? rids.filter(bookable).map((r) => freeHtml(c.day, r, c.label)).join('') : Array.from({ length: ((to - from) * 60) / slot }, (_, i) => gone(c.day, from * 60 + i * slot) ? '' : `<button class="cx-ag-slot" data-slot-time="${from * 60 + i * slot}" style="top:${y(from * 60 + i * slot)}px;height:${(slot / 60) * hourPx}px" aria-label="${w.newItem}, ${c.label}, ${toHM(from * 60 + i * slot)}" tabindex="-1"></button>`).join('')}${blOn(c.day, rid).map(blockHtml).join('')}${evs.map((e) => evHtml(e, ln, c.rid === 'all' && resources.length > 1, rid, cap === 0)).join('')}${ghostHtml(c.day, rid)}${goneHtml(c.day)}${nowLine(c, ci, cols)}</div>`; }).join('')}</div></div></div>`; };
|
|
796
825
|
// list = the phone's agenda: a solid row is sold, a dashed one is still free
|
|
797
826
|
// one appointment as a row of the list (the day's list and the range's)
|
|
798
|
-
const evRow = (e) => `<button class="cx-ag-row" data-ev="${e.id}"${clash.has(e.id) ? ' data-conflict' : ''} data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}"><span class="cx-ag-row-t">${ui.text(clk(e.start), 'strong')}${ui.text(`${e.dur} min`, 'caption')}</span>${e.photo ? `<span class="cn-avatar cx-ag-face cx-ag-rowface" style="background-image:url('${e.photo}')" aria-hidden="true"></span>` : ''}<span class="cx-vstack" style="gap:0;min-width:0">${ui.text(e.title, 'heading')}${ui.text(`${e.subtitle || ''}${resources.length > 1 ? ' · ' + resName(e.resource) : ''}${tagsOf(e) ? ' · ' + tagsOf(e) : ''}`, 'muted')}</span>${e.status && w.status[e.status] ? ui.badge(w.status[e.status], { tone: { info: 'info', live: 'wait', done: 'ok', bad: 'bad', ok: 'ok' }[toneOf(e)] || 'ok' }) : e.steps ? ui.badge(e.steps[e.at], { tone: e.tone === 'wait' ? 'wait' : e.tone === 'info' ? 'info' : 'ok' }) : ''}${e.price ? ui.text(fmtMoney(e.price), 'strong') : ''}</button>`;
|
|
827
|
+
const evRow = (e) => `<button class="cx-ag-row" data-ev="${e.id}"${clash.has(e.id) ? ' data-conflict' : ''} data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}"><span class="cx-ag-row-t">${ui.text(clk(e.start), 'strong')}${ui.text(`${e.dur} min`, 'caption')}</span>${e.photo ? `<span class="cn-avatar cx-ag-face cx-ag-rowface" style="background-image:url('${e.photo}')" aria-hidden="true"></span>` : ''}<span class="cx-vstack" style="gap:0;min-width:0">${ui.text(e.title, 'heading')}${ui.text(`${e.subtitle || ''}${resources.length > 1 ? ' · ' + resName(e.resource) : ''}${tagsOf(e) ? ' · ' + tagsOf(e) : ''}`, 'muted')}${byAgent(e) && agentLabel ? `<span class="cx-ag-byline">${agentImg()}${agentLabel}</span>` : ''}</span>${e.status && w.status[e.status] ? ui.badge(w.status[e.status], { tone: { info: 'info', live: 'wait', done: 'ok', bad: 'bad', ok: 'ok' }[toneOf(e)] || 'ok' }) : e.steps ? ui.badge(e.steps[e.at], { tone: e.tone === 'wait' ? 'wait' : e.tone === 'info' ? 'info' : 'ok' }) : ''}${e.price ? ui.text(fmtMoney(e.price), 'strong') : ''}</button>`;
|
|
799
828
|
// RANGE: the days picked in the calendar, one after another, each with its appointments (a day with none says so)
|
|
800
829
|
const rangeList = () => { const [a, b] = rangeSel; const n = Math.min(62, Math.round((b - a) / 864e5) + 1); return `<div class="cx-ag-list cx-ag-range" role="list">${Array.from({ length: n }, (_, k) => { const d = addDays(a, k); const evs = evOn(d).sort((x, z) => toMin(x.start) - toMin(z.start)); return `<button type="button" class="cx-ag-rangeday" data-ag-day="${+d}" ${sameDay(d, today) ? 'data-today' : ''}>${ui.text(`${w.dayL[d.getDay()]} ${d.getDate()} ${w.mon[d.getMonth()]}`, 'strong')}${ui.text(isClosed(d) && !evs.length ? w.closed : evs.length ? w.countOf(evs.length) : w.noItems, 'caption')}</button>${evs.map(evRow).join('')}`; }).join('')}</div>`; };
|
|
801
830
|
const list = () => {
|
|
@@ -975,7 +1004,7 @@ let wheelsNow = [];
|
|
|
975
1004
|
const tick = setInterval(() => {
|
|
976
1005
|
if (!root.isConnected) { clearInterval(tick); return; }
|
|
977
1006
|
if (drag || loading) return; const m = nowMin(); if (m <= from * 60 || m >= to * 60) return;
|
|
978
|
-
$$('.cx-ag-now', root).forEach((x) => { x.style.top = y(m) + 'px'; }); const t = $('.cx-ag-nowtime', root); if (t) { t.style.top = y(m) + 'px'; t.textContent = clk(toHM(m)); }
|
|
1007
|
+
$$('.cx-ag-now', root).forEach((x) => { x.style.top = y(m) + 'px'; }); const t = $('.cx-ag-nowtime', root); if (t) { t.style.top = y(m) + 'px'; t.textContent = clk(toHM(m)); t.toggleAttribute('data-long', t.textContent.length > 7); }
|
|
979
1008
|
$$('.cx-ag-col', root).forEach((c) => { if (!sameDay(new Date(+c.dataset.day), today)) return; const g = $('.cx-ag-gone', c); if (g) g.style.height = y(m) + 'px'; });
|
|
980
1009
|
}, 30000);
|
|
981
1010
|
// only the agenda's own box decides: crossing the phone width repaints, and with fit a new HEIGHT of the box measures
|
|
@@ -1161,6 +1190,7 @@ let wheelsNow = [];
|
|
|
1161
1190
|
if ('availability' in next) availability = next.availability; if ('free' in next) freeList = next.free; if ('queue' in next) queue = next.queue;
|
|
1162
1191
|
if ('closed' in next) closed = next.closed; if ('loading' in next) loading = next.loading;
|
|
1163
1192
|
if (Number(next.from) >= 0 && Number(next.to) > Number(next.from)) { from = Number(next.from); to = Number(next.to); }
|
|
1193
|
+
if ('agentMark' in next) agentMark = next.agentMark; if ('agentLabel' in next) agentLabel = next.agentLabel;
|
|
1164
1194
|
if ('overlap' in next) overlap = next.overlap; if ('placing' in next) placing = next.placing; if ('currency' in next) currency = next.currency; if ('locale' in next) locale = next.locale; if ('cents' in next) cents = next.cents;
|
|
1165
1195
|
if ('lang' in next || 'words' in next) { lang = next.lang ?? lang; words = next.words ?? words; w = { ...(AG_WORDS[lang] || AG_WORDS.es), ...words }; }
|
|
1166
1196
|
if (who !== 'all' && !(who.startsWith('kind:') ? resources.some((r) => kindOf(r) === who.slice(5)) : resources.some((r) => r.id === who))) who = 'all';
|
|
@@ -16,9 +16,27 @@ export function mount(el, inputs, on) {
|
|
|
16
16
|
const placeFooter = () => { const f = body.querySelector('.cx-modal-foot'); if (f) mdl.appendChild(f); };
|
|
17
17
|
const focusables = () => [...mdl.querySelectorAll('button:not([disabled]), input, textarea, select, [tabindex]:not([tabindex="-1"])')].filter((x) => x.offsetParent !== null);
|
|
18
18
|
const close = () => on.close?.();
|
|
19
|
+
// ANCHORED: a card beside what was tapped ("x,y,w,h" in the viewport): on its right when it fits, else its left, and
|
|
20
|
+
// kept on screen; it grows from that side. No anchor (or a phone) makes it the dialog.
|
|
21
|
+
let anchor = String(inputs.anchor || '');
|
|
22
|
+
// `want` is the mode the page asked for; `mode` is what it is now (an anchor with nothing to hold on to is a dialog)
|
|
23
|
+
const wantsAnchor = () => mdl.dataset.want === 'anchor';
|
|
24
|
+
const place = () => {
|
|
25
|
+
const at = anchor.split(',').map(Number);
|
|
26
|
+
if (!wantsAnchor() || phone.matches || at.length !== 4 || at.some((n) => !Number.isFinite(n))) { if (wantsAnchor()) mdl.dataset.mode = phone.matches ? 'drawer' : 'dialog'; return; }
|
|
27
|
+
mdl.dataset.mode = 'anchor';
|
|
28
|
+
const [x, y, w] = at, gap = 12, edge = 16;
|
|
29
|
+
const width = mdl.offsetWidth, height = Math.min(mdl.scrollHeight, innerHeight - edge * 2);
|
|
30
|
+
const right = x + w + gap + width <= innerWidth - edge;
|
|
31
|
+
const left = right ? x + w + gap : Math.max(edge, x - gap - width);
|
|
32
|
+
const top = Math.min(Math.max(edge, y - 24), innerHeight - height - edge);
|
|
33
|
+
mdl.style.left = `${left}px`; mdl.style.top = `${top}px`;
|
|
34
|
+
mdl.style.transformOrigin = `${right ? 'left' : 'right'} ${Math.max(0, y - top + 12)}px`;
|
|
35
|
+
};
|
|
19
36
|
const open = () => {
|
|
20
37
|
if (isOpen) return; isOpen = true; placeFooter();
|
|
21
38
|
if (auto) mdl.dataset.mode = phone.matches ? 'drawer' : 'dialog';
|
|
39
|
+
if (wantsAnchor()) place();
|
|
22
40
|
lastFocus = document.activeElement;
|
|
23
41
|
document.documentElement.classList.add('cx-locked');
|
|
24
42
|
// on the phone, focusing a field would throw the keyboard over the sheet: focus the sheet itself
|
|
@@ -62,6 +80,34 @@ export function mount(el, inputs, on) {
|
|
|
62
80
|
});
|
|
63
81
|
const endDrag = () => { if (!drag) return; const d = drag; drag = null; if (!d.live) return; mdl.classList.remove('dragging'); ov?.classList.remove('dragging'); const v = d.dy / (performance.now() - d.t); if (d.dy > mdl.offsetHeight * 0.3 || v > 0.5) close(); else { mdl.style.transform = ''; if (ov) ov.style.opacity = ''; } };
|
|
64
82
|
addEventListener('pointerup', endDrag); addEventListener('pointercancel', endDrag);
|
|
83
|
+
|
|
84
|
+
// a finger can also pull the sheet down from ANYWHERE in it while its content sits at the top, like the phone's own
|
|
85
|
+
// sheets: the body pans vertically (touch-action: pan-y), so this listens to touch events and takes the gesture
|
|
86
|
+
// (preventDefault) only once it is a pull down with nothing above to scroll back to
|
|
87
|
+
let pull = null;
|
|
88
|
+
const scrollerOf = (el) => { for (let x = el; x && x !== mdl; x = x.parentElement) { if (x.scrollHeight > x.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(x).overflowY)) return x; } return null; };
|
|
89
|
+
mdl.addEventListener('touchstart', (e) => {
|
|
90
|
+
if (mdl.dataset.mode !== 'drawer' || e.touches.length !== 1 || drag) return;
|
|
91
|
+
if (e.target.closest('input, textarea, select, [contenteditable="true"], [role="slider"], .cx-wheel, .cx-slider')) return;
|
|
92
|
+
const t = e.touches[0]; pull = { y: t.clientY, x: t.clientX, t: performance.now(), dy: 0, live: false, sc: scrollerOf(e.target) };
|
|
93
|
+
}, { passive: true });
|
|
94
|
+
mdl.addEventListener('touchmove', (e) => {
|
|
95
|
+
if (!pull) return;
|
|
96
|
+
const t = e.touches[0], dy = t.clientY - pull.y, dx = t.clientX - pull.x;
|
|
97
|
+
if (!pull.live) {
|
|
98
|
+
if (Math.abs(dy) < 6 && Math.abs(dx) < 6) return;
|
|
99
|
+
if (dy < 0 || Math.abs(dx) > Math.abs(dy) || (pull.sc && pull.sc.scrollTop > 0)) { pull = null; return; }
|
|
100
|
+
pull.live = true; mdl.classList.add('dragging'); ov?.classList.add('dragging');
|
|
101
|
+
}
|
|
102
|
+
e.preventDefault(); pull.dy = dy; mdl.style.transform = `translateY(${dy}px)`;
|
|
103
|
+
if (ov) ov.style.opacity = String(Math.max(0, 1 - dy / mdl.offsetHeight));
|
|
104
|
+
}, { passive: false });
|
|
105
|
+
const endPull = () => { if (!pull) return; const d = pull; pull = null; if (!d.live) return; mdl.classList.remove('dragging'); ov?.classList.remove('dragging'); const v = d.dy / (performance.now() - d.t); if (d.dy > mdl.offsetHeight * 0.3 || v > 0.5) close(); else { mdl.style.transform = ''; if (ov) ov.style.opacity = ''; } };
|
|
106
|
+
mdl.addEventListener('touchend', endPull); mdl.addEventListener('touchcancel', endPull);
|
|
65
107
|
if (inputs.open === true || inputs.open === 'true') open();
|
|
66
|
-
|
|
108
|
+
// the card follows the window, and its own height when a step inside changes it (it only moves when it would leave
|
|
109
|
+
// the screen: the top stays by the cita while there is room below)
|
|
110
|
+
addEventListener('resize', () => { if (isOpen && wantsAnchor()) place(); });
|
|
111
|
+
new ResizeObserver(() => { if (isOpen && mdl.dataset.mode === 'anchor') place(); }).observe(body);
|
|
112
|
+
return (next) => { anchor = String(next.anchor || ''); (next.open === true || next.open === 'true') ? open() : shut(); };
|
|
67
113
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
# ModalBehavior - what a modal needs that is not markup: the phone's Back closes it, Esc closes it, focus stays
|
|
2
2
|
# inside, the page behind does not scroll, and on a phone the drawer drags down to close. Used by Modal; you never
|
|
3
3
|
# call it yourself. shadcn-for-muten · Custom · host JS is modal-behavior.js.
|
|
4
|
-
part ModalBehavior(open: bool, onClose: action) { Custom ModalBehavior inputs(open: $open) on(close: $onClose) class("cx-modal-behavior") }
|
|
4
|
+
part ModalBehavior(open: bool, onClose: action, anchor: text = "") { Custom ModalBehavior inputs(open: $open, anchor: $anchor) on(close: $onClose) class("cx-modal-behavior") }
|
package/registry/modal.muten
CHANGED
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
# Field { … }
|
|
8
8
|
# ModalFooter { Btn(variant: "outline", onClick: done) class("cx-exit") { Span "Cancel" } Btn(onClick: save) class("cx-go") { Span "Save changes" } }
|
|
9
9
|
# }
|
|
10
|
-
# mode: auto | dialog | drawer | sheet (a panel from a side)
|
|
10
|
+
# mode: auto | dialog | drawer | sheet (a panel from a side) | anchor (a card beside what was tapped)
|
|
11
|
+
# side: right | left | top | bottom (for sheet) · anchor: "x,y,w,h" of what was tapped, in the viewport (for anchor;
|
|
12
|
+
# with none it opens as a dialog). AgendaLive's onOpen sends it as `at`, so a cita opens beside itself.
|
|
11
13
|
# role: dialog | alertdialog · dismiss: on | off (off: no ✕ and the backdrop does not close it; only its buttons do)
|
|
12
14
|
# size: "" | tall (a list that pages) · shadcn-for-muten · part + Custom (ModalBehavior) · kit.css (.cx-modal).
|
|
13
|
-
part Modal(open: bool, onClose: action, title: text = "", description: text = "", mode: text = "auto", size: text = "", side: text = "right", role: text = "dialog", dismiss: text = "on") {
|
|
15
|
+
part Modal(open: bool, onClose: action, title: text = "", description: text = "", mode: text = "auto", size: text = "", side: text = "right", role: text = "dialog", dismiss: text = "on", anchor: text = "") {
|
|
14
16
|
Stack class("cx-modal-root") data(open: $open) {
|
|
15
17
|
when $dismiss != "off" { Button "" -> $onClose class("cx-overlay") aria(label: "Close", hidden: "true") data(closes: "true", trl: "Close") }
|
|
16
18
|
when $dismiss == "off" { Stack class("cx-overlay") aria(hidden: "true") }
|
|
17
|
-
Stack class("cx-modal") data(mode: $mode, size: $size, side: $side) aria(role: $role, modal: "true", label: $title) {
|
|
19
|
+
Stack class("cx-modal") data(mode: $mode, want: $mode, size: $size, side: $side) aria(role: $role, modal: "true", label: $title) {
|
|
18
20
|
Stack class("cx-handle") aria(hidden: "true")
|
|
19
21
|
Stack class("cx-modal-head") data(drag: "true") {
|
|
20
22
|
Span "{$title}" class("cx-modal-title")
|
|
@@ -22,7 +24,7 @@ part Modal(open: bool, onClose: action, title: text = "", description: text = ""
|
|
|
22
24
|
}
|
|
23
25
|
when $dismiss != "off" { Btn(variant: "ghost", size: "icon-sm", label: "Close", onClick: $onClose) class("cx-modal-x") { Icon "lucide:x" } }
|
|
24
26
|
Stack class("cx-modal-body") { slot }
|
|
25
|
-
ModalBehavior(open: $open, onClose: $onClose)
|
|
27
|
+
ModalBehavior(open: $open, onClose: $onClose, anchor: $anchor)
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
}
|