@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 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
- @media (pointer: coarse) { .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%); } }
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
- .cx-ag-dots > i { width: 6px; height: 6px; border-radius: 99px; flex: none; }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "shadcn-style component registry for muten - own-the-source parts you add with `muten add`.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 }),
@@ -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
  }
@@ -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, '&quot;')}" 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, '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>`; };
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 ? '' : evs.slice(0, 4).map((e) => `<i style="background:${e.tint || 'var(--selected, var(--primary))'}"></i>`).join('')}</span>` : `<span class="cx-ag-count" aria-hidden="true">${loading ? '<s class="skel"></s>' : n || ''}</span>`}</button>`; }).join('')}</div>`;
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
- return (next) => { (next.open === true || next.open === 'true') ? open() : shut(); };
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") }
@@ -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) · side: right | left | top | bottom (for sheet)
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
  }