@muten/shadcn 0.2.1 → 0.2.3

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,15 @@ 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: none; scale: .96; opacity: 0; transition: scale calc(var(--t-modal) * .5) ease, opacity calc(var(--t-modal) * .4) ease; }
374
+ /* it grows in place with `scale`, never `transform`: a modal that was a sheet sits off-screen by its transform, and
375
+ animating that would fly the card in from the side */
376
+ .cx-modal-root[data-open="true"] > .cx-modal[data-mode="anchor"] { scale: 1; opacity: 1; transition: scale var(--t-pop, 180ms) var(--ease-out), opacity var(--t-pop, 180ms) var(--ease-out); }
377
+ .cx-modal-root:has(> .cx-modal[data-mode="anchor"]) > .cx-overlay { background: transparent; backdrop-filter: none; }
369
378
  /* teléfono = Drawer desde abajo, arrastrable */
370
379
  .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
380
  transform: translateY(100%); transition: transform 450ms var(--ease-drawer); touch-action: none; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",
@@ -72,6 +72,17 @@ export function mount(el, inputs, on) {
72
72
  };
73
73
  map = data;
74
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 very chip,
76
+ // or row that was tapped; else its chip or row (never the «now» card: a band as wide as the agenda, nothing to sit by);
77
+ // '' when none is visible (opened from a command)
78
+ let tapped = null;
79
+ el.addEventListener('click', (e) => { tapped = e.target.closest?.('[data-ev]') ?? null; }, true);
80
+ const boxOf = (x) => { const r = x?.getBoundingClientRect(); return r && r.width > 0 && r.height > 0 ? [r.left, r.top, r.width, r.height].map(Math.round).join(',') : ''; };
81
+ const whereIs = (id) => {
82
+ if (tapped?.isConnected && !tapped.matches('.cx-ag-nowcard') && tapped.dataset.ev === String(id) && boxOf(tapped)) return boxOf(tapped);
83
+ const own = [...el.querySelectorAll(`.cx-ag-ev[data-ev="${CSS.escape(String(id))}"], .cx-ag-row[data-ev="${CSS.escape(String(id))}"]`)].map(boxOf).find(Boolean);
84
+ return own ?? '';
85
+ };
75
86
  const day1 = (x) => ({ ...x, day: x.day instanceof Date ? iso(x.day) : x.day });
76
87
  el.innerHTML = '';
77
88
  api = kit.agenda(el, {
@@ -83,7 +94,7 @@ export function mount(el, inputs, on) {
83
94
  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',
84
95
  onAction: (id) => send('action', id),
85
96
  onCreate: (x) => send('create', day1(x)),
86
- 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,
97
+ 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,
87
98
  onBlock: on.block ? (b) => send('block', day1(b)) : null,
88
99
  onMove: (m) => send('move', day1(m)),
89
100
  onRange: (r) => send('range', { from: iso(r.from), to: iso(r.to), view: r.view }),
@@ -35,6 +35,7 @@ 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")
38
39
  # events take by "agent" (made by the app's assistant): agentMark (an image url) goes before their name, and agentLabel
39
40
  # ("Booked by your assistant") under it on the phone's list and in what a screen reader says
40
41
  # AgendaLive(resources: @team, hours: @hours, events: @citas, blocks: @blocks, free: @free, queue: @queue, closed: @closed,
@@ -16,9 +16,29 @@ 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
+ // what an earlier opening as a card left on the element: a sheet or a dialog opened next must not keep it
25
+ const unplace = () => { mdl.style.left = ''; mdl.style.top = ''; mdl.style.transformOrigin = ''; };
26
+ const place = () => {
27
+ const at = anchor.split(',').map(Number);
28
+ if (!wantsAnchor() || phone.matches || at.length !== 4 || at.some((n) => !Number.isFinite(n))) { unplace(); if (wantsAnchor()) mdl.dataset.mode = phone.matches ? 'drawer' : 'dialog'; return; }
29
+ mdl.dataset.mode = 'anchor';
30
+ const [x, y, w] = at, gap = 12, edge = 16;
31
+ const width = mdl.offsetWidth, height = Math.min(mdl.scrollHeight, innerHeight - edge * 2);
32
+ const right = x + w + gap + width <= innerWidth - edge;
33
+ const left = right ? x + w + gap : Math.max(edge, x - gap - width);
34
+ const top = Math.min(Math.max(edge, y - 24), innerHeight - height - edge);
35
+ mdl.style.left = `${left}px`; mdl.style.top = `${top}px`;
36
+ mdl.style.transformOrigin = `${right ? 'left' : 'right'} ${Math.max(0, y - top + 12)}px`;
37
+ };
19
38
  const open = () => {
20
39
  if (isOpen) return; isOpen = true; placeFooter();
21
40
  if (auto) mdl.dataset.mode = phone.matches ? 'drawer' : 'dialog';
41
+ if (wantsAnchor()) place(); else unplace();
22
42
  lastFocus = document.activeElement;
23
43
  document.documentElement.classList.add('cx-locked');
24
44
  // on the phone, focusing a field would throw the keyboard over the sheet: focus the sheet itself
@@ -87,5 +107,9 @@ export function mount(el, inputs, on) {
87
107
  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 = ''; } };
88
108
  mdl.addEventListener('touchend', endPull); mdl.addEventListener('touchcancel', endPull);
89
109
  if (inputs.open === true || inputs.open === 'true') open();
90
- return (next) => { (next.open === true || next.open === 'true') ? open() : shut(); };
110
+ // the card follows the window, and its own height when a step inside changes it (it only moves when it would leave
111
+ // the screen: the top stays by the cita while there is room below)
112
+ addEventListener('resize', () => { if (isOpen && wantsAnchor()) place(); });
113
+ new ResizeObserver(() => { if (isOpen && mdl.dataset.mode === 'anchor') place(); }).observe(body);
114
+ return (next) => { anchor = String(next.anchor || ''); (next.open === true || next.open === 'true') ? open() : shut(); };
91
115
  }
@@ -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
  }