@muten/shadcn 0.0.5 → 0.1.0

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.
Files changed (213) hide show
  1. package/AGENTS.md +61 -6
  2. package/README.md +57 -4
  3. package/globals.css +30 -2
  4. package/kit.css +2763 -0
  5. package/motion.css +79 -0
  6. package/nova.css +1387 -0
  7. package/package.json +10 -2
  8. package/registry/accordion.muten +11 -10
  9. package/registry/agenda.js +35 -0
  10. package/registry/agenda.muten +10 -0
  11. package/registry/alert-dialog.muten +9 -12
  12. package/registry/alert.muten +8 -8
  13. package/registry/app-shell.js +9 -0
  14. package/registry/app-shell.muten +12 -0
  15. package/registry/attachment.muten +13 -9
  16. package/registry/avatar.muten +24 -5
  17. package/registry/badge.muten +23 -8
  18. package/registry/band.muten +16 -0
  19. package/registry/bar-chart.muten +7 -0
  20. package/registry/bell.muten +3 -0
  21. package/registry/bento.muten +6 -9
  22. package/registry/booking.js +21 -0
  23. package/registry/booking.muten +13 -0
  24. package/registry/breadcrumb.muten +9 -7
  25. package/registry/button-group.muten +13 -4
  26. package/registry/button.muten +21 -8
  27. package/registry/calendar.js +28 -93
  28. package/registry/calendar.muten +9 -8
  29. package/registry/canvas-viewport.js +12 -5
  30. package/registry/canvas-viewport.muten +9 -9
  31. package/registry/card-field.muten +4 -0
  32. package/registry/card.muten +12 -8
  33. package/registry/carousel.js +5 -36
  34. package/registry/carousel.muten +12 -5
  35. package/registry/chatbot.muten +9 -0
  36. package/registry/checklist.muten +6 -0
  37. package/registry/choice-list.muten +16 -0
  38. package/registry/code.js +14 -12
  39. package/registry/code.muten +6 -7
  40. package/registry/collapsible.muten +14 -10
  41. package/registry/color-picker.js +460 -41
  42. package/registry/color-picker.muten +7 -6
  43. package/registry/combobox.js +5 -0
  44. package/registry/combobox.muten +6 -23
  45. package/registry/command-palette.muten +8 -12
  46. package/registry/command.js +32 -0
  47. package/registry/command.muten +32 -11
  48. package/registry/context-menu.muten +9 -8
  49. package/registry/count-up.muten +5 -10
  50. package/registry/date-field.js +5 -0
  51. package/registry/date-field.muten +7 -0
  52. package/registry/device-frame.muten +8 -0
  53. package/registry/dialog.muten +6 -12
  54. package/registry/dirty-bar.js +19 -0
  55. package/registry/dirty-bar.muten +13 -0
  56. package/registry/drawer.muten +6 -11
  57. package/registry/dropdown-menu.muten +37 -13
  58. package/registry/empty.muten +11 -7
  59. package/registry/feature-grid.muten +10 -12
  60. package/registry/feature-rows.muten +12 -8
  61. package/registry/field-behavior.js +33 -0
  62. package/registry/field-behavior.muten +9 -0
  63. package/registry/field.muten +26 -7
  64. package/registry/file-input.js +36 -21
  65. package/registry/file-input.muten +8 -7
  66. package/registry/fixed-card.muten +25 -0
  67. package/registry/folder-picker.js +13 -15
  68. package/registry/folder-picker.muten +5 -8
  69. package/registry/gradient-blinds.muten +6 -13
  70. package/registry/hover-card.muten +7 -9
  71. package/registry/image-upload.js +5 -0
  72. package/registry/image-upload.muten +8 -0
  73. package/registry/indicator.muten +5 -0
  74. package/registry/input-group.muten +27 -4
  75. package/registry/input-otp.js +8 -35
  76. package/registry/input-otp.muten +6 -6
  77. package/registry/input.muten +8 -7
  78. package/registry/inspector.muten +13 -15
  79. package/registry/island.js +7 -0
  80. package/registry/island.muten +5 -0
  81. package/registry/item.muten +20 -7
  82. package/registry/kbd.muten +3 -3
  83. package/registry/kit/board.js +300 -0
  84. package/registry/kit/collections.js +148 -0
  85. package/registry/kit/core.js +989 -0
  86. package/registry/kit/dates.js +106 -0
  87. package/registry/kit/menus.js +90 -0
  88. package/registry/kit/overlays.js +114 -0
  89. package/registry/kit/paint.js +211 -0
  90. package/registry/kit/qrcode.js +2291 -0
  91. package/registry/kit/structure.js +393 -0
  92. package/registry/kit/table.js +198 -0
  93. package/registry/kit/values.js +179 -0
  94. package/registry/kit-layer.js +2 -0
  95. package/registry/kit-layer.muten +5 -0
  96. package/registry/kit-skeleton.js +24 -0
  97. package/registry/kit-skeleton.muten +16 -0
  98. package/registry/kit-widget.js +79 -0
  99. package/registry/kit-widget.muten +4 -0
  100. package/registry/link-btn.muten +9 -7
  101. package/registry/live-stack.js +29 -0
  102. package/registry/live-stack.muten +7 -0
  103. package/registry/logo-cloud.muten +6 -11
  104. package/registry/member-list.muten +6 -0
  105. package/registry/menubar.muten +10 -14
  106. package/registry/message-scroller.js +12 -0
  107. package/registry/message-scroller.muten +8 -4
  108. package/registry/message.muten +27 -10
  109. package/registry/messaging.muten +11 -0
  110. package/registry/modal-behavior.js +56 -0
  111. package/registry/modal-behavior.muten +4 -0
  112. package/registry/modal.muten +29 -0
  113. package/registry/money-field.js +5 -0
  114. package/registry/money-field.muten +7 -0
  115. package/registry/nav-button.muten +14 -0
  116. package/registry/navigation-menu.muten +16 -14
  117. package/registry/navpill.muten +15 -9
  118. package/registry/new-appointment.js +8 -0
  119. package/registry/new-appointment.muten +10 -0
  120. package/registry/number-field.js +34 -24
  121. package/registry/number-field.muten +8 -6
  122. package/registry/order-board.js +6 -0
  123. package/registry/order-board.muten +21 -0
  124. package/registry/overlay-behavior.js +13 -0
  125. package/registry/overlay-behavior.muten +4 -0
  126. package/registry/pagination-nav.js +6 -0
  127. package/registry/pagination-nav.muten +8 -0
  128. package/registry/pagination.muten +12 -9
  129. package/registry/password-field.muten +3 -0
  130. package/registry/permission-matrix.muten +6 -0
  131. package/registry/phone-field.js +5 -0
  132. package/registry/phone-field.muten +7 -0
  133. package/registry/phone-shell.js +7 -0
  134. package/registry/phone-shell.muten +9 -0
  135. package/registry/pill.muten +6 -5
  136. package/registry/plans.muten +4 -0
  137. package/registry/popover.muten +16 -9
  138. package/registry/pricing.muten +21 -21
  139. package/registry/progress.muten +5 -5
  140. package/registry/queue.muten +3 -0
  141. package/registry/radio-group.muten +16 -8
  142. package/registry/range-slider.js +9 -24
  143. package/registry/range-slider.muten +4 -5
  144. package/registry/resizable.js +34 -27
  145. package/registry/resizable.muten +16 -5
  146. package/registry/roving-behavior.js +16 -0
  147. package/registry/roving-behavior.muten +3 -0
  148. package/registry/row-menu.js +5 -0
  149. package/registry/row-menu.muten +5 -0
  150. package/registry/save-button.muten +11 -0
  151. package/registry/scale-slider.js +7 -0
  152. package/registry/scale-slider.muten +9 -0
  153. package/registry/scroll-area.muten +8 -4
  154. package/registry/segmented-control.muten +8 -8
  155. package/registry/separator.muten +3 -3
  156. package/registry/share-button.js +7 -0
  157. package/registry/share-button.muten +10 -0
  158. package/registry/sheet.muten +6 -10
  159. package/registry/sidebar.muten +23 -15
  160. package/registry/skeleton.muten +6 -4
  161. package/registry/slider.js +73 -27
  162. package/registry/slider.muten +6 -6
  163. package/registry/sortable-behavior.js +43 -0
  164. package/registry/sortable-behavior.muten +2 -0
  165. package/registry/sortable-list.js +10 -26
  166. package/registry/sortable-list.muten +6 -7
  167. package/registry/sortable.muten +15 -0
  168. package/registry/spark.js +14 -0
  169. package/registry/spark.muten +3 -0
  170. package/registry/spinner.muten +4 -3
  171. package/registry/split-button.muten +12 -10
  172. package/registry/stamp-card.muten +7 -0
  173. package/registry/stat-band.muten +6 -9
  174. package/registry/stat.muten +23 -0
  175. package/registry/status-bar.muten +7 -6
  176. package/registry/stepper.muten +17 -14
  177. package/registry/store-qr.js +5 -0
  178. package/registry/store-qr.muten +5 -0
  179. package/registry/swatch.muten +8 -8
  180. package/registry/switch.muten +14 -6
  181. package/registry/table-view.muten +10 -0
  182. package/registry/tabs-behavior.js +27 -0
  183. package/registry/tabs-behavior.muten +3 -0
  184. package/registry/tabs.muten +19 -10
  185. package/registry/tag-input.js +4 -27
  186. package/registry/tag-input.muten +6 -6
  187. package/registry/text.muten +7 -0
  188. package/registry/time-field.js +5 -0
  189. package/registry/time-field.muten +6 -0
  190. package/registry/timeline.muten +14 -14
  191. package/registry/toaster.js +34 -22
  192. package/registry/toaster.muten +10 -8
  193. package/registry/toggle-group.muten +11 -6
  194. package/registry/toggle.muten +8 -5
  195. package/registry/toolbar.muten +11 -6
  196. package/registry/tooltip.muten +6 -9
  197. package/registry/tree-view.js +27 -7
  198. package/registry/tree-view.muten +8 -13
  199. package/registry/vector-field.muten +9 -8
  200. package/registry/virtual-list.js +5 -0
  201. package/registry/virtual-list.muten +6 -0
  202. package/registry/weekly-hours.muten +4 -0
  203. package/registry/wordline.muten +6 -0
  204. package/registry/zoom-control.muten +9 -9
  205. package/registry.json +868 -424
  206. package/themes/bloom.css +53 -0
  207. package/themes/editorial.css +65 -0
  208. package/themes/noir.css +54 -0
  209. package/registry/chart.js +0 -34
  210. package/registry/chart.muten +0 -7
  211. package/registry/checkbox.muten +0 -8
  212. package/registry/context-menu.js +0 -34
  213. package/registry/select.muten +0 -21
@@ -0,0 +1,300 @@
1
+ // kit/board.js - the order Board and its card (the reference artifact's «Tablero» and «Tarjeta: mínimo y máximo"),
2
+ // composed only from atoms. Lanes and cards are data; the card groups its information by what it is about and
3
+ // orders it by what needs the owner first. Drag with the mouse, hold on touch, Space/arrows with the keyboard.
4
+ // lanes [{id, title, next}] next = the label of the step that moves a card INTO the following lane
5
+ // cards [{id, lane, who, sub, total, contact, status [tone,label], request {…} | [{…}], order {count, photos, extras},
6
+ // pay {method transfer|card|cash, tone, label, detail}, delivery {mode ship|pickup, label, when, map, track},
7
+ // note, actions [["advance", label]]}] photos and request.photo are CSS backgrounds (a gradient or url()).
8
+ // sizes [{title, description, card}] renders those cards side by side (static) instead of a board.
9
+ // on.change(json [{id, lane}]) after a card moves; on.action(json {id, action}) for every card button.
10
+ export function orderBoard(root, i, core, on) {
11
+ const { ui, $, $$, lib, openModal: open, closeModal, CONTENT, body, foot, easeHeight, money0, hue, ini } = core;
12
+ const list = (v) => (Array.isArray(v) ? v : []);
13
+ const copy = (x) => JSON.parse(JSON.stringify(x));
14
+ const CARDS = Object.fromEntries(list(i.cards).map((c) => [c.id, { actions: [], ...copy(c) }]));
15
+ const LANES_B = list(i.lanes).map((l) => ({ ...l, cards: list(i.cards).filter((c) => c.lane === l.id).map((c) => c.id) }));
16
+ const SIZES = list(i.sizes).map((s) => [s.title, s.description || '', s.card]);
17
+ const btn = (variant, label, attrs = '') => `<button data-slot="button" class="cn-button cn-button-variant-${variant} cn-button-size-default group/button" ${attrs}>${label}</button>`;
18
+ // one shared modal: this board's content is put in place right before it opens
19
+ const KINDS = {}; const openModal = (kind, mode) => { CONTENT[kind] = KINDS[kind]; open(kind, mode); };
20
+ const emit = () => on.change?.(JSON.stringify($$('.cx-bcard', brd).map((c) => ({ id: c.dataset.card, lane: c.closest('.cx-blane')?.dataset.lane }))));
21
+ root.innerHTML = SIZES.length ? '<div class="cx-card-sizes"></div>' : '<div class="cx-board cx-scroll"></div><p class="sr-only cx-board-live" aria-live="assertive"></p>';
22
+ const TONE_CLS = { ok: 'cx-tone-ok', wait: 'cx-tone-wait', new: 'cx-tone-new', bad: 'cx-tone-bad', info: 'cx-tone-new', muted: 'cn-badge-variant-outline' };
23
+ const badge = ([t, l, dot = t !== 'muted']) => `<span data-slot="badge" class="cn-badge group/badge ${TONE_CLS[t]}">${dot ? '<span class="cx-dot"></span>' : ''}${l}</span>`;
24
+ const bBtn = (act, label, variant = 'outline', icon = '') => `<button data-slot="button" class="cn-button cn-button-variant-${variant} cn-button-size-xs group/button" data-bact="${act}">${icon ? `<svg><use href="#${icon}"/></svg>` : ''}${label}</button>`;
25
+ const miniMap = (m) => `<svg class="cx-map-svg" viewBox="0 0 240 110" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
26
+ <rect width="240" height="110" class="mp-ground"/>
27
+ <path class="mp-park" d="M150 8h52v34h-52z"/><path class="mp-water" d="M0 92c40-10 70 6 110-2s70-14 130-4v24H0z"/>
28
+ <g class="mp-street"><path d="M0 30h240M0 64h240M40 0v110M98 0v110M170 0v110M214 0v110"/></g>
29
+ <g class="mp-minor"><path d="M0 47h240M69 0v110M134 0v110"/></g>
30
+ ${m.route ? '<path class="mp-route" d="M40 64H98V30h72V64h44"/>' : ''}
31
+ <circle cx="40" cy="64" r="5" class="mp-shop"/>
32
+ <g transform="translate(${m.route ? 214 : 170} 64)"><path class="mp-pin" d="M0 0c-6-8-10-12-10-17a10 10 0 0 1 20 0c0 5-4 9-10 17z"/><circle cy="-17" r="3.5" class="mp-pin-dot"/></g>
33
+ </svg>`;
34
+ // ── Order card for Storio, composed only from atoms ──
35
+ // Card > CardHeader (Item: Avatar · name · line · contact Button) + attention Buttons
36
+ // > CardContent (ItemGroup: Productos · Pago · Entrega, split by ItemSeparator)
37
+ // > CardFooter (the one Button that changes the state)
38
+ const TONE = { ok: 'ok', wait: 'wait', new: 'info', bad: 'bad', info: 'info', muted: 'muted' };
39
+ const GROUPS = {
40
+ order: (o) => ui.item({
41
+ media: ui.itemMedia(ui.icon('bag')),
42
+ title: `${o.count} ${o.count === 1 ? 'producto' : 'productos'}`,
43
+ footer: `<div class="cx-vstack"><div class="cx-hstack">${o.photos.slice(0, 5).map((x) => ui.avatar(x, { shape: 'square', size: 'lg', bg: x })).join('')}${o.photos.length > 5 ? `<span data-slot="avatar" data-size="lg" data-shape="square" class="cn-avatar group/avatar"><span class="cn-avatar-fallback">+${o.photos.length - 5}</span></span>` : ''}</div>${o.extras?.length ? `<div class="cx-hstack">${o.extras.map((e) => ui.badge(e, { variant: 'outline' })).join('')}</div>` : ''}</div>`,
44
+ }),
45
+ pay: (p, c) => {
46
+ const action = p.method === 'transfer' && p.tone === 'new' ? ['receipt', 'Revisar comprobante', 'image'] : p.method === 'card' ? ['stripe', 'Ver recibo de Stripe', 'card'] : null;
47
+ return ui.item({
48
+ media: ui.itemMedia(ui.icon('card')),
49
+ title: `<span>Pago · ${money0(c.total)}</span>`,
50
+ description: p.detail || '',
51
+ actions: ui.badge(p.label, { tone: TONE[p.tone] }),
52
+ footer: action ? ui.button({ label: action[1], icon: action[2], attrs: `data-bact="${action[0]}"`, cls: 'cx-fill' }) : '',
53
+ });
54
+ },
55
+ delivery: (d) => {
56
+ const info = d.track ? `<b>${d.track.steps[d.track.at]}</b> · ${d.track.courier.split(' ')[0]}, ${d.track.eta}` : d.map ? d.map.address : '';
57
+ const more = (d.track || d.map) ? ui.button({ label: d.track ? 'Ver entrega' : 'Ver mapa', size: 'xs', attrs: 'data-bact="delivery"' }) : '';
58
+ return ui.item({
59
+ media: ui.itemMedia(ui.icon(d.mode === 'pickup' ? 'store' : 'truck')),
60
+ title: `${d.label}${d.when ? ` · ${d.when}` : ''}`,
61
+ description: info,
62
+ actions: more,
63
+ footer: d.track ? ui.progress((d.track.at / (d.track.steps.length - 1)) * 100, `Entrega: ${d.track.steps[d.track.at]}`) : '',
64
+ });
65
+ },
66
+ };
67
+ const GROUP_ORDER = ['order', 'pay', 'delivery'];
68
+ const SIGNAL_TONE = { wait: 'wait', bad: 'bad', info: 'info' };
69
+ const signals = (c) => {
70
+ const list = c.request ? (Array.isArray(c.request) ? c.request : [c.request]) : [];
71
+ const items = list.map((r, i) => ui.button({ icon: r.icon, size: 'icon-sm', variant: 'ghost', tone: SIGNAL_TONE[r.tone] || 'info', cls: 'cx-has-indicator', attrs: `data-bact="request" data-i="${i}" data-name="${r.title}" aria-haspopup="dialog" aria-label="${r.title}${r.text ? ': ' + r.text : ''}"` }).replace('</button>', `${ui.indicator(SIGNAL_TONE[r.tone] || 'info')}</button>`));
72
+ if (c.note) items.push(ui.button({ icon: 'quote', size: 'icon-sm', variant: 'ghost', tone: 'note', cls: 'cx-has-indicator', attrs: `data-bact="note" data-name="Nota del cliente" aria-haspopup="dialog" aria-label="Nota de ${c.who.split(' ')[0]}: ${c.note}"` }).replace('</button>', `${ui.indicator('note')}</button>`));
73
+ return items.length ? `<div class="cx-hstack" role="group" aria-label="Pendientes">${items.join('')}</div>` : '';
74
+ };
75
+ const CONTACT = {
76
+ whatsapp: { icon: 'b-whatsapp', color: '#25D366', name: 'WhatsApp' },
77
+ telegram: { icon: 'b-telegram', color: '#26A5E4', name: 'Telegram' },
78
+ instagram: { icon: 'b-instagram', color: '#E4405F', name: 'Instagram' },
79
+ phone: { icon: 'call', color: 'var(--foreground)', name: 'una llamada' },
80
+ };
81
+ const cardInner = (id) => {
82
+ const c = CARDS[id], via = CONTACT[c.contact];
83
+ const contact = via ? ui.button({ icon: via.icon, size: 'icon-sm', variant: 'ghost', tone: 'brand', attrs: `style="--brand:${via.color}" data-bact="contact" aria-label="Contactar a ${c.who} por ${via.name}, como lo pidió" title="Prefiere ${via.name}"` }) : '';
84
+ const person = ui.item({
85
+ media: ui.itemMedia(ui.avatar(c.who), 'default'),
86
+ title: `<span class="cx-ellipsis" title="${c.who}">${c.who}</span>`,
87
+ description: `<span class="cx-ellipsis" title="${c.sub}">${c.sub}</span>`,
88
+ actions: contact || (c.status ? ui.badge(c.status[1], { tone: TONE[c.status[0]] }) : (c.pay ? '' : `<span class="cx-num cx-strong">${money0(c.total)}</span>`)),
89
+ footer: (c.status && contact ? ui.badge(c.status[1], { tone: TONE[c.status[0]] }) : '') + signals(c),
90
+ });
91
+ const groups = GROUP_ORDER.filter((k) => c[k]).map((k) => GROUPS[k](c[k], c));
92
+ const next = c.actions.find(([a]) => a === 'advance');
93
+ return `<div data-slot="card-header" class="cn-card-header">${person}</div>`
94
+ + (groups.length ? `<div data-slot="card-content" class="cn-card-content"><div data-slot="item-group" class="cn-item-group" role="list">${groups.join(ui.itemSeparator())}</div></div>` : '')
95
+ + (next ? `<div data-slot="card-footer" class="cn-card-footer">${ui.button({ label: `${next[1]}<svg><use href="#right"/></svg>`, variant: 'default', size: 'default', attrs: 'data-bact="advance"', cls: 'cx-fill' })}</div>` : '');
96
+ };
97
+
98
+ const brd = $('.cx-board', root) || document.createElement('div'), brdLive = $('.cx-board-live', root);
99
+ brd.innerHTML = LANES_B.map((l) => `<section class="cx-blane" data-lane="${l.id}" aria-label="${l.title}"><div class="cx-lane-h"><span>${l.title}</span><span class="cx-count"></span></div><div class="cx-lane-list">${l.cards.map((id) => `<div data-slot="card" data-size="sm" class="cn-card group/card cx-bcard" tabindex="0" data-card="${id}" aria-roledescription="tarjeta que se puede mover" aria-label="Pedido ${id} de ${CARDS[id].who}">${cardInner(id)}</div>`).join('')}</div></section>`).join('');
100
+ const lanesEls = () => $$('.cx-blane', brd);
101
+ const syncLanes = () => lanesEls().forEach((l) => {
102
+ const list = $('.cx-lane-list', l), n = $$('.cx-bcard', list).length;
103
+ $('.cx-count', l).textContent = n;
104
+ const empty = $('.cx-lane-empty-b', list);
105
+ if (!n && !$('.cx-bph', list) && !empty) list.insertAdjacentHTML('beforeend', '<div class="cx-lane-empty-b">Suelta aquí un pedido</div>');
106
+ if ((n || $('.cx-bph', list)) && empty) empty.remove();
107
+ });
108
+ const sayCard = (card) => { const lane = card.closest('.cx-blane'), list = $$('.cx-bcard', lane); brdLive.textContent = `Pedido ${card.dataset.card}: ${lane.getAttribute('aria-label')}, posición ${list.indexOf(card) + 1} de ${list.length}`; };
109
+ const flip = (els, change) => { // animate siblings from their old place to the new one
110
+ const before = new Map(els.map((x) => [x, x.getBoundingClientRect()])); change();
111
+ before.forEach((r, x) => { const n = x.getBoundingClientRect(); const dx = r.left - n.left, dy = r.top - n.top; if (dx || dy) x.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: 200, easing: 'cubic-bezier(.22, 1, .36, 1)' }); });
112
+ };
113
+ let bDrag = null;
114
+ brd.addEventListener('pointerdown', (e) => {
115
+ const card = e.target.closest('.cx-bcard'); if (!card || e.button > 0 || card.hasAttribute('data-lifted') || e.target.closest('button, a, input')) return;
116
+ bDrag = { card, x0: e.clientX, y0: e.clientY, lx: e.clientX, ly: e.clientY, live: false, id: e.pointerId, touch: e.pointerType !== 'mouse', held: false, pan: false };
117
+ // on touch, a swipe moves the board and the page; holding still for a moment picks the card up
118
+ if (bDrag.touch) { const d = bDrag; d.timer = setTimeout(() => { if (bDrag === d && !d.pan) { d.held = true; card.setAttribute('data-held', ''); navigator.vibrate?.(8); } }, 260); }
119
+ });
120
+ addEventListener('pointermove', (e) => {
121
+ if (!bDrag || e.pointerId !== bDrag.id) return;
122
+ const d = bDrag;
123
+ if (d.pan) { brd.scrollLeft -= e.clientX - d.lx; scrollBy(0, -(e.clientY - d.ly)); d.lx = e.clientX; d.ly = e.clientY; return; }
124
+ if (!d.live) {
125
+ if (Math.hypot(e.clientX - d.x0, e.clientY - d.y0) < 6) return;
126
+ if (d.touch && !d.held) { clearTimeout(d.timer); d.pan = true; d.lx = e.clientX; d.ly = e.clientY; return; }
127
+ d.card.removeAttribute('data-held');
128
+ d.live = true; const r = d.card.getBoundingClientRect();
129
+ d.dx = d.x0 - r.left; d.dy = d.y0 - r.top;
130
+ d.ph = document.createElement('div'); d.ph.className = 'cx-bph'; d.ph.style.height = r.height + 'px';
131
+ d.card.after(d.ph); d.card.style.width = r.width + 'px'; d.card.classList.add('cx-dragging'); lib.appendChild(d.card);
132
+ d.from = { list: d.ph.parentElement, next: d.ph.nextSibling };
133
+ }
134
+ d.card.style.left = e.clientX - d.dx + 'px'; d.card.style.top = e.clientY - d.dy + 'px';
135
+ const lane = document.elementFromPoint(e.clientX, e.clientY)?.closest?.('.cx-blane');
136
+ lanesEls().forEach((l) => l.toggleAttribute('data-over', l === lane));
137
+ if (!lane) return;
138
+ const list = $('.cx-lane-list', lane), cards = $$('.cx-bcard', list);
139
+ const before = cards.find((c) => { const r = c.getBoundingClientRect(); return e.clientY < r.top + r.height / 2; }) || null;
140
+ if (d.ph.parentElement === list && (d.ph.nextElementSibling === before || (!before && d.ph === list.lastElementChild))) return;
141
+ flip($$('.cx-bcard', brd), () => { list.insertBefore(d.ph, before); syncLanes(); });
142
+ });
143
+ const bEnd = () => {
144
+ if (!bDrag) return; const d = bDrag; bDrag = null;
145
+ clearTimeout(d.timer); d.card.removeAttribute('data-held');
146
+ if (!d.live) return;
147
+ lanesEls().forEach((l) => l.removeAttribute('data-over'));
148
+ const to = d.ph.getBoundingClientRect(), from = d.card.getBoundingClientRect();
149
+ d.card.classList.remove('cx-dragging'); d.card.style.cssText = '';
150
+ d.ph.replaceWith(d.card); syncLanes();
151
+ d.card.animate([{ transform: `translate(${from.left - to.left}px, ${from.top - to.top}px) rotate(1.5deg)` }, { transform: 'none' }], { duration: 220, easing: 'cubic-bezier(.22, 1, .36, 1)' });
152
+ sayCard(d.card); emit();
153
+ };
154
+ addEventListener('pointerup', bEnd); addEventListener('pointercancel', bEnd);
155
+ let lifted = null;
156
+ brd.addEventListener('keydown', (e) => {
157
+ const card = e.target.closest('.cx-bcard'); if (!card || e.target !== card) return;
158
+ if (e.key === ' ' || e.key === 'Enter') {
159
+ e.preventDefault();
160
+ if (lifted === card) { card.removeAttribute('data-lifted'); lifted = null; brdLive.textContent = `Soltado. ${brdLive.textContent}`; sayCard(card); emit(); return; }
161
+ lifted = card; card.setAttribute('data-lifted', ''); card._home = { list: card.parentElement, next: card.nextSibling };
162
+ brdLive.textContent = `Levantaste el pedido ${card.dataset.card}. Flechas para moverlo, Espacio para soltar, Esc para cancelar.`; return;
163
+ }
164
+ if (lifted !== card) return;
165
+ const list = card.parentElement, lane = card.closest('.cx-blane'), ls = lanesEls(), li = ls.indexOf(lane);
166
+ let move = null;
167
+ if (e.key === 'ArrowUp' && card.previousElementSibling?.classList.contains('cx-bcard')) move = () => list.insertBefore(card, card.previousElementSibling);
168
+ if (e.key === 'ArrowDown' && card.nextElementSibling?.classList.contains('cx-bcard')) move = () => list.insertBefore(card.nextElementSibling, card);
169
+ if ((e.key === 'ArrowLeft' && li > 0) || (e.key === 'ArrowRight' && li < ls.length - 1)) {
170
+ const target = $('.cx-lane-list', ls[li + (e.key === 'ArrowLeft' ? -1 : 1)]), idx = $$('.cx-bcard', list).indexOf(card);
171
+ move = () => target.insertBefore(card, $$('.cx-bcard', target)[idx] || null);
172
+ }
173
+ if (e.key === 'Escape') { e.preventDefault(); move = () => card._home.list.insertBefore(card, card._home.next); card.removeAttribute('data-lifted'); lifted = null; }
174
+ if (!move) return;
175
+ e.preventDefault(); flip($$('.cx-bcard', brd), () => { move(); syncLanes(); }); card.focus({ preventScroll: true }); card.scrollIntoView({ block: 'nearest', inline: 'nearest' }); sayCard(card);
176
+ });
177
+ // move the board sideways: drag its background with the mouse (touch pans natively), wheel with Shift,
178
+ // and while a card is being dragged near an edge the board scrolls by itself so every lane is reachable
179
+ let pan = null;
180
+ brd.addEventListener('pointerdown', (e) => {
181
+ if (e.pointerType !== 'mouse' || e.button > 0 || e.target.closest('.cx-bcard, button, a')) return;
182
+ pan = { x: e.clientX, left: brd.scrollLeft, moved: false };
183
+ });
184
+ addEventListener('pointermove', (e) => {
185
+ if (!pan) return; const dx = e.clientX - pan.x;
186
+ if (!pan.moved && Math.abs(dx) > 3) { pan.moved = true; brd.classList.add('panning'); }
187
+ if (pan.moved) brd.scrollLeft = pan.left - dx;
188
+ });
189
+ addEventListener('pointerup', () => { if (!pan) return; pan = null; brd.classList.remove('panning'); });
190
+ const boardEdges = () => { brd.toggleAttribute('data-start', brd.scrollLeft > 2); brd.toggleAttribute('data-end', brd.scrollLeft < brd.scrollWidth - brd.clientWidth - 2); };
191
+ brd.addEventListener('scroll', boardEdges, { passive: true }); new ResizeObserver(boardEdges).observe(brd);
192
+ let edgeX = null;
193
+ const edgeTick = () => {
194
+ if (!bDrag?.live) { edgeX = null; return; }
195
+ if (edgeX != null) { const r = brd.getBoundingClientRect(), zone = 70; const v = edgeX < r.left + zone ? -(r.left + zone - edgeX) : edgeX > r.right - zone ? edgeX - (r.right - zone) : 0; if (v) brd.scrollLeft += Math.max(-18, Math.min(18, v / 3)); }
196
+ requestAnimationFrame(edgeTick);
197
+ };
198
+ addEventListener('pointermove', (e) => { if (bDrag?.live) { if (edgeX == null) requestAnimationFrame(edgeTick); edgeX = e.clientX; } });
199
+
200
+ // the card's own buttons
201
+ let receiptFor = null, replyFor = null, deliveryFor = null, requestFor = null, requestIdx = 0;
202
+ KINDS.request = () => {
203
+ const c = CARDS[requestFor], list = Array.isArray(c.request) ? c.request : [c.request], r = list[requestIdx] || list[0];
204
+ $('#mdl-title').textContent = r.title; $('#mdl-desc').textContent = `${c.who} · pedido ${requestFor}${r.when ? ' · ' + r.when : ''}`;
205
+ body.innerHTML = `${r.text ? `<blockquote class="cx-req-full" data-tone="${r.tone}">${r.text}</blockquote>` : '<p class="cx-note" style="text-align:left">Sin mensaje del cliente.</p>'}${r.photo ? `<span class="cx-bc-ph" style="width:96px;background:${r.photo}"></span>` : ''}${list.length > 1 ? `<div class="cx-req-next"><b>También por resolver</b>${list.filter((x) => x !== r).map((x) => `<span><svg><use href="#${x.icon}"/></svg>${x.title}</span>`).join('')}</div>` : ''}`;
206
+ const [yes, no] = r.actions;
207
+ foot.innerHTML = (no ? btn('outline', no[1], `data-req-act="${no[0]}"`) : btn('outline', 'Cerrar', 'data-dismiss')) + (yes ? btn('default', yes[1], `data-req-act="${yes[0]}"`) : '');
208
+ foot.firstElementChild.classList.add('cx-exit'); foot.lastElementChild.classList.add('cx-go');
209
+ };
210
+ document.addEventListener('click', (e) => {
211
+ const r = e.target.closest('[data-req-act]'); if (!r || !requestFor) return;
212
+ const card = $(`.cx-bcard[data-card="${requestFor}"]`, root), act = r.dataset.reqAct, idx = requestIdx; requestFor = null; closeModal();
213
+ // run the card's action once the modal (and its history entry) is gone, so a follow-up modal can open
214
+ setTimeout(() => { if (!card) return; const t = document.createElement('button'); t.dataset.bact = act; t.dataset.i = idx; t.hidden = true; card.appendChild(t); t.click(); t.remove(); }, 420);
215
+ });
216
+ let noteFor = null, stripeFor = null;
217
+ KINDS.note = () => {
218
+ const c = CARDS[noteFor];
219
+ $('#mdl-title').textContent = `Nota de ${c.who.split(' ')[0]}`; $('#mdl-desc').textContent = `${c.who} · pedido ${noteFor}`;
220
+ body.innerHTML = `<blockquote class="cx-req-full" data-tone="note">${c.note}</blockquote>`;
221
+ foot.innerHTML = btn('outline', 'Cerrar', 'data-dismiss') + btn('default', 'Copiar nota', 'data-note-copy');
222
+ foot.firstElementChild.classList.add('cx-exit'); foot.lastElementChild.classList.add('cx-go');
223
+ };
224
+ KINDS.stripe = () => {
225
+ const c = CARDS[stripeFor], p = c.pay;
226
+ $('#mdl-title').textContent = 'Recibo de pago'; $('#mdl-desc').textContent = `${c.who} · pedido ${stripeFor}`;
227
+ body.innerHTML = `<div class="cx-stripe"><div class="cx-stripe-top"><span class="cx-stripe-amt cx-num">${money0(c.total)}</span><span data-slot="badge" class="cn-badge group/badge cx-tone-ok"><span class="cx-dot"></span>Pagado</span></div><dl><dt>Medio</dt><dd>${p.detail.split(' · ')[0]}</dd><dt>Procesado por</dt><dd>Stripe</dd><dt>Fecha</dt><dd>8 oct 2026 · 09:14</dd><dt>ID del pago</dt><dd class="cx-num">pi_3Q${stripeFor.slice(1)}xK8aR2</dd><dt>Comisión</dt><dd class="cx-num">${money0(Math.round(c.total * 0.039))}</dd><dt>Recibes</dt><dd class="cx-num"><b>${money0(c.total - Math.round(c.total * 0.039))}</b></dd></dl></div>`;
228
+ foot.innerHTML = btn('outline', 'Cerrar', 'data-dismiss') + `<a data-slot="button" class="cn-button cn-button-variant-default cn-button-size-default group/button cx-go" href="https://dashboard.stripe.com/test/payments" target="_blank" rel="noopener">Abrir en Stripe</a>`;
229
+ foot.firstElementChild.classList.add('cx-exit');
230
+ };
231
+ document.addEventListener('click', (e) => {
232
+ if (!e.target.closest('[data-note-copy]') || !noteFor) return;
233
+ const t = e.target.closest('[data-note-copy]'), text = CARDS[noteFor].note;
234
+ try { navigator.clipboard.writeText(text).then(() => { t.textContent = 'Copiada'; }, () => { getSelection().selectAllChildren($('.cx-req-full', body)); }); } catch { getSelection().selectAllChildren($('.cx-req-full', body)); }
235
+ });
236
+ KINDS.delivery = () => {
237
+ const c = CARDS[deliveryFor], d = c.delivery;
238
+ $('#mdl-title').textContent = d.label; $('#mdl-desc').textContent = `${c.who} · pedido ${deliveryFor}${d.when ? ' · ' + d.when : ''}`;
239
+ const steps = d.track ? `<div class="cx-dl-steps" style="--n:${d.track.steps.length}">${d.track.steps.map((st, i) => `<div ${i < d.track.at ? 'data-done' : i === d.track.at ? 'data-now aria-current="step"' : ''}><i></i><span>${st}</span></div>`).join('')}</div>` : '';
240
+ const courier = d.track ? `<div class="cx-dl-courier"><span data-slot="avatar" data-size="default" class="cn-avatar cx-av" style="--h:${hue(d.track.courier)}"><span class="cn-avatar-fallback">${ini(d.track.courier)}</span></span><div><b>${d.track.courier}</b><small>Domiciliario · ${d.track.vehicle}</small></div><div class="cx-dl-eta"><small>Llega en</small><b>${d.track.eta}</b></div></div>` : '';
241
+ body.innerHTML = `<div class="cx-dl">${d.map ? `<div class="cx-dl-map">${miniMap(d.map)}</div><div class="cx-dl-addr"><svg><use href="#mappin"/></svg><div><b>${d.map.address}</b><small>${d.map.detail}</small></div></div>` : ''}${steps}${courier}</div>`;
242
+ foot.innerHTML = btn('outline', 'Cerrar', 'data-dismiss') + (d.map ? `<a data-slot="button" class="cn-button cn-button-variant-default cn-button-size-default group/button cx-go" href="https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(d.map.address)}" target="_blank" rel="noopener"><svg><use href="#mappin"/></svg>Abrir en Maps</a>` : '');
243
+ foot.firstElementChild.classList.add('cx-exit');
244
+ };
245
+ const redraw = (id) => { const card = $(`.cx-bcard[data-card="${id}"]`, root); easeHeight(card, () => { card.innerHTML = cardInner(id); }); card.animate([{ boxShadow: '0 0 0 2px var(--selected)' }, { boxShadow: '0 0 0 1px var(--border)' }], { duration: 700, easing: 'ease-out' }); };
246
+ const onCardButton = (e) => {
247
+ const b = e.target.closest('[data-bact]'); if (!b) return;
248
+ const card = b.closest('.cx-bcard'); if (!card) return; const id = card.dataset.card, act = b.dataset.bact, c = CARDS[id];
249
+ on.action?.(JSON.stringify({ id, action: act }));
250
+ if (act === 'advance') {
251
+ const ls = lanesEls().filter((l) => l.dataset.lane !== 'cancel'), li = ls.indexOf(card.closest('.cx-blane')); if (li < 0 || li >= ls.length - 1) return;
252
+ const NEXT = Object.fromEntries(LANES_B.map((l) => [l.id, l.next ? [['advance', l.next, 'secondary']] : []]));
253
+ const to = ls[li + 1]; c.actions = NEXT[to.dataset.lane] ?? c.actions;
254
+ if (to.dataset.lane === 'done') { if (c.delivery) delete c.delivery.track; c.status = ['ok', 'Entregado']; }
255
+ flip($$('.cx-bcard', brd), () => { $('.cx-lane-list', to).prepend(card); card.innerHTML = cardInner(id); syncLanes(); });
256
+ card.focus({ preventScroll: true }); sayCard(card); emit();
257
+ }
258
+ if (act === 'receipt') { receiptFor = id; openModal('receipt', 'auto'); }
259
+ if (act === 'delivery') { deliveryFor = id; openModal('delivery', 'auto'); }
260
+ if (act === 'request') { requestFor = id; requestIdx = +(b.dataset.i || 0); openModal('request', 'auto'); }
261
+ if (act === 'note') { noteFor = id; openModal('note', 'auto'); }
262
+ if (act === 'stripe') { stripeFor = id; openModal('stripe', 'auto'); }
263
+ if (act === 'contact') brdLive.textContent = `Abriendo ${CONTACT[c.contact].name} con ${c.who}`;
264
+ if (act === 'reply') { replyFor = id; openModal('reply', 'auto'); }
265
+ if (act === 'cancel-yes') { delete c.request; c.status = ['bad', 'Cancelado']; c.pay = { ...c.pay, tone: 'muted', label: `Devuelto ${money0(c.total)}` }; delete c.delivery; c.actions = []; if (!card.closest('.cx-board')) { redraw(id); return; } const to = $('[data-lane=cancel] .cx-lane-list', brd); flip($$('.cx-bcard', brd), () => { to.prepend(card); card.innerHTML = cardInner(id); syncLanes(); }); to.closest('.cx-blane').scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }); brdLive.textContent = `Pedido ${id} cancelado y devuelto`; emit(); }
266
+ if (act === 'cancel-no') { const rest = Array.isArray(c.request) ? c.request.filter((_, i) => i !== +(b.dataset.i || 0)) : []; if (rest.length) c.request = rest.length === 1 ? rest[0] : rest; else delete c.request; c.actions = [['advance', 'Preparar', 'secondary']]; redraw(id); }
267
+ if (act === 'refund') { if (Array.isArray(c.request)) { c.request = c.request.filter((_, i) => i !== +(b.dataset.i || 0)); if (c.request.length === 1) c.request = c.request[0]; } else delete c.request; c.pay = { ...c.pay, tone: 'info', label: `Reembolsado ${money0(c.total)}` }; redraw(id); }
268
+ };
269
+ root.addEventListener('click', onCardButton);
270
+
271
+ const sizes = $('.cx-card-sizes', root); if (sizes) sizes.innerHTML = SIZES.map(([t, d, id]) => `<div class="cx-cs-col"><div class="cx-cs-h"><b>${t}</b><span>${d}</span></div><div data-slot="card" data-size="sm" class="cn-card group/card cx-bcard cx-bcard-static" data-card="${id}" aria-label="${t}">${cardInner(id)}</div></div>`).join('');
272
+ KINDS.receipt = () => {
273
+ const c = CARDS[receiptFor];
274
+ $('#mdl-title').textContent = 'Comprobante de pago'; $('#mdl-desc').textContent = `${c.who} · pedido ${receiptFor}`;
275
+ body.innerHTML = `<div class="cx-rcpt"><div class="cx-rcpt-shot" role="img" aria-label="Captura de la transferencia"><span class="cx-rcpt-ok"><svg><use href="#check"/></svg></span><b>Transferencia exitosa</b><span class="cx-rcpt-amt">${money0(c.total)}</span><dl><dt>Para</dt><dd>Barbería El Roble</dd><dt>Desde</dt><dd>${c.who}</dd><dt>Referencia</dt><dd class="cx-num">M${receiptFor.slice(1)}7731</dd><dt>Fecha</dt><dd>8 oct 2026 · 10:42</dd></dl></div><div class="cx-rcpt-check"><span>Monto del pedido</span><b class="cx-num">${money0(c.total)}</b><span data-slot="badge" class="cn-badge group/badge cx-tone-ok"><span class="cx-dot"></span>Coincide</span></div></div>`;
276
+ foot.innerHTML = btn('outline', 'Rechazar', 'data-rcpt="no"') + btn('default', 'Aprobar pago', 'data-rcpt="yes"');
277
+ foot.firstElementChild.classList.add('cx-exit'); foot.lastElementChild.classList.add('cx-go');
278
+ };
279
+ KINDS.reply = () => {
280
+ const c = CARDS[replyFor];
281
+ $('#mdl-title').textContent = `Responder a ${c.who}`; $('#mdl-desc').textContent = `Queja del pedido ${replyFor}`;
282
+ body.innerHTML = `<div class="cx-bc-req" data-tone="bad" style="margin:0"><div class="cx-bc-req-h"><svg><use href="#info"/></svg><b>Queja</b><small>${(c.request.when ?? c.request[0]?.when)}</small></div><p>«${(c.request.text ?? c.request[0]?.text)}»</p></div><div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><label data-slot="label" class="cn-label" for="rp-t">Tu respuesta</label><textarea data-slot="textarea" class="cn-textarea" id="rp-t" rows="4">Hola ${c.who.split(' ')[0]}, lo siento mucho. Te enviamos una nueva hoy sin costo.</textarea><p data-slot="field-description" class="cn-field-description">Le llega por WhatsApp.</p></div>`;
283
+ foot.innerHTML = btn('outline', 'Cancelar', 'data-dismiss') + btn('default', 'Enviar respuesta', 'data-reply-send');
284
+ foot.firstElementChild.classList.add('cx-exit'); foot.lastElementChild.classList.add('cx-go');
285
+ };
286
+ document.addEventListener('click', (e) => {
287
+ const r = e.target.closest('[data-rcpt]');
288
+ if (r && receiptFor) {
289
+ const c = CARDS[receiptFor], ok = r.dataset.rcpt === 'yes';
290
+ c.pay = ok ? { method: 'transfer', tone: 'ok', label: 'Pagado', detail: c.pay?.detail } : { method: 'transfer', tone: 'bad', label: 'Rechazado', detail: c.pay?.detail };
291
+ c.actions = ok ? [['advance', 'Preparar', 'secondary']] : [];
292
+ redraw(receiptFor); brdLive.textContent = ok ? `Pago de ${c.who} aprobado` : `Comprobante de ${c.who} rechazado`;
293
+ receiptFor = null; closeModal();
294
+ }
295
+ if (e.target.closest('[data-reply-send]') && replyFor) {
296
+ const c = CARDS[replyFor]; c.request = { ...(Array.isArray(c.request) ? c.request[0] : c.request), tone: 'info', icon: 'check', title: 'Queja respondida', when: 'ahora', text: c.request.text, actions: [['refund', 'Reembolsar', 'ghost']] };
297
+ redraw(replyFor); replyFor = null; closeModal();
298
+ }
299
+ });
300
+ syncLanes();}
@@ -0,0 +1,148 @@
1
+ // @muten/shadcn · collections (kit): the reference artifact's Tags field, Pagination nav, Virtual list and the
2
+ // Carousel's behaviour, generic. Data and labels come in; nothing about a product is inside.
3
+
4
+ // Tags: chips animate in and out, the field's height eases; suggestions; a cap with its count. i {label, tags
5
+ // "Barba,Corte clásico", suggestions "Fade,Navaja", max, hint} → on.change("Barba,Corte clásico,Fade")
6
+ export function tagInput(root, i, core, on) {
7
+ const { $, $$, fold, easeHeight } = core;
8
+ const split = (s) => String(s || '').split(',').map((x) => x.trim()).filter(Boolean);
9
+ const MAXT = Number(i.max) || 8, SUG = split(i.suggestions), id = 'tg' + Math.random().toString(36).slice(2, 7);
10
+ let tags = split(i.tags);
11
+ root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><label data-slot="label" class="cn-label" for="${id}">${i.label || 'Etiquetas'}</label><div class="cx-taginput"><input id="${id}" class="cx-tag-field" placeholder="${i.placeholder || 'Agrega y presiona Enter'}" autocomplete="off"></div><p data-slot="field-description" class="cn-field-description cx-count-line"><span>${i.hint || ''}</span><span data-tg-n></span></p></div>${SUG.length ? '<div class="cx-suggest"><span>Sugerencias</span><div class="cx-line" style="justify-content:flex-start" data-tg-sug></div></div>' : ''}`;
12
+ const tgBox = $('.cx-taginput', root), tgIn = $('input', root), tgN = $('[data-tg-n]', root), tgSug = $('[data-tg-sug]', root);
13
+ const chipHtml = (t) => `<span data-slot="badge" class="cn-badge cn-badge-variant-secondary cx-chip" data-tag="${t}">${t}<button class="cn-button cn-button-variant-ghost cn-button-size-icon-xs cx-chip-x" aria-label="Quitar ${t}" title="Quitar" tabindex="-1"><svg style="width:12px;height:12px"><use href="#x"/></svg></button></span>`;
14
+ const emit = () => on?.change?.(tags.join(','));
15
+ const tgMeta = () => {
16
+ tgN.textContent = `${tags.length}/${MAXT}`; tgN.parentElement.toggleAttribute('data-near', tags.length >= MAXT);
17
+ tgIn.placeholder = tags.length >= MAXT ? `Llegaste al tope de ${MAXT}` : tags.length ? '' : (i.placeholder || 'Agrega y presiona Enter');
18
+ if (tgSug) $$('.cx-sug', tgSug).forEach((b) => { b.disabled = tags.some((t) => fold(t) === fold(b.textContent)) || tags.length >= MAXT; });
19
+ };
20
+ const tgAdd = (raw) => {
21
+ const t = raw.trim().replace(/\s+/g, ' '); if (!t) return;
22
+ const same = tags.find((x) => fold(x) === fold(t));
23
+ if (same) { const c = $(`[data-tag="${same}"]`, tgBox); c.classList.remove('flash'); void c.offsetWidth; c.classList.add('flash'); tgIn.value = ''; return; }
24
+ if (tags.length >= MAXT) return;
25
+ const tag = t[0].toUpperCase() + t.slice(1); tags.push(tag); tgIn.value = '';
26
+ easeHeight(tgBox, () => { tgIn.insertAdjacentHTML('beforebegin', chipHtml(tag)); tgMeta(); });
27
+ $(`[data-tag="${tag}"]`, tgBox).classList.add('in'); emit();
28
+ };
29
+ const tgRemove = (chip) => { tags = tags.filter((t) => t !== chip.dataset.tag); tgMeta(); chip.classList.add('out'); chip.addEventListener('animationend', () => easeHeight(tgBox, () => chip.remove()), { once: true }); emit(); };
30
+ tgIn.addEventListener('keydown', (e) => {
31
+ const marked = $('.cx-chip[data-marked]', tgBox);
32
+ if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); tgAdd(tgIn.value); }
33
+ else if (e.key === 'Backspace' && !tgIn.value && tags.length) { e.preventDefault(); const last = $$('.cx-chip:not(.out)', tgBox).at(-1); if (marked) tgRemove(marked); else last?.setAttribute('data-marked', ''); }
34
+ else marked?.removeAttribute('data-marked');
35
+ });
36
+ tgIn.addEventListener('blur', () => { $('.cx-chip[data-marked]', tgBox)?.removeAttribute('data-marked'); if (tgIn.value.trim()) tgAdd(tgIn.value); });
37
+ tgBox.addEventListener('click', (e) => { const x = e.target.closest('.cx-chip .cx-chip-x'); if (x) tgRemove(x.parentElement); tgIn.focus(); });
38
+ if (tgSug) { tgSug.innerHTML = SUG.map((s) => `<button class="cn-button cn-button-variant-outline cn-button-size-xs cx-sug"><svg style="width:12px;height:12px"><use href="#plus"/></svg>${s}</button>`).join(''); tgSug.addEventListener('click', (e) => { const b = e.target.closest('.cx-sug'); if (b) tgAdd(b.textContent); }); }
39
+ tags.forEach((t) => tgIn.insertAdjacentHTML('beforebegin', chipHtml(t))); tgMeta();
40
+ }
41
+
42
+ // Pagination nav for server pages: always 7 slots once there are more than 7 pages (the buttons never slide),
43
+ // «Página n de N» on a phone, and the range line. i {total, per, page, noun} → on.page(n)
44
+ export function paginationNav(root, i, core, on) {
45
+ const { $ } = core;
46
+ root.innerHTML = '<div class="cx-pg-foot"><nav class="cn-pagination cx-pagination" aria-label="Páginas"><ul class="cn-pagination-content" style="list-style:none;margin:0;padding:0"></ul></nav><span class="cx-num" aria-live="polite" data-pg-range></span></div>';
47
+ const pgNav = $('ul', root), pgRange = $('[data-pg-range]', root);
48
+ let total = Number(i.total) || 0, per = Number(i.per) || 10, page = Number(i.page) || 1, noun = i.noun || 'resultados';
49
+ const pages = () => Math.max(1, Math.ceil(total / per));
50
+ const nums = () => { const P = pages(); if (P <= 7) return Array.from({ length: P }, (_, k) => k + 1); if (page <= 4) return [1, 2, 3, 4, 5, '…', P]; if (page >= P - 3) return [1, '…', P - 4, P - 3, P - 2, P - 1, P]; return [1, '…', page - 1, page, page + 1, '…', P]; };
51
+ const paint = (focus) => {
52
+ const P = pages();
53
+ const nav = (dir, p, off) => `<li><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-default cn-pagination-${dir === -1 ? 'previous' : 'next'} group/button" data-page="${p}" ${off ? 'disabled' : ''} aria-label="Página ${dir === -1 ? 'anterior' : 'siguiente'}">${dir === -1 ? '<svg><use href="#left"/></svg><span>Anterior</span>' : '<span>Siguiente</span><svg><use href="#right"/></svg>'}</button></li>`;
54
+ pgNav.innerHTML = nav(-1, page - 1, page === 1) + nums().map((p) => (p === '…' ? '<li class="cx-pg-num"><span class="cn-pagination-ellipsis cx-pg-gap" aria-hidden="true"><svg><use href="#more"/></svg></span></li>' : `<li class="cx-pg-num"><button data-slot="button" class="cn-button ${p === page ? 'cn-button-variant-outline' : 'cn-button-variant-ghost'} cn-button-size-icon group/button cx-num" data-page="${p}" ${p === page ? 'aria-current="page"' : ''} aria-label="Página ${p}">${p}</button></li>`)).join('') + `<li class="cx-pg-compact">Página ${page} de ${P}</li>` + nav(1, page + 1, page === P);
55
+ pgRange.textContent = `${(page - 1) * per + 1}–${Math.min(total, page * per)} de ${total} ${noun}`;
56
+ if (focus) pgNav.querySelector('[aria-current]')?.focus({ preventScroll: true });
57
+ };
58
+ pgNav.addEventListener('click', (e) => { const b = e.target.closest('[data-page]'); if (!b || b.disabled) return; page = +b.dataset.page; paint(true); on?.page?.(page); });
59
+ paint();
60
+ return (next) => { const np = Number(next.page) || page, nt = Number(next.total) || total; if (np === page && nt === total) return; page = np; total = nt; per = Number(next.per) || per; paint(); };
61
+ }
62
+
63
+ // Virtual list: only the visible rows (+ a margin) exist in the DOM. i {rows [{title, subtitle, value}], total
64
+ // (rows repeat up to it, for a long demo), rowHeight, label} → on.jump not needed: «Ir al n» via inputs.jump.
65
+ export function virtualList(root, i, core) {
66
+ const { $ } = core;
67
+ const rows = Array.isArray(i.rows) ? i.rows : [];
68
+ const VTOTAL = Math.max(Number(i.total) || 0, rows.length), VROW = Number(i.rowHeight) || 52, VOVER = 6;
69
+ root.innerHTML = `<div class="cx-pages-wrap"><div class="cx-vlist cx-scroll" tabindex="0" role="list" aria-label="${i.label || 'Lista'}"><div class="cx-vspace"><div class="cx-vwin"></div></div></div><div class="cx-pg-foot"><span class="cx-num" data-vl-info></span>${i.jump ? `<button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button" data-vl-jump>Ir al ${Number(i.jump).toLocaleString('es-CO')}</button>` : ''}</div></div>`;
70
+ const vBox = $('.cx-vlist', root), vSpace = $('.cx-vspace', root), vWin = $('.cx-vwin', root), vInfo = $('[data-vl-info]', root);
71
+ vSpace.style.height = VTOTAL * VROW + 'px';
72
+ let vFirst = -1, vRaf = 0;
73
+ const vPaint = () => {
74
+ const top = vBox.scrollTop, h = vBox.clientHeight;
75
+ const first = Math.max(0, Math.floor(top / VROW) - VOVER), last = Math.min(VTOTAL, Math.ceil((top + h) / VROW) + VOVER);
76
+ if (first !== vFirst) {
77
+ vFirst = first; vWin.style.transform = `translateY(${first * VROW}px)`;
78
+ let html = '';
79
+ for (let k = first; k < last; k++) { const c = rows[k % rows.length] || {}; html += `<div class="cx-vrow" role="listitem" aria-setsize="${VTOTAL}" aria-posinset="${k + 1}"><span class="cx-vn cx-num">${(k + 1).toLocaleString('es-CO')}</span><span>${c.title ?? ''}<small>${c.subtitle ?? ''}</small></span><b>${c.value ?? ''}</b></div>`; }
80
+ vWin.innerHTML = html;
81
+ }
82
+ const a = Math.floor(top / VROW) + 1, b = Math.min(VTOTAL, Math.ceil((top + h) / VROW));
83
+ vInfo.textContent = `${a.toLocaleString('es-CO')}–${b.toLocaleString('es-CO')} de ${VTOTAL.toLocaleString('es-CO')} · ${vWin.childElementCount} filas en el DOM`;
84
+ };
85
+ vBox.addEventListener('scroll', () => { cancelAnimationFrame(vRaf); vRaf = requestAnimationFrame(vPaint); }, { passive: true });
86
+ vBox.addEventListener('keydown', (e) => { const d = { ArrowDown: VROW, ArrowUp: -VROW, PageDown: vBox.clientHeight, PageUp: -vBox.clientHeight }[e.key]; if (d) { e.preventDefault(); vBox.scrollTop += d; } if (e.key === 'Home') { e.preventDefault(); vBox.scrollTop = 0; } if (e.key === 'End') { e.preventDefault(); vBox.scrollTop = vBox.scrollHeight; } });
87
+ $('[data-vl-jump]', root)?.addEventListener('click', () => vBox.scrollTo({ top: Number(i.jump) * VROW - VROW, behavior: 'smooth' }));
88
+ new ResizeObserver(() => { vFirst = -1; vPaint(); }).observe(vBox);
89
+ }
90
+
91
+ // Carousel behaviour over the slides muten rendered inside a `.cx-car` (the Carousel part): pages, indicator
92
+ // (dots · progress · count · thumbs), loop, autoplay with its own pause, arrows (bar | overlay), keys and mouse drag.
93
+ export function carouselBehavior(root, i, core) {
94
+ const { $, $$, ui } = core;
95
+ const perView = String(i.perView || '1').split(',').map(Number), [perSm, perLg] = perView.length > 1 ? perView : [Math.min(perView[0], 2), perView[0]];
96
+ const center = i.center === 'on', loop = i.loop === 'on', autoplay = Number(i.autoplay) || 0, indicator = i.indicator || 'dots', arrows = i.arrows || 'bar';
97
+ root.style.setProperty('--per', perLg); root.style.setProperty('--per-sm', perSm);
98
+ if (center) root.dataset.center = '';
99
+ const view = $('.cx-car-view', root); view.dataset.arrows = arrows;
100
+ const prevB = ui.button({ icon: 'left', size: 'icon-sm', attrs: 'data-car="prev" aria-label="Anterior" title="Anterior"', cls: 'cx-car-arrow' });
101
+ const nextB = ui.button({ icon: 'right', size: 'icon-sm', attrs: 'data-car="next" aria-label="Siguiente" title="Siguiente"', cls: 'cx-car-arrow' });
102
+ if (arrows === 'overlay') view.insertAdjacentHTML('beforeend', prevB + nextB);
103
+ root.insertAdjacentHTML('beforeend', `<div class="cx-car-bar"><div class="cx-car-ind" data-ind="${indicator}"></div><div class="cx-hstack" style="gap:6px">${autoplay ? ui.button({ icon: 'pause', size: 'icon-sm', variant: 'ghost', attrs: 'data-car="play" aria-label="Pausar" title="Pausar"' }) : ''}${arrows === 'bar' ? prevB + nextB : ''}</div></div>`);
104
+ const track = $('.cx-car-track', root), ind = $('.cx-car-ind', root), cards = $$('.cx-car-slide', track);
105
+ cards.forEach((c, k) => { c.setAttribute('role', 'group'); c.setAttribute('aria-roledescription', 'diapositiva'); c.setAttribute('aria-label', `${k + 1} de ${cards.length}`); });
106
+ const step = () => (cards[1] ? cards[1].offsetLeft - cards[0].offsetLeft : track.clientWidth);
107
+ const perPage = () => (center ? 1 : Math.max(1, Math.round(track.clientWidth / step())));
108
+ const pages = () => (center ? cards.length : Math.max(1, Math.ceil(cards.length / perPage())));
109
+ const current = () => {
110
+ if (center) { const mid = track.scrollLeft + track.clientWidth / 2; let best = 0, d = Infinity; cards.forEach((c, k) => { const cd = Math.abs(c.offsetLeft + c.offsetWidth / 2 - mid); if (cd < d) { d = cd; best = k; } }); return best; }
111
+ if (track.scrollLeft >= track.scrollWidth - track.clientWidth - 4) return pages() - 1;
112
+ return Math.round(track.scrollLeft / (step() * perPage()));
113
+ };
114
+ const go = (p) => { const n = pages(); if (loop) p = (p + n) % n; else p = Math.max(0, Math.min(n - 1, p)); const target = center ? cards[p].offsetLeft + cards[p].offsetWidth / 2 - track.clientWidth / 2 : p * step() * perPage(); track.scrollTo({ left: target, behavior: 'smooth' }); };
115
+ const thumbOf = (k) => { const media = cards[k].querySelector('img, [role=img], .cx-gallery-img'); const bg = media ? getComputedStyle(media).backgroundImage : ''; return media?.tagName === 'IMG' ? `<img src="${media.src}" alt="">` : `<span class="cx-thumb-fill" style="background-image:${bg}"></span>`; };
116
+ const paint = () => {
117
+ const n = pages(), cur = current();
118
+ if (indicator === 'dots') { if (ind.childElementCount !== n) ind.innerHTML = Array.from({ length: n }, (_, k) => ui.button({ variant: 'ghost', size: 'icon-xs', cls: 'cx-dotb', attrs: `role="tab" data-go="${k}" aria-label="Ir a ${k + 1} de ${n}"`, label: '<span class="cx-dot-mark"></span>' })).join(''); $$('[data-go]', ind).forEach((d, k) => d.setAttribute('aria-selected', k === cur)); }
119
+ if (indicator === 'progress') ind.innerHTML = `<div class="cx-hstack" style="gap:10px;flex-wrap:nowrap">${ui.progress(((cur + 1) / n) * 100, `Diapositiva ${cur + 1} de ${n}`)}${ui.text(`${cur + 1} / ${n}`, 'caption')}</div>`;
120
+ if (indicator === 'count') ind.innerHTML = ui.text(`${cur + 1} de ${n}`, 'caption');
121
+ if (indicator === 'thumbs') { if (!ind.childElementCount) ind.innerHTML = `<div class="cx-hstack" role="tablist" aria-label="Miniaturas">${cards.map((_, k) => ui.button({ variant: 'ghost', size: 'icon-lg', cls: 'cx-thumb', attrs: `role="tab" data-go="${k}" aria-label="Foto ${k + 1}"`, label: thumbOf(k) })).join('')}</div>`; $$('[data-go]', ind).forEach((d, k) => { d.setAttribute('aria-selected', k === cur); d.setAttribute('aria-pressed', k === cur); }); }
122
+ if (center) cards.forEach((c, k) => c.toggleAttribute('data-active', k === cur));
123
+ if (!loop) { $$('[data-car="prev"]', root).forEach((b) => { b.disabled = cur === 0; }); $$('[data-car="next"]', root).forEach((b) => { b.disabled = cur >= n - 1; }); }
124
+ };
125
+ let raf = 0; track.addEventListener('scroll', () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(paint); }, { passive: true });
126
+ root.addEventListener('click', (e) => {
127
+ const g = e.target.closest('[data-go]'); if (g) { go(+g.dataset.go); return; }
128
+ const a = e.target.closest('[data-car]'); if (!a) return;
129
+ if (a.dataset.car === 'prev') go(current() - 1);
130
+ if (a.dataset.car === 'next') go(current() + 1);
131
+ if (a.dataset.car === 'play') { playing = !playing; a.innerHTML = ui.icon(playing ? 'pause' : 'play'); a.setAttribute('aria-label', playing ? 'Pausar' : 'Reproducir'); a.title = a.getAttribute('aria-label'); }
132
+ });
133
+ track.tabIndex = 0;
134
+ track.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') { e.preventDefault(); go(current() + 1); } if (e.key === 'ArrowLeft') { e.preventDefault(); go(current() - 1); } });
135
+ let drag = null;
136
+ track.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse' || e.button > 0) return; drag = { x: e.clientX, left: track.scrollLeft, moved: false }; track.classList.add('dragging'); });
137
+ addEventListener('pointermove', (e) => { if (!drag) return; const dx = e.clientX - drag.x; if (Math.abs(dx) > 4) drag.moved = true; track.scrollLeft = drag.left - dx; });
138
+ addEventListener('pointerup', () => { if (!drag) return; const moved = drag.moved; drag = null; track.classList.remove('dragging'); if (moved) track.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }, { capture: true, once: true }); });
139
+ let playing = !!autoplay && !matchMedia('(prefers-reduced-motion: reduce)').matches, hold = false;
140
+ if (autoplay) {
141
+ if (!playing) { const b = $('[data-car="play"]', root); b.innerHTML = ui.icon('play'); b.setAttribute('aria-label', 'Reproducir'); }
142
+ ['pointerenter', 'focusin', 'touchstart'].forEach((t) => root.addEventListener(t, () => { hold = true; }, { passive: true }));
143
+ ['pointerleave', 'focusout'].forEach((t) => root.addEventListener(t, () => { hold = false; }));
144
+ setInterval(() => { if (playing && !hold && !document.hidden) go(loop || current() < pages() - 1 ? current() + 1 : 0); }, autoplay);
145
+ }
146
+ new ResizeObserver(() => { if (indicator !== 'thumbs') ind.innerHTML = ''; paint(); }).observe(track);
147
+ paint();
148
+ }