@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,198 @@
1
+ // @muten/shadcn · DataTable (kit). The reference artifact's DataTable, made generic: columns, rows, filters, statuses
2
+ // and bulk actions are data. Everything else is the artifact's: filters from their type (search · facet · date ·
3
+ // range) with counts, chips that leave with a fade, rows that fade out / slide / enter, the height easing, sort by
4
+ // header, selection with its own bulk bar, the phone's «buscar + Filtros» sheet, and a narrow box becoming Items.
5
+ // columns [{key, label, type: person|text|date|status|money, sub (person: the key under the name), sortable}]
6
+ // rows [{id, …keys}] (a date is "yyyy-mm-ddTHH:MM"; a row's date may be "+N" days from today too)
7
+ // filters [{id, type: search|facet|date|range, label, key | keys (search)}]
8
+ // statuses [{id, label, tone: ok|wait|info|bad}] · noun ["cita", "citas"] · bulk [{id, label, variant}]
9
+ // on.action(json {action, ids})
10
+ export function dataTable(root, cfg, core, on) {
11
+ const { ui, $, $$, lib, place, show, hide, easeHeight, setCheck, CB_CHECK, money0, fold, reformat, groupThousands, today, addDays, DAY, openModal, CONTENT, body, foot } = core;
12
+ const list = (v) => (Array.isArray(v) ? v : []);
13
+ const COLS = list(cfg.columns), STATUS = Object.fromEntries(list(cfg.statuses).map((s) => [s.id, s]));
14
+ const [ONE, MANY] = list(cfg.noun).length === 2 ? cfg.noun : ['fila', 'filas'];
15
+ const ST_ORDER = list(cfg.statuses).map((s) => s.id);
16
+ const asDate = (v) => { const s = String(v ?? ''); const m = /^\+(\d+)(?:T(\d\d:\d\d))?$/.exec(s); if (m) { const d = addDays(today, +m[1]); if (m[2]) { const [h, mi] = m[2].split(':'); d.setHours(+h, +mi); } return d; } return new Date(s); };
17
+ const DT = list(cfg.rows).map((r, i) => ({ id: r.id ?? i, ...r }));
18
+ const colOf = (type) => COLS.find((c) => c.type === type);
19
+ const dateCol = colOf('date'), statusCol = colOf('status');
20
+ const whenText = (v) => { const d = asDate(v); return `${DAY[d.getDay()]} ${d.getDate()} · ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
21
+ const DATE_PRESETS = { today: ['Hoy', (d) => +d === +today], tomorrow: ['Mañana', (d) => +d === +addDays(today, 1)], week: ['Próximos 7 días', (d) => d >= today && d < addDays(today, 7)], later: ['Más adelante', (d) => d >= addDays(today, 7)] };
22
+ const dayOf = (v) => { const d = asDate(v); d.setHours(0, 0, 0, 0); return d; };
23
+ const FILTERS = list(cfg.filters).map((f) => ({
24
+ ...f,
25
+ read: f.type === 'search' ? (r) => list(f.keys || [f.key]).map((k) => r[k]).join(' ') : f.type === 'date' ? (r) => dayOf(r[f.key]) : (r) => r[f.key],
26
+ name: (v) => (f.key === statusCol?.key && STATUS[v] ? STATUS[v].label : v),
27
+ fmt: f.money === false ? (v) => v : money0,
28
+ }));
29
+ const fState = Object.fromEntries(FILTERS.map((f) => [f.id, f.type === 'search' ? '' : f.type === 'facet' ? new Set() : null]));
30
+ const PASS = {
31
+ search: (f, v, r) => !v || fold(f.read(r)).includes(fold(v)),
32
+ facet: (f, v, r) => !v.size || v.has(f.read(r)),
33
+ date: (f, v, r) => !v || DATE_PRESETS[v][1](f.read(r)),
34
+ range: (f, v, r) => !v || ((v[0] == null || f.read(r) >= v[0]) && (v[1] == null || f.read(r) <= v[1])),
35
+ };
36
+ const SORTERS = Object.fromEntries(COLS.map((c) => [c.key, c.type === 'status' ? (r) => ST_ORDER.indexOf(r[c.key]) : c.type === 'date' ? (r) => +asDate(r[c.key]) : (r) => r[c.key]]));
37
+ const cbHtml = (label, on) => `<button data-slot="checkbox" role="checkbox" data-state="${on ? 'checked' : 'unchecked'}" aria-checked="${on}" class="cn-checkbox" aria-label="${label}"><span data-slot="checkbox-indicator" class="cn-checkbox-indicator"><svg class="cb-mark" viewBox="0 0 24 24">${CB_CHECK}</svg></span></button>`;
38
+ const firstSort = dateCol ? { key: dateCol.key, dir: 1 } : { key: COLS[0]?.key, dir: 1 };
39
+ const bulk = list(cfg.bulk);
40
+ const CELL_CLASS = { person: 'cx-dt-who', text: 'cx-dt-svc', date: 'cx-dt-when', status: 'cx-dt-st', money: 'cx-dt-num' };
41
+ root.innerHTML = `<div class="cx-dt-host"><div class="cx-dt-toolbar"><div class="cx-hstack cx-dt-tools" role="toolbar" aria-label="Filtros"></div><span class="cx-text" data-variant="caption" data-dt-shown aria-live="polite"></span></div><div class="cx-hstack cx-dt-chips" hidden></div><div class="cx-dt-bulk" aria-live="polite"><div><span data-dt-count></span><div class="cx-hstack" style="gap:6px">${bulk.map((b) => ui.button({ label: b.label, variant: b.variant || 'outline', size: 'xs', attrs: `data-dt-bulk="${b.id}"` })).join('')}${ui.button({ label: 'Quitar selección', variant: 'ghost', size: 'xs', attrs: 'data-dt-unselect' })}</div></div></div><div class="cx-dt-scroll cx-scroll"><table class="cx-dt"><thead><tr><th class="cx-dt-check">${cbHtml('Seleccionar todas', false).replace('class="cn-checkbox"', 'class="cn-checkbox" data-dt-all')}</th>${COLS.map((c) => `<th aria-sort="${c.key === firstSort.key ? 'ascending' : 'none'}" ${c.type === 'money' ? 'class="cx-dt-num"' : ''}>${c.sortable === false ? ui.text(c.label, 'label') : `<button class="cn-button cn-button-variant-ghost cn-button-size-xs cx-th" data-sort="${c.key}">${c.label}<svg><use href="#${c.key === firstSort.key ? 'arrowup' : 'sort'}"/></svg></button>`}</th>`).join('')}</tr></thead><tbody></tbody></table></div></div>`;
42
+ const host = $('.cx-dt-host', root), dtTools = $('.cx-dt-tools', root), dtChips = $('.cx-dt-chips', root), dtCountEl = $('[data-dt-shown]', root), dtBody = $('tbody', root), dtAll = $('[data-dt-all]', root), dtBulk = $('.cx-dt-bulk', root);
43
+ const dtSel = new Set(); let dtSort = firstSort;
44
+ const passes = (r, except) => FILTERS.every((f) => f.id === except || PASS[f.type](f, fState[f.id], r));
45
+ const isActive = (f) => { const v = fState[f.id]; return f.type === 'facet' ? v.size > 0 : f.type === 'range' ? !!v && (v[0] != null || v[1] != null) : !!v; };
46
+ const summary = (f) => { const v = fState[f.id]; if (f.type === 'facet') return v.size === 1 ? f.name([...v][0]) : `${v.size}`; if (f.type === 'date') return DATE_PRESETS[v][0]; if (f.type === 'range') return v[0] != null && v[1] != null ? `${f.fmt(v[0])}–${f.fmt(v[1])}` : v[0] != null ? `desde ${f.fmt(v[0])}` : `hasta ${f.fmt(v[1])}`; return v; };
47
+ let dtMobile = false;
48
+ const searchF = FILTERS.find((x) => x.type === 'search');
49
+ const activeCount = () => FILTERS.filter((f) => f.type !== 'search' && isActive(f)).reduce((n, f) => n + (f.type === 'facet' ? fState[f.id].size : 1), 0);
50
+ const searchHtml = () => (searchF ? `<div data-slot="input-group" class="cn-input-group group/input-group cx-dt-search"><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-start">${ui.icon('search')}</div><input data-slot="input-group-control" class="cn-input cn-input-group-input" data-filter="${searchF.id}" placeholder="${searchF.label}" aria-label="${searchF.label}" value="${String(fState[searchF.id]).replace(/"/g, '&quot;')}"></div>` : '');
51
+ const paintTools = () => {
52
+ if (dtMobile) {
53
+ const n = activeCount(), focused = document.activeElement?.dataset?.filter === searchF?.id;
54
+ dtTools.innerHTML = searchHtml() + ui.button({ label: `${ui.icon('filter')}Filtros${n ? ui.badge(String(n), { variant: 'default' }) : ''}`, size: 'sm', cls: 'cx-fbtn-m', attrs: 'data-fmobile aria-haspopup="dialog"' });
55
+ if (focused) { const i = dtTools.querySelector('[data-filter]'); i.focus(); i.setSelectionRange(i.value.length, i.value.length); }
56
+ return;
57
+ }
58
+ dtTools.innerHTML = FILTERS.map((f) => (f.type === 'search' ? searchHtml()
59
+ : ui.button({ label: `${isActive(f) ? '' : ui.icon('plus')}${f.label}${isActive(f) ? `<span data-slot="separator" class="cn-separator cn-separator-vertical cx-fsep"></span>${ui.badge(summary(f), { variant: 'secondary' })}` : ''}`, size: 'sm', cls: `cx-fbtn ${isActive(f) ? '' : 'cx-dashed'}`, attrs: `data-fopen="${f.id}" aria-haspopup="dialog" aria-expanded="false"` }))).join('')
60
+ + (FILTERS.some(isActive) ? ui.button({ label: 'Limpiar', size: 'sm', variant: 'ghost', attrs: 'data-fclear' }) : '');
61
+ };
62
+ const fPop = document.createElement('div'); fPop.className = 'cn-popover-content pg-pop cx-fpop'; fPop.setAttribute('role', 'dialog'); fPop.hidden = true; lib.appendChild(fPop);
63
+ let fOpen = null, fBtn = null;
64
+ const fClose = (refocus) => { if (!fOpen) return; hide(fPop, 120); fBtn?.setAttribute('aria-expanded', 'false'); if (refocus) fBtn?.focus(); fOpen = null; };
65
+ const rangeInputs = (f, prefix) => { const v = fState[f.id] || [null, null]; return `<div class="cx-frange">${['Desde', 'Hasta'].map((lab, i) => ui.field({ id: `${prefix}${f.id}${i}`, label: lab, control: `<div data-slot="input-group" class="cn-input-group"><div class="cn-input-group-addon cn-input-group-addon-align-inline-start">$</div><input class="cn-input cn-input-group-input cx-num" id="${prefix}${f.id}${i}" inputmode="numeric" data-frange="${i}" data-frid="${f.id}" value="${v[i] != null ? groupThousands(String(v[i]), '.') : ''}" placeholder="${i ? 'Sin tope' : '0'}"></div>` })).join('')}</div>`; };
66
+ const facetItems = (f, sheet) => { const counts = new Map(); DT.filter((r) => passes(r, f.id)).forEach((r) => counts.set(f.read(r), (counts.get(f.read(r)) || 0) + 1)); return [...new Set(DT.map(f.read))].map((v) => sheet
67
+ ? `<button type="button" class="cn-command-item cx-fsheet-opt" role="checkbox" aria-checked="${fState[f.id].has(v)}" data-fs-facet="${f.id}" data-fval="${v}"><span data-slot="checkbox" class="cn-checkbox" data-state="${fState[f.id].has(v) ? 'checked' : 'unchecked'}"><span class="cn-checkbox-indicator"><svg class="cb-mark" viewBox="0 0 24 24">${CB_CHECK}</svg></span></span>${ui.text(f.name(v))}<span class="cx-fcount">${counts.get(v) || 0}</span></button>`
68
+ : `<div data-slot="command-item" class="cn-command-item group/command-item" role="option" aria-selected="${fState[f.id].has(v)}" data-fval="${v}" tabindex="-1"><span data-slot="checkbox" class="cn-checkbox" data-state="${fState[f.id].has(v) ? 'checked' : 'unchecked'}"><span class="cn-checkbox-indicator"><svg class="cb-mark" viewBox="0 0 24 24">${CB_CHECK}</svg></span></span>${ui.text(f.name(v))}<span class="cx-fcount">${counts.get(v) || 0}</span></div>`).join(''); };
69
+ const popBody = (f) => {
70
+ if (f.type === 'facet') return `<div data-slot="command" class="cn-command"><div data-slot="command-input-wrapper" class="cn-command-input-wrapper"><div data-slot="input-group" class="cn-input-group cn-command-input-group group/input-group"><input data-slot="input-group-control" class="cn-command-input cn-input-group-input" placeholder="${f.label}…" data-fsearch aria-label="Buscar en ${f.label}"><div class="cn-input-group-addon cn-input-group-addon-align-inline-start" data-align="inline-start"><svg class="cn-command-input-icon"><use href="#search"/></svg></div></div></div><div data-slot="command-list" class="cn-command-list" role="listbox" aria-multiselectable="true">${facetItems(f)}</div>${fState[f.id].size ? `<div data-slot="separator" class="cn-separator cn-separator-horizontal"></div><button class="cn-command-item cx-fclear-one" data-fclear-one>Quitar filtro</button>` : ''}</div>`;
71
+ if (f.type === 'date') return `<div class="cx-vstack" style="gap:4px">${Object.entries(DATE_PRESETS).map(([k, [n]]) => ui.button({ label: n, variant: 'ghost', size: 'sm', cls: 'cx-fill cx-left', attrs: `aria-pressed="${fState[f.id] === k}" data-fdate="${k}"` })).join('')}${fState[f.id] ? ui.button({ label: 'Cualquier fecha', variant: 'ghost', size: 'sm', cls: 'cx-fill cx-left', attrs: 'data-fdate=""' }) : ''}</div>`;
72
+ if (f.type === 'range') return `<div class="cx-vstack" style="gap:10px">${ui.text(f.label, 'label')}${rangeInputs(f, 'f-')}</div>`;
73
+ return '';
74
+ };
75
+ const fOpenPop = (id, btn) => { fClose(); fOpen = id; fBtn = btn; const f = FILTERS.find((x) => x.id === id); fPop.innerHTML = popBody(f); fPop.hidden = false; place(fPop, btn, 'start', 6); show(fPop); btn.setAttribute('aria-expanded', 'true'); (fPop.querySelector('[data-fsearch], input, [data-fdate], .cn-command-item') || fPop).focus({ preventScroll: true }); };
76
+ const refresh = (keepPop) => { dtPaint(); paintTools(); if (keepPop && fOpen) { const f = FILTERS.find((x) => x.id === fOpen); const scroll = fPop.querySelector('.cn-command-list')?.scrollTop; const focused = document.activeElement?.dataset?.frange; fPop.innerHTML = popBody(f); if (scroll) fPop.querySelector('.cn-command-list').scrollTop = scroll; fBtn = dtTools.querySelector(`[data-fopen="${fOpen}"]`); fBtn?.setAttribute('aria-expanded', 'true'); if (focused != null) { const i = fPop.querySelector(`[data-frange="${focused}"]`); i?.focus(); i?.setSelectionRange(i.value.length, i.value.length); } } };
77
+ let mobileCount = DT.length, sheetOpen = false;
78
+ const syncMobileCount = (n) => { mobileCount = n; const go = $('[data-fsheet-go]', foot); if (go && sheetOpen) go.innerHTML = `Ver ${n} ${n === 1 ? ONE : MANY}`; };
79
+ const sheetBody = () => FILTERS.filter((f) => f.type !== 'search').map((f) => {
80
+ const ctrl = f.type === 'facet' ? `<div class="cx-vstack" style="gap:2px">${facetItems(f, true)}</div>` : f.type === 'date' ? `<div class="cx-hstack">${Object.entries(DATE_PRESETS).map(([k, [n]]) => ui.button({ label: n, size: 'sm', attrs: `aria-pressed="${fState[f.id] === k}" data-fs-date="${f.id}|${k}"` })).join('')}</div>` : f.type === 'range' ? rangeInputs(f, 'fs-') : '';
81
+ return `<section class="cx-vstack" style="gap:10px">${ui.text(f.label, 'label')}${ctrl}</section>`;
82
+ }).join('<div data-slot="separator" class="cn-separator cn-separator-horizontal"></div>');
83
+ const sheetKind = 'dtFilters' + Math.random().toString(36).slice(2, 7);
84
+ CONTENT[sheetKind] = () => { sheetOpen = true; document.getElementById('mdl-title').textContent = 'Filtros'; document.getElementById('mdl-desc').textContent = 'Se aplican mientras eliges.'; body.innerHTML = sheetBody(); foot.innerHTML = ui.button({ label: 'Limpiar', size: 'default', attrs: 'data-fsheet-clear', cls: 'cx-exit' }) + ui.button({ label: `Ver ${mobileCount} ${mobileCount === 1 ? ONE : MANY}`, variant: 'default', size: 'default', attrs: 'data-dismiss data-fsheet-go', cls: 'cx-go' }); };
85
+ const sheetRefresh = () => { if (!sheetOpen) return; const st = body.scrollTop, focused = document.activeElement?.dataset?.frange; body.innerHTML = sheetBody(); body.scrollTop = st; if (focused != null) { const i = body.querySelector(`[data-frange="${focused}"]`); i?.focus(); i?.setSelectionRange(i.value.length, i.value.length); } };
86
+ const clearAll = () => FILTERS.forEach((f) => { fState[f.id] = f.type === 'search' ? (f.id === searchF?.id ? fState[f.id] : '') : f.type === 'facet' ? new Set() : null; });
87
+ body.addEventListener('click', (e) => {
88
+ if (!sheetOpen) return;
89
+ const o = e.target.closest('[data-fs-facet]'); if (o) { const set = fState[o.dataset.fsFacet], v = o.dataset.fval; set.has(v) ? set.delete(v) : set.add(v); dtPaint(); paintTools(); sheetRefresh(); return; }
90
+ const d = e.target.closest('[data-fs-date]'); if (d) { const [id, k] = d.dataset.fsDate.split('|'); fState[id] = fState[id] === k ? null : k; dtPaint(); paintTools(); sheetRefresh(); }
91
+ });
92
+ const onRange = (e) => { const r = e.target.dataset.frange, id = e.target.dataset.frid; if (r == null || !id) return false; reformat(e.target, (v) => groupThousands(v.replace(/\D/g, '').slice(0, 9), '.'), /\d/); const n = e.target.value ? Number(e.target.value.replace(/\D/g, '')) : null; const v = fState[id] ? [...fState[id]] : [null, null]; v[+r] = n; fState[id] = v[0] == null && v[1] == null ? null : v; dtPaint(); paintTools(); return true; };
93
+ body.addEventListener('input', (e) => { if (sheetOpen && e.target.id?.startsWith('fs-')) onRange(e); });
94
+ foot.addEventListener('click', (e) => { if (sheetOpen && e.target.closest('[data-fsheet-clear]')) { clearAll(); dtPaint(); paintTools(); sheetRefresh(); } });
95
+ addEventListener('popstate', () => { sheetOpen = false; });
96
+ document.addEventListener('click', (e) => { if (e.target.closest('[data-dismiss]')) sheetOpen = false; });
97
+ new ResizeObserver(() => { const m = host.clientWidth < 600; host.toggleAttribute('data-narrow', host.clientWidth < 600); if (m !== dtMobile) { dtMobile = m; fClose(); paintTools(); } }).observe(host);
98
+ dtTools.addEventListener('click', (e) => {
99
+ if (e.target.closest('[data-fmobile]')) { openModal(sheetKind, 'auto'); return; }
100
+ const b = e.target.closest('[data-fopen]'); if (b) { if (fOpen === b.dataset.fopen) fClose(); else fOpenPop(b.dataset.fopen, b); return; }
101
+ if (e.target.closest('[data-fclear]')) { FILTERS.forEach((f) => { fState[f.id] = f.type === 'search' ? '' : f.type === 'facet' ? new Set() : null; }); fClose(); refresh(); }
102
+ });
103
+ dtTools.addEventListener('input', (e) => { if (e.target.dataset.filter) { fState[e.target.dataset.filter] = e.target.value; dtPaint(); paintChips(); } });
104
+ fPop.addEventListener('click', (e) => {
105
+ const f = FILTERS.find((x) => x.id === fOpen); if (!f) return;
106
+ const it = e.target.closest('[data-fval]'); if (it) { const set = fState[f.id], v = it.dataset.fval; set.has(v) ? set.delete(v) : set.add(v); refresh(true); return; }
107
+ if (e.target.closest('[data-fclear-one]')) { fState[f.id].clear(); refresh(true); return; }
108
+ const d = e.target.closest('[data-fdate]'); if (d) { fState[f.id] = d.dataset.fdate || null; fClose(true); refresh(); }
109
+ });
110
+ fPop.addEventListener('input', (e) => {
111
+ if (e.target.dataset.fsearch != null) { const q = fold(e.target.value); $$('[data-fval]', fPop).forEach((x) => { x.hidden = !fold(x.textContent).includes(q); }); return; }
112
+ if (onRange(e)) { paintChips(); fBtn = dtTools.querySelector(`[data-fopen="${e.target.dataset.frid}"]`); fBtn?.setAttribute('aria-expanded', 'true'); }
113
+ });
114
+ fPop.addEventListener('keydown', (e) => {
115
+ const items = $$('[data-fval]:not([hidden])', fPop), i = items.indexOf(document.activeElement);
116
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); (items[(i + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length] || items[0])?.focus(); }
117
+ if ((e.key === 'Enter' || e.key === ' ') && document.activeElement?.dataset?.fval != null) { e.preventDefault(); document.activeElement.click(); }
118
+ if (e.key === 'Escape') { e.stopPropagation(); fClose(true); }
119
+ });
120
+ document.addEventListener('pointerdown', (e) => { if (fOpen && !fPop.contains(e.target) && !dtTools.contains(e.target)) fClose(); });
121
+ addEventListener('scroll', () => { if (fOpen && fBtn) place(fPop, fBtn, 'start', 6); }, { passive: true });
122
+ const paintChips = () => {
123
+ const chips = [];
124
+ FILTERS.forEach((f) => { if (f.type === 'facet') fState[f.id].forEach((v) => chips.push([`${f.label}: ${f.name(v)}`, `${f.id}|${v}`])); else if (f.type !== 'search' && isActive(f)) chips.push([`${f.label}: ${summary(f)}`, f.id]); });
125
+ const have = new Map($$('.cx-chip', dtChips).map((c) => [c.dataset.key, c])), want = new Set(chips.map(([, k]) => k));
126
+ have.forEach((c, k) => { if (!want.has(k) && !c.classList.contains('out')) { c.classList.add('out'); c.addEventListener('animationend', () => easeHeight(dtChips, () => { c.remove(); dtChips.hidden = !dtChips.children.length; }), { once: true }); } });
127
+ easeHeight(dtChips, () => {
128
+ chips.forEach(([t, k]) => {
129
+ const html = `${t}${ui.button({ icon: 'x', variant: 'ghost', size: 'icon-xs', cls: 'cx-chip-x', attrs: `data-fchip="${k}" aria-label="Quitar ${t}" title="Quitar"` })}`;
130
+ const c = have.get(k);
131
+ if (c) { if (c.dataset.text !== t) { c.innerHTML = html; c.dataset.text = t; } dtChips.appendChild(c); return; }
132
+ const el = document.createElement('span'); el.dataset.slot = 'badge'; el.className = 'cn-badge cn-badge-variant-secondary cx-chip in'; el.dataset.key = k; el.dataset.text = t; el.innerHTML = html; dtChips.appendChild(el);
133
+ });
134
+ if (chips.length) dtChips.hidden = false;
135
+ });
136
+ };
137
+ dtChips.addEventListener('click', (e) => { const b = e.target.closest('[data-fchip]'); if (!b) return; b.closest('.cx-chip').classList.add('out'); const [id, v] = b.dataset.fchip.split('|'); const f = FILTERS.find((x) => x.id === id); if (f.type === 'facet') fState[id].delete(v); else fState[id] = null; refresh(); });
138
+ const cell = (c, r) => {
139
+ const v = r[c.key];
140
+ if (c.type === 'person') return `<td class="cx-dt-who"><b>${v}</b>${c.sub ? `<small class="cx-num">${r[c.sub] ?? ''}</small>` : ''}</td>`;
141
+ if (c.type === 'date') return `<td class="cx-dt-when">${whenText(v)}</td>`;
142
+ if (c.type === 'status') return `<td class="cx-dt-st">${STATUS[v] ? ui.badge(STATUS[v].label, { tone: STATUS[v].tone }) : ''}</td>`;
143
+ if (c.type === 'money') return `<td class="cx-dt-num">${money0(v)}</td>`;
144
+ return `<td class="cx-dt-svc" ${dateCol ? `data-when="${whenText(r[dateCol.key])}"` : ''}>${v ?? ''}</td>`;
145
+ };
146
+ const who = (r) => r[colOf('person')?.key ?? COLS[0]?.key] ?? '';
147
+ const rowCells = (r) => `<td class="cx-dt-check">${cbHtml('Seleccionar ' + who(r), dtSel.has(r.id))}</td>${COLS.map((c) => cell(c, r)).join('')}`;
148
+ const EASE = 'cubic-bezier(.22, 1, .36, 1)';
149
+ let dtAnim = 0;
150
+ const dtPaint = () => {
151
+ const f = SORTERS[dtSort.key] || (() => 0);
152
+ const rows = DT.filter((r) => passes(r)).sort((a, b) => (f(a) > f(b) ? 1 : f(a) < f(b) ? -1 : 0) * dtSort.dir);
153
+ const token = ++dtAnim, scroller = dtBody.closest('.cx-dt-scroll');
154
+ const before = new Map([...dtBody.children].map((tr) => [tr.dataset.id, tr.getBoundingClientRect().top]));
155
+ const keep = new Set(rows.map((r) => String(r.id)));
156
+ const leaving = [...dtBody.children].filter((tr) => !keep.has(tr.dataset.id));
157
+ const apply = () => {
158
+ if (token !== dtAnim) return;
159
+ const fromH = scroller.offsetHeight;
160
+ const byId = new Map([...dtBody.children].map((tr) => [tr.dataset.id, tr]));
161
+ const frag = document.createDocumentFragment(), entering = [];
162
+ rows.forEach((r) => { let tr = byId.get(String(r.id)); if (!tr) { tr = document.createElement('tr'); tr.dataset.id = r.id; entering.push(tr); } tr.toggleAttribute('data-selected', dtSel.has(r.id)); tr.innerHTML = rowCells(r); frag.appendChild(tr); });
163
+ if (!rows.length) { let tr = byId.get('empty'); if (!tr) { tr = document.createElement('tr'); tr.dataset.id = 'empty'; tr.innerHTML = `<td colspan="${COLS.length + 1}"><div data-slot="empty" class="cn-empty cx-dt-empty"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon('search')}</div><div class="cn-empty-title">Nada coincide con los filtros</div><div class="cn-empty-description">Quita alguno o límpialos todos.</div></div><div class="cn-empty-content">${ui.button({ label: 'Limpiar filtros', attrs: 'data-fclear-empty' })}</div></div></td>`; entering.push(tr); } frag.appendChild(tr); }
164
+ dtBody.replaceChildren(frag);
165
+ dtBody.querySelectorAll('tr').forEach((tr) => { const old = before.get(tr.dataset.id); if (old == null) return; const dy = old - tr.getBoundingClientRect().top; if (dy) tr.animate([{ transform: `translateY(${dy}px)` }, { transform: 'none' }], { duration: 240, easing: EASE }); });
166
+ entering.forEach((tr, i) => tr.animate([{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], { duration: 220, delay: Math.min(i, 6) * 18, easing: EASE, fill: 'backwards' }));
167
+ const toH = scroller.offsetHeight; if (fromH !== toH) scroller.animate([{ height: fromH + 'px' }, { height: toH + 'px' }], { duration: 240, easing: EASE });
168
+ };
169
+ if (leaving.length && dtBody.children.length) { leaving.forEach((tr) => tr.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 130, easing: 'ease-out', fill: 'forwards' })); setTimeout(apply, 130); } else apply();
170
+ dtCountEl.textContent = rows.length === DT.length ? `${DT.length} ${DT.length === 1 ? ONE : MANY}` : `${rows.length} de ${DT.length} ${MANY}`; paintChips();
171
+ setCheck(dtAll, dtSel.size === 0 ? false : dtSel.size === DT.length ? true : 'mixed');
172
+ dtBulk.toggleAttribute('data-open', dtSel.size > 0);
173
+ if (dtSel.size) $('[data-dt-count]', root).textContent = `${dtSel.size} ${dtSel.size === 1 ? 'seleccionada' : 'seleccionadas'}`;
174
+ syncMobileCount(rows.length);
175
+ };
176
+ $('thead', root).addEventListener('click', (e) => {
177
+ const th = e.target.closest('[data-sort]'); if (!th) return;
178
+ const key = th.dataset.sort; dtSort = { key, dir: dtSort.key === key ? -dtSort.dir : 1 };
179
+ $$('thead [data-sort]', root).forEach((b) => { const on = b.dataset.sort === key; b.parentElement.setAttribute('aria-sort', on ? (dtSort.dir === 1 ? 'ascending' : 'descending') : 'none'); $('use', b).setAttribute('href', on ? (dtSort.dir === 1 ? '#arrowup' : '#arrowdown') : '#sort'); });
180
+ dtPaint();
181
+ });
182
+ dtAll.addEventListener('click', () => { if (dtSel.size === DT.length) dtSel.clear(); else DT.forEach((r) => dtSel.add(r.id)); dtPaint(); });
183
+ dtBody.addEventListener('click', (e) => {
184
+ if (e.target.closest('[data-fclear-empty]')) { dtTools.querySelector('[data-fclear]')?.click(); return; }
185
+ const tr = e.target.closest('tr'); if (!tr || tr.dataset.id === 'empty') return;
186
+ if (!e.target.closest('.cn-checkbox') && !host.hasAttribute('data-narrow') && !matchMedia('(max-width: 640px)').matches) return;
187
+ const raw = tr.dataset.id, id = DT.find((r) => String(r.id) === raw)?.id; dtSel.has(id) ? dtSel.delete(id) : dtSel.add(id); dtPaint();
188
+ });
189
+ dtBulk.addEventListener('click', (e) => {
190
+ if (e.target.closest('[data-dt-unselect]')) { dtSel.clear(); dtPaint(); return; }
191
+ const b = e.target.closest('[data-dt-bulk]'); if (!b) return;
192
+ const def = bulk.find((x) => x.id === b.dataset.dtBulk); const ids = [...dtSel];
193
+ if (def?.set) DT.forEach((r) => { if (dtSel.has(r.id)) Object.assign(r, def.set); });
194
+ dtSel.clear(); dtPaint(); on?.action?.(JSON.stringify({ action: b.dataset.dtBulk, ids }));
195
+ });
196
+ paintTools(); dtPaint();
197
+ return { refresh: () => { dtPaint(); paintTools(); } };
198
+ }
@@ -0,0 +1,179 @@
1
+ // @muten/shadcn · values (kit): the reference artifact's ScaleSlider (value riding on the thumb, two bubbles merge),
2
+ // CodePicker (a searchable list under a trigger), MoneyField (a currency = code + locale, Intl does the rest),
3
+ // PhoneField (a country = dial code + digits + mask) and the 6-slot verification code. Generic: data and labels in.
4
+ export const CURRENCIES = [
5
+ { id: 'COP', label: 'Peso colombiano', locale: 'es-CO', minor: 2, shown: 0 }, { id: 'USD', label: 'Dólar estadounidense', locale: 'en-US' },
6
+ { id: 'MXN', label: 'Peso mexicano', locale: 'es-MX' }, { id: 'ARS', label: 'Peso argentino', locale: 'es-AR' }, { id: 'VES', label: 'Bolívar', locale: 'es-VE' },
7
+ { id: 'CLP', label: 'Peso chileno', locale: 'es-CL' }, { id: 'PEN', label: 'Sol peruano', locale: 'es-PE' }, { id: 'BRL', label: 'Real brasileño', locale: 'pt-BR' },
8
+ { id: 'EUR', label: 'Euro', locale: 'es-ES' }, { id: 'CAD', label: 'Dólar canadiense', locale: 'en-CA' }, { id: 'AUD', label: 'Dólar australiano', locale: 'en-AU' }, { id: 'JPY', label: 'Yen', locale: 'ja-JP' },
9
+ ];
10
+ export const COUNTRIES = [
11
+ { id: 'AR', label: 'Argentina', dial: '+54 9', e164: '549', len: 10, mask: '## ####-####' }, { id: 'CO', label: 'Colombia', dial: '+57', e164: '57', len: 10, mask: '### ### ####' },
12
+ { id: 'MX', label: 'México', dial: '+52', e164: '52', len: 10, mask: '## #### ####' }, { id: 'US', label: 'Estados Unidos', dial: '+1', e164: '1', len: 10, mask: '(###) ###-####' },
13
+ { id: 'VE', label: 'Venezuela', dial: '+58', e164: '58', len: 10, mask: '### ### ####' }, { id: 'BR', label: 'Brasil', dial: '+55', e164: '55', len: 11, mask: '(##) #####-####' },
14
+ { id: 'CL', label: 'Chile', dial: '+56', e164: '56', len: 9, mask: '# #### ####' }, { id: 'PE', label: 'Perú', dial: '+51', e164: '51', len: 9, mask: '### ### ###' },
15
+ { id: 'ES', label: 'España', dial: '+34', e164: '34', len: 9, mask: '### ## ## ##' }, { id: 'CA', label: 'Canadá', dial: '+1', e164: '1', len: 10, mask: '(###) ###-####' },
16
+ ];
17
+
18
+ export function codePicker(core, { trigger, anchor, items, current, onPick, placeholder }) {
19
+ const { $, $$, lib, place, show, hide, fold } = core;
20
+ const pop = document.createElement('div');
21
+ pop.className = 'cx-cpick pg-pop'; pop.hidden = true; pop.setAttribute('role', 'dialog');
22
+ pop.innerHTML = `<div class="cx-cpick-search"><svg><use href="#search"/></svg><input placeholder="${placeholder}" aria-label="${placeholder}" autocomplete="off" role="combobox" aria-expanded="true"></div><div class="cx-cpick-list cx-combo-list" role="listbox"></div>`;
23
+ lib.appendChild(pop);
24
+ const q = $('input', pop), list = $('[role=listbox]', pop);
25
+ let shown = [], hl = 0;
26
+ const paint = () => {
27
+ const t = fold(q.value.trim());
28
+ shown = items().filter((it) => !t || fold(`${it.label} ${it.id} ${it.hint}`).includes(t));
29
+ hl = Math.min(hl, Math.max(0, shown.length - 1));
30
+ list.innerHTML = shown.length ? shown.map((it, i) => `<div class="cx-opt" role="option" data-i="${i}" aria-selected="${it.id === current()}"${i === hl ? ' data-hl' : ''}><span class="cx-opt-code">${it.id}</span><span>${it.label}</span><small>${it.hint}</small>${it.id === current() ? '<svg class="cx-tick" style="margin-left:6px"><use href="#check"/></svg>' : ''}</div>`).join('') : '<div class="cx-combo-empty">Sin resultados</div>';
31
+ $('[data-hl]', list)?.scrollIntoView({ block: 'nearest' });
32
+ };
33
+ const isOpen = () => pop.dataset.state === 'open';
34
+ const open = () => { q.value = ''; hl = Math.max(0, items().findIndex((it) => it.id === current())); paint(); pop.hidden = false; place(pop, anchor, 'start', 6, Math.max(260, anchor.offsetWidth)); show(pop); trigger.setAttribute('aria-expanded', 'true'); q.focus({ preventScroll: true }); };
35
+ const close = (refocus) => { if (!isOpen()) return; hide(pop, 120); trigger.setAttribute('aria-expanded', 'false'); if (refocus) trigger.focus({ preventScroll: true }); };
36
+ const pick = (i) => { const it = shown[i]; if (!it) return; close(false); onPick(it); };
37
+ trigger.addEventListener('click', () => (isOpen() ? close(true) : open()));
38
+ q.addEventListener('input', () => { hl = 0; paint(); });
39
+ q.addEventListener('keydown', (e) => {
40
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); hl = (hl + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % Math.max(1, shown.length); paint(); }
41
+ else if (e.key === 'Enter') { e.preventDefault(); pick(hl); }
42
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); }
43
+ else if (e.key === 'Tab') close(false);
44
+ });
45
+ list.addEventListener('pointerdown', (e) => e.preventDefault());
46
+ list.addEventListener('pointermove', (e) => { const o = e.target.closest('[data-i]'); if (o && +o.dataset.i !== hl) { hl = +o.dataset.i; $$('[data-i]', list).forEach((x) => x.toggleAttribute('data-hl', x === o)); } });
47
+ list.addEventListener('click', (e) => { const o = e.target.closest('[data-i]'); if (o) pick(+o.dataset.i); });
48
+ document.addEventListener('pointerdown', (e) => { if (isOpen() && !pop.contains(e.target) && !trigger.contains(e.target)) close(false); });
49
+ addEventListener('scroll', () => { if (isOpen()) place(pop, anchor, 'start', 6, Math.max(260, anchor.offsetWidth)); }, { passive: true });
50
+ }
51
+
52
+ // ScaleSlider: one or two thumbs, the value rides on each thumb, two bubbles that would touch merge into one.
53
+ // i: {label, min, max, step, values "24" | "20000,80000", gap, format: hours|money|percent|"" , suffix}
54
+ export function scaleSlider(root, i, core, on) {
55
+ const { $, $$, money0 } = core;
56
+ const min = Number(i.min) || 0, max = Number(i.max) || 100, step = Number(i.step) || 1, gap = Number(i.gap) || 0;
57
+ const FMT = { hours: (v) => `${v} h`, money: money0, percent: (v) => `${v} %` };
58
+ const fmt = FMT[i.format] || ((v) => `${v}${i.suffix || ''}`);
59
+ const vals = String(i.values ?? min).split(',').map(Number), two = vals.length > 1, TW = 21;
60
+ const id = 'sl' + Math.random().toString(36).slice(2, 7);
61
+ root.innerHTML = `<div class="cx-sl-field"><div class="cx-sl-head"><span class="cn-label" id="${id}-l">${i.label || ''}</span></div><div data-slot="slider" class="cn-slider cx-slider" data-orientation="horizontal" data-horizontal><div data-slot="slider-track" class="cn-slider-track" data-orientation="horizontal"><div data-slot="slider-range" class="cn-slider-range" data-orientation="horizontal"></div></div>${vals.map((_, k) => `<span data-slot="slider-thumb" class="cn-slider-thumb" tabindex="0" role="slider" ${two ? `aria-label="${i.label || ''} ${k ? 'máximo' : 'mínimo'}"` : `aria-labelledby="${id}-l"`}></span>`).join('')}</div><div class="cx-sl-scale"><span>${fmt(min)}</span><span>${fmt(max)}</span></div></div>`;
62
+ const sl = $('.cx-slider', root), thumbs = $$('.cn-slider-thumb', sl), range = $('.cn-slider-range', sl);
63
+ const bubbles = thumbs.map(() => { const b = document.createElement('span'); b.className = 'cx-sl-bubble'; b.setAttribute('aria-hidden', 'true'); sl.appendChild(b); return b; });
64
+ const bounds = (k) => [two && k === 1 ? vals[0] + gap : min, two && k === 0 ? vals[1] - gap : max];
65
+ const paint = () => {
66
+ const run = sl.clientWidth - TW, at = (v) => ((v - min) / (max - min)) * run;
67
+ thumbs.forEach((t, k) => { const [lo, hi] = bounds(k); t.style.left = at(vals[k]) + 'px'; t.setAttribute('aria-valuenow', vals[k]); t.setAttribute('aria-valuemin', lo); t.setAttribute('aria-valuemax', hi); t.setAttribute('aria-valuetext', fmt(vals[k])); });
68
+ const a = two ? at(vals[0]) + TW / 2 : 0, b = at(vals[vals.length - 1]) + TW / 2;
69
+ range.style.left = a + 'px'; range.style.width = (b - a) + 'px';
70
+ bubbles.forEach((bb, k) => { bb.hidden = false; bb.textContent = fmt(vals[k]); bb.style.left = at(vals[k]) + TW / 2 + 'px'; });
71
+ if (two) { const [b0, b1] = bubbles, r0 = b0.getBoundingClientRect(), r1 = b1.getBoundingClientRect(); if (r0.right + 6 > r1.left) { b1.hidden = true; b0.textContent = `${fmt(vals[0])} – ${fmt(vals[1])}`; b0.style.left = (at(vals[0]) + at(vals[1])) / 2 + TW / 2 + 'px'; } }
72
+ };
73
+ const emit = () => on?.change?.(vals.join(','));
74
+ const set = (k, v) => { const [lo, hi] = bounds(k); const nv = Math.min(hi, Math.max(lo, Math.round((v - min) / step) * step + min)); if (nv === vals[k]) return; vals[k] = nv; paint(); emit(); };
75
+ const valueAt = (x) => min + ((x - sl.getBoundingClientRect().left - TW / 2) / (sl.clientWidth - TW)) * (max - min);
76
+ let drag = null;
77
+ sl.addEventListener('pointerdown', (e) => {
78
+ e.preventDefault();
79
+ const onThumb = thumbs.indexOf(e.target); let k = onThumb;
80
+ if (k < 0) { const v = valueAt(e.clientX); k = two ? (Math.abs(v - vals[0]) <= Math.abs(v - vals[1]) ? (v > vals[1] ? 1 : 0) : 1) : 0; }
81
+ const tr = thumbs[k].getBoundingClientRect();
82
+ drag = { k, grab: onThumb >= 0 ? e.clientX - (tr.left + TW / 2) : 0 };
83
+ thumbs[k].setAttribute('data-drag', ''); thumbs[k].focus({ preventScroll: true }); sl.setPointerCapture(e.pointerId);
84
+ set(k, valueAt(e.clientX - drag.grab));
85
+ });
86
+ sl.addEventListener('pointermove', (e) => { if (drag) set(drag.k, valueAt(e.clientX - drag.grab)); });
87
+ const end = () => { if (!drag) return; thumbs[drag.k].removeAttribute('data-drag'); drag = null; };
88
+ sl.addEventListener('pointerup', end); sl.addEventListener('pointercancel', end);
89
+ thumbs.forEach((t, k) => t.addEventListener('keydown', (e) => {
90
+ const d = { ArrowRight: step, ArrowUp: step, ArrowLeft: -step, ArrowDown: -step, PageUp: step * 10, PageDown: -step * 10 }[e.key];
91
+ if (d !== undefined) { e.preventDefault(); set(k, vals[k] + d); }
92
+ if (e.key === 'Home') { e.preventDefault(); set(k, bounds(k)[0]); }
93
+ if (e.key === 'End') { e.preventDefault(); set(k, bounds(k)[1]); }
94
+ }));
95
+ new ResizeObserver(paint).observe(sl); paint();
96
+ return (next) => { const nv = String(next.values ?? '').split(',').map(Number); if (nv.length === vals.length && nv.every((v, k) => v === vals[k])) return; nv.forEach((v, k) => { if (k < vals.length && !Number.isNaN(v)) vals[k] = v; }); paint(); };
97
+ }
98
+
99
+ // MoneyField: i {label, value (a number), currency "COP", hint "on"} → on.change(json {amount, minor, currency})
100
+ export function moneyField(root, i, core, on) {
101
+ const { $, ui, reformat, groupThousands } = core;
102
+ const id = 'mf' + Math.random().toString(36).slice(2, 7);
103
+ 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 || 'Precio'}</label><div data-slot="input-group" class="cn-input-group group/input-group" data-mf-group><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-start cx-num" data-mf-sym>$</div><input data-slot="input-group-control" class="cn-input cn-input-group-input cx-num" id="${id}" inputmode="decimal" autocomplete="off"><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-end" style="padding-right:2px"><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-xs group/button cx-cc" data-mf-cur aria-haspopup="dialog" aria-expanded="false" aria-label="Moneda"><span class="cx-cc-code" data-mf-code>COP</span><svg><use href="#chev"/></svg></button></div></div><p data-slot="field-description" class="cn-field-description cx-num" data-mf-out></p></div>`;
104
+ const currencySpec = (c) => {
105
+ const nf = new Intl.NumberFormat(c.locale, { style: 'currency', currency: c.id, currencyDisplay: 'narrowSymbol' });
106
+ const parts = nf.formatToParts(1234567.89), part = (t, d) => parts.find((p) => p.type === t)?.value ?? d;
107
+ const dec = new Intl.NumberFormat(c.locale, { minimumFractionDigits: 1 }).formatToParts(1.5).find((p) => p.type === 'decimal').value;
108
+ const digits = nf.resolvedOptions().maximumFractionDigits;
109
+ return { sym: part('currency', c.id), group: part('group', dec === ',' ? '.' : ','), dec, minor: c.minor ?? digits, places: c.shown ?? digits };
110
+ };
111
+ const mf = $('input', root), mfOut = $('[data-mf-out]', root);
112
+ let curRow = CURRENCIES.find((c) => c.id === i.currency) || CURRENCIES[0], cur = currencySpec(curRow);
113
+ const escRe = (c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
114
+ const sigFor = () => new RegExp(`[\\d${escRe(cur.dec)}]`);
115
+ const fmtMoney = (v) => { const k = cur.places ? v.indexOf(cur.dec) : -1; const int = (k < 0 ? v : v.slice(0, k)).replace(/\D/g, '').slice(0, 12); const frac = k < 0 ? '' : v.slice(k + 1).replace(/\D/g, '').slice(0, cur.places); return (groupThousands(int, cur.group) || (k >= 0 ? '0' : '')) + (k >= 0 ? cur.dec + frac : ''); };
116
+ const amount = () => { if (!mf.value) return 0; const [a, f = ''] = mf.value.split(cur.dec); return Number(a.split(cur.group).join('') + '.' + (f || '0')); };
117
+ const unitName = () => (cur.minor === 0 ? `${curRow.id} (no tiene unidad menor)` : `centavos de ${curRow.id}`);
118
+ const showMinor = () => { mfOut.textContent = mf.value ? `Se guarda: ${Math.round(amount() * 10 ** cur.minor)} ${unitName()}` : 'Escribe un precio'; };
119
+ const toText = (n) => { const [a, f] = n.toFixed(cur.places).split('.'); return groupThousands(a, cur.group) + (cur.places && /[1-9]/.test(f) ? cur.dec + f : ''); };
120
+ const emit = () => on?.change?.(JSON.stringify({ amount: amount(), minor: Math.round(amount() * 10 ** cur.minor), currency: curRow.id }));
121
+ mf.addEventListener('input', () => { reformat(mf, fmtMoney, sigFor()); showMinor(); emit(); });
122
+ mf.addEventListener('keydown', (e) => { if (!cur.places || (e.key !== '.' && e.key !== ',') || e.key === cur.dec) return; e.preventDefault(); if (!mf.value.includes(cur.dec)) { mf.setRangeText(cur.dec, mf.selectionStart, mf.selectionEnd, 'end'); mf.dispatchEvent(new Event('input')); } });
123
+ const setCurrency = (row) => { const n = amount(); curRow = row; cur = currencySpec(row); $('[data-mf-sym]', root).textContent = cur.sym; $('[data-mf-code]', root).textContent = row.id; mf.value = n ? toText(n) : ''; showMinor(); };
124
+ codePicker(core, { trigger: $('[data-mf-cur]', root), anchor: $('[data-mf-group]', root), placeholder: 'Buscar moneda', current: () => curRow.id, items: () => CURRENCIES.map((c) => ({ id: c.id, label: c.label, hint: currencySpec(c).sym })), onPick: (it) => { setCurrency(CURRENCIES.find((c) => c.id === it.id)); mf.focus({ preventScroll: true }); emit(); } });
125
+ mf.value = fmtMoney(String(i.value ?? '')); setCurrency(curRow);
126
+ void ui;
127
+ }
128
+
129
+ // PhoneField: i {label, value (digits), country "CO"} → on.change(json {e164, valid, country})
130
+ export function phoneField(root, i, core, on) {
131
+ const { $, reformat } = core;
132
+ const id = 'ph' + Math.random().toString(36).slice(2, 7);
133
+ root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field" data-ph-f><label data-slot="label" class="cn-label" for="${id}">${i.label || 'Teléfono'}</label><div data-slot="input-group" class="cn-input-group group/input-group cx-phone" data-ph-group><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-start" style="padding-left:2px"><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-xs group/button cx-cc" data-ph-cc aria-haspopup="dialog" aria-expanded="false" aria-label="País"><span class="cx-cc-code" data-ph-flag></span><span class="cx-num cx-cc-dial" data-ph-dial></span><svg><use href="#chev"/></svg></button></div><input data-slot="input-group-control" class="cn-input cn-input-group-input cx-num" id="${id}" type="tel" inputmode="tel" autocomplete="tel-national" aria-describedby="${id}-msg"></div><p data-slot="field-description" class="cn-field-description cx-msg cx-num" id="${id}-msg"></p></div>`;
134
+ const ph = $('input', root), phMsg = $(`#${id}-msg`, root), phF = $('[data-ph-f]', root);
135
+ let country = COUNTRIES.find((c) => c.id === i.country) || COUNTRIES[1];
136
+ const phDigits = (v) => { let d = v.replace(/\D/g, ''); if (d.length > country.len && d.startsWith(country.e164)) d = d.slice(country.e164.length); return d.slice(0, country.len); };
137
+ const fmtPhone = (v) => { const d = phDigits(v); let out = '', k = 0; for (const ch of country.mask) { if (k >= d.length) break; out += ch === '#' ? d[k++] : ch; } return out; };
138
+ const emit = () => { const d = phDigits(ph.value); on?.change?.(JSON.stringify({ e164: `+${country.e164}${d}`, valid: d.length === country.len, country: country.id })); };
139
+ const phCheck = (final) => { const d = phDigits(ph.value), ok = d.length === country.len, bad = final && d.length > 0 && !ok; phF.toggleAttribute('data-invalid', bad); ph.setAttribute('aria-invalid', bad); phMsg.textContent = ok ? `Se guarda como +${country.e164}${d}` : bad ? `Faltan ${country.len - d.length} dígitos para un número de ${country.label}` : `${country.len} dígitos, sin el ${country.dial}`; };
140
+ const setCountry = (c) => { country = c; $('[data-ph-flag]', root).textContent = c.id; $('[data-ph-dial]', root).textContent = c.dial; ph.placeholder = c.mask.replace(/#/g, '0'); ph.value = fmtPhone(ph.value); phCheck(false); };
141
+ ph.addEventListener('input', () => { reformat(ph, fmtPhone, /\d/); phCheck(phF.hasAttribute('data-invalid')); emit(); });
142
+ ph.addEventListener('blur', () => phCheck(true));
143
+ codePicker(core, { trigger: $('[data-ph-cc]', root), anchor: $('[data-ph-group]', root), placeholder: 'Buscar país', current: () => country.id, items: () => COUNTRIES.map((c) => ({ id: c.id, label: c.label, hint: c.dial })), onPick: (it) => { setCountry(COUNTRIES.find((c) => c.id === it.id)); ph.focus({ preventScroll: true }); emit(); } });
144
+ setCountry(country); ph.value = fmtPhone(String(i.value ?? '')); phCheck(false);
145
+ }
146
+
147
+ // OtpField: 6 slots in two groups of 3. i {length, hint, state: ""|checking|ok|bad, message, resendIn} →
148
+ // on.complete(code) when full, on.resend(). The page checks the code and answers with state + message.
149
+ export function otpField(root, i, core, on) {
150
+ const { $ } = core;
151
+ const len = Number(i.length) || 6, half = Math.ceil(len / 2);
152
+ root.innerHTML = `<div class="cx-vline cx-otp-stack"><div class="cx-otp"><input class="cx-otp-input" inputmode="numeric" autocomplete="one-time-code" maxlength="${len}" aria-label="Código de ${len} dígitos"><div data-slot="input-otp" class="cn-input-otp" aria-hidden="true"><div data-slot="input-otp-group" class="cn-input-otp-group"></div><div data-slot="input-otp-separator" class="cn-input-otp-separator cx-otp-sep"><span></span></div><div data-slot="input-otp-group" class="cn-input-otp-group"></div></div></div><p class="cx-note" data-otp-msg></p><button data-slot="button" class="cn-button cn-button-variant-link cn-button-size-sm group/button" data-otp-resend disabled></button></div>`;
153
+ const otpIn = $('.cx-otp-input', root), otpBox = $('.cx-otp', root), otpMsg = $('[data-otp-msg]', root), resend = $('[data-otp-resend]', root);
154
+ const slots = [];
155
+ root.querySelectorAll('.cn-input-otp-group').forEach((g, gi) => { const n = gi ? len - half : half; for (let k = 0; k < n; k++) { const s = document.createElement('div'); s.className = 'cn-input-otp-slot'; s.dataset.slot = 'input-otp-slot'; g.appendChild(s); slots.push(s); } });
156
+ const hint = i.hint || ''; let busy = false;
157
+ otpMsg.textContent = hint;
158
+ const paint = () => { const v = otpIn.value, focused = document.activeElement === otpIn, at = Math.min(v.length, len - 1); slots.forEach((s, k) => { s.textContent = v[k] || ''; const act = focused && k === at && !busy; s.dataset.active = act; if (act && !v[k]) s.innerHTML = '<span class="cx-caret"></span>'; }); };
159
+ const toEnd = () => { const n = otpIn.value.length; otpIn.setSelectionRange(n, n); };
160
+ otpIn.addEventListener('input', () => {
161
+ otpIn.value = otpIn.value.replace(/\D/g, '').slice(0, len); delete otpBox.dataset.state; otpMsg.removeAttribute('data-tone'); otpMsg.textContent = hint; paint();
162
+ if (otpIn.value.length === len) { busy = true; otpIn.readOnly = true; otpMsg.textContent = 'Verificando…'; paint(); on?.complete?.(otpIn.value); }
163
+ });
164
+ ['focus', 'blur', 'click', 'keyup'].forEach((t) => otpIn.addEventListener(t, () => { toEnd(); paint(); }));
165
+ otpIn.addEventListener('select', toEnd);
166
+ let left = Number(i.resendIn ?? 30), rt = 0;
167
+ const tick = () => { left--; resend.disabled = left > 0; resend.textContent = left > 0 ? `Reenviar en 0:${String(left).padStart(2, '0')}` : 'Reenviar código'; if (left > 0) rt = setTimeout(tick, 1000); };
168
+ resend.textContent = `Reenviar en 0:${String(left).padStart(2, '0')}`;
169
+ resend.onclick = () => { left = Number(i.resendIn ?? 30) + 1; clearTimeout(rt); tick(); otpMsg.removeAttribute('data-tone'); otpMsg.textContent = 'Te enviamos un código nuevo'; otpIn.value = ''; delete otpBox.dataset.state; otpIn.focus(); on?.resend?.(); };
170
+ rt = setTimeout(tick, 1000); paint();
171
+ const answer = (st, msg) => {
172
+ if (!st || st === 'checking') return;
173
+ busy = false; otpIn.readOnly = false;
174
+ if (st === 'ok') { otpBox.dataset.state = 'ok'; otpMsg.dataset.tone = 'ok'; otpMsg.textContent = msg || 'Listo, quedó verificado'; otpIn.blur(); }
175
+ if (st === 'bad') { otpBox.dataset.state = 'bad'; otpMsg.dataset.tone = 'bad'; otpMsg.textContent = msg || 'Ese código no es'; const wrong = otpIn.value; setTimeout(() => { if (otpIn.value === wrong) { otpIn.value = ''; paint(); } }, 450); }
176
+ paint();
177
+ };
178
+ return (next) => answer(next.state, next.message);
179
+ }
@@ -0,0 +1,2 @@
1
+ // KitLayer (muten Custom) - loads kit/core.js once; importing it installs the tooltip, the island and the sprite.
2
+ export function mount() { import('@muten/shadcn/registry/kit/core.js'); return () => {}; }
@@ -0,0 +1,5 @@
1
+ # KitLayer - put it ONCE in the app shell: it loads the kit core so the library's shared pieces work everywhere
2
+ # (the tooltip on anything with a name, the Island, the icon sprite, the floating layer that follows the skin).
3
+ # shell { Stack { Custom KitLayer class("cx-kit-host") … slot … } } (a shell cannot call plugin parts: use the Custom directly)
4
+ # shadcn-for-muten · Custom (kit core) · host JS is kit-layer.js.
5
+ part KitLayer() { Custom KitLayer class("cx-kit-host") }
@@ -0,0 +1,24 @@
1
+ // KitSkeleton (muten Custom) - the kit's skeletons (kit.sk in kit/core.js): the shape of the content, built from the
2
+ // same atoms and layout classes as the real thing. inputs: kind (list · item · stat · stats · card · products ·
3
+ // bubbles · table · messaging · agenda), count, media (avatar · icon · none).
4
+ export function mount(el, inputs) {
5
+ const n = Number(inputs.count) || 4;
6
+ const KINDS = {
7
+ list: (sk) => `<div class="cn-item-group">${sk.list(n, { media: inputs.media || 'avatar', aside: 44 })}</div>`,
8
+ item: (sk) => sk.item({ media: inputs.media || 'avatar' }),
9
+ stat: (sk) => sk.stat(),
10
+ stats: (sk) => `<div class="cx-stats">${Array.from({ length: n }, sk.stat).join('')}</div>`,
11
+ card: (sk) => sk.card({ media: 140, lines: 2 }),
12
+ products: (sk) => sk.products(n),
13
+ bubbles: (sk) => `<div class="cx-sk-chatbox">${sk.bubbles(n)}</div>`,
14
+ table: (sk) => sk.table(n),
15
+ messaging: (sk) => `<div class="cx-mx">${sk.messaging()}</div>`,
16
+ agenda: (sk) => sk.agenda({ columns: 3 }),
17
+ };
18
+ import('@muten/shadcn/registry/kit/core.js').then(({ kit }) => {
19
+ el.innerHTML = `<span class="cx-sr">Cargando…</span>${(KINDS[inputs.kind] || KINDS.list)(kit.sk)}`;
20
+ // the inbox lays itself out by its own width, like the real one
21
+ const mx = el.querySelector('.cx-mx'); if (mx) { mx.dataset.view = 'list'; kit.watchWidth(mx, [[720, 'narrow'], [1060, 'mid']], 'wide'); }
22
+ });
23
+ return () => {};
24
+ }
@@ -0,0 +1,16 @@
1
+ # Skeletons / Loading - while there is NOTHING to show, the shape of the content: built from the same atoms (Item,
2
+ # Card, Stat, bubbles) with Skeleton in place of the text, so when the data lands only the pixels change, with a fade
3
+ # and no jump in height. A refresh keeps the content: gate on «nothing yet», never on «fetching again».
4
+ # Loading(busy: orders.loading, kind: "list", count: 4) { each orders as o { … } }
5
+ # Skeletons(kind: "messaging")
6
+ # kind: list · item · stat · stats · card · products · bubbles · table · messaging · agenda. media: avatar · icon · none.
7
+ # shadcn-for-muten · Custom (kit.sk) · kit.css (.cx-sk*, .cx-loaded fade).
8
+ part Skeletons(kind: text = "list", count: number = 4, media: text = "avatar") {
9
+ Custom KitSkeleton inputs(kind: $kind, count: $count, media: $media) class("cx-sk-host") aria(busy: "true")
10
+ }
11
+ part Loading(busy: bool, kind: text = "list", count: number = 4, media: text = "avatar") {
12
+ Stack class("cx-loading") {
13
+ when $busy { Custom KitSkeleton inputs(kind: $kind, count: $count, media: $media) class("cx-sk-host") aria(busy: "true") }
14
+ when not $busy { Stack class("cx-loaded") { slot } }
15
+ }
16
+ }
@@ -0,0 +1,79 @@
1
+ // KitWidget (muten Custom) - one host for the system kit's widgets (kit/core.js): it loads the core once and renders
2
+ // the widget named by `name` with the page's data. The kit's functions (a bot's answers, a filter, a reply) arrive
3
+ // as DATA (rules, ids), never as code. Each widget has its own part (Messaging, Chatbot, BarChart, …); use those.
4
+ export function mount(el, inputs, on) {
5
+ const list = (v) => (Array.isArray(v) ? v : []);
6
+ const rx = (s) => { try { return new RegExp(s, 'i'); } catch { return /$^/; } };
7
+ const RENDER = {
8
+ messaging: ({ kit, ui }, i) => {
9
+ const map = (xs) => Object.fromEntries(list(xs).map((x) => [x.id, x]));
10
+ const buttons = (xs) => list(xs).map((b) => ui.button({ label: b.label, variant: b.variant || 'outline', size: 'xs', attrs: `data-kw-act="${b.id || b.label}"` })).join('');
11
+ const conversations = list(i.conversations).map((c) => ({ ...c, messages: list(c.messages).map((m) => (m.event ? { ...m, event: { ...m.event, actions: Array.isArray(m.event.actions) ? buttons(m.event.actions) : m.event.actions || '' } } : m)) }));
12
+ const replies = list(i.replies);
13
+ kit.messaging(el, {
14
+ title: i.title || 'Mensajes', channels: map(i.channels), conversations,
15
+ filters: list(i.filters).map((f) => ({ id: f.id, label: f.label, match: f.state ? (c) => c.state === f.state : () => true })),
16
+ states: map(i.states), control: map(i.controls),
17
+ quick: list(i.quick).map((q) => ({ ...q, card: () => `${ui.text(q.title || q.label, 'label')}${q.subtitle ? ui.text(q.subtitle, 'caption') : ''}` })),
18
+ profile: (c) => c.client, templates: list(i.templates).map((t) => [t.title, t.body]),
19
+ reply: (c, text) => (replies.find((r) => r.match && rx(r.match).test(text || '')) || replies.find((r) => !r.match))?.text || null,
20
+ });
21
+ el.addEventListener('click', (e) => { const b = e.target.closest('[data-kw-act]'); if (b) on.action?.(b.dataset.kwAct); });
22
+ },
23
+ chatbot: ({ kit, ui }, i) => {
24
+ const answers = list(i.answers);
25
+ kit.chatbot(el, { name: i.title || 'Asistente', greeting: i.greeting || '', suggestions: list(i.suggestions).map((s) => s.text ?? s),
26
+ respond: (t) => { const a = answers.find((x) => x.match && rx(x.match).test(t)) || answers.find((x) => !x.match) || { text: '' }; return { text: a.text, actions: a.action ? ui.button({ label: a.action, variant: a.primary ? 'default' : 'outline', size: 'xs', attrs: `data-kw-act="${a.action}"` }) : '' }; } });
27
+ if (!el.__kwBound) { el.__kwBound = true; el.addEventListener('click', (e) => { const b = e.target.closest('[data-kw-act]'); if (b) on.action?.(b.dataset.kwAct); }); }
28
+ },
29
+ barChart: ({ kit, money0, today, addDays, DAY, MON }, i) => {
30
+ // a period is {label, days, values}: the labels are the last `days` days up to today
31
+ const periods = list(i.periods).map((p) => { const n = list(p.values).length; return [p.label, list(p.values).map((v, k) => { const d = addDays(today, k - n + 1); return [n > 14 ? `${d.getDate()} ${MON[d.getMonth()].slice(0, 3)}` : `${DAY[d.getDay()]} ${d.getDate()}`, v]; })]; });
32
+ kit.barChart(el, { title: i.title || '', data: periods[0]?.[1] || [], fmt: i.money === 'on' ? money0 : (v) => v, periods });
33
+ },
34
+ checklist: ({ kit }, i) => { kit.checklist(el, { title: i.title || '', steps: list(i.steps) }); el.addEventListener('click', (e) => { const b = e.target.closest('button'); if (b && !b.disabled && b.closest('[data-slot="item"], .cn-item')) on.action?.(b.textContent.trim()); }); },
35
+ plans: ({ kit }, i) => kit.plans(el, { yearlyNote: i.note || '', plans: list(i.plans) }),
36
+ cardField: ({ kit }) => kit.cardField(el, { onSubmit: (x) => on.action?.(JSON.stringify(x || {})) }),
37
+ memberList: ({ kit }, i) => kit.memberList(el, { members: list(i.members), roles: list(i.roles), ownerRole: i.owner || '', noun: [i.one || 'persona', i.many || 'personas'], invite: { cta: i.cta || 'Invitar', title: i.cta || 'Invitar', description: i.note || '' } }),
38
+ permissionMatrix: ({ kit }, i) => kit.permissionMatrix(el, { roles: list(i.roles).map((r) => r.name ?? r), rows: list(i.rows), lockedRole: i.locked || '', onChange: (rows) => on.action?.(JSON.stringify(rows)) }),
39
+ stampCard: ({ kit }, i) => kit.stampCard(el, { store: i.store || '', owner: i.owner || '', total: Number(i.total) || 10, stamps: Number(i.stamps) || 0, reward: i.reward || '' }),
40
+ bell: ({ kit }, i) => kit.bell(el, { items: list(i.items) }),
41
+ queue: ({ kit }, i) => kit.queue(el, { title: i.title || '', people: list(i.people), offerMinutes: Number(i.minutes) || 10 }),
42
+ password: ({ kit }, i) => kit.password(el, { label: i.label || 'Contraseña' }),
43
+ // days: {name, open, ranges [["09:00","13:00"]]}. Inside a form with a DirtyBar, «Descartar» brings the hours back.
44
+ weeklyHours: ({ kit }, i) => {
45
+ const wh = kit.weeklyHours(el, { days: list(i.days).map((d) => ({ name: d.name, open: !!d.open, ranges: list(d.ranges).map((r) => [...r]) })) });
46
+ let saved = wh.value(); const form = el.closest('.cx-set-form');
47
+ form?.addEventListener('discard', () => wh.set(saved)); form?.addEventListener('kit-saved', () => { saved = wh.value(); });
48
+ el.addEventListener('input', () => on.action?.(JSON.stringify(wh.value())));
49
+ },
50
+ };
51
+ RENDER.table = (core, i) => import('@muten/shadcn/registry/kit/table.js').then(({ dataTable }) => dataTable(el, { columns: i.columns, rows: i.rows, filters: i.filters, statuses: i.statuses, noun: [i.one || 'fila', i.many || 'filas'], bulk: i.bulk }, core, on));
52
+ // until the kit arrives the host keeps its final size with a shimmer (kit.css, :empty), then the widget fades in
53
+ el.classList.add('cx-kw-wait');
54
+ import('@muten/shadcn/registry/kit/core.js').then((core) => {
55
+ const render = RENDER[inputs.name]; if (!render) return;
56
+ render(core, inputs); el.classList.remove('cx-kw-wait');
57
+ el.animate([{ opacity: 0.001 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' });
58
+ play(core, render);
59
+ });
60
+ // demo "on": the widget plays itself for a landing page - it presses its own control in turn (a stamp, a suggestion)
61
+ // and starts over when it runs out; the first touch hands it to the person. Still under reduced motion.
62
+ const DEMO = { stampCard: { target: '[data-stamp]', every: 1100, steps: () => (Number(inputs.total) || 10) - (Number(inputs.stamps) || 0) + 1 },
63
+ chatbot: { target: '[data-sug]', every: 5200, steps: () => list(inputs.suggestions).length } };
64
+ function play(core, render) {
65
+ const d = DEMO[inputs.name];
66
+ if (!d || inputs.demo !== 'on' || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
67
+ let timer = 0, done = 0, stopped = false;
68
+ const tick = () => {
69
+ if (stopped) return;
70
+ const open = [...el.querySelectorAll(d.target)].filter((b) => !b.disabled);
71
+ if (!open.length || done >= d.steps()) { done = 0; timer = setTimeout(() => { if (stopped) return; el.innerHTML = ''; render(core, inputs); timer = setTimeout(tick, d.every); }, d.every * 1.6); return; }
72
+ open[0].click(); done++;
73
+ timer = setTimeout(tick, d.every);
74
+ };
75
+ el.addEventListener('pointerdown', () => { stopped = true; clearTimeout(timer); }, { once: true, capture: true });
76
+ timer = setTimeout(tick, d.every);
77
+ }
78
+ return () => {};
79
+ }
@@ -0,0 +1,4 @@
1
+ # KitWidget - the host the system kit's parts (Messaging, Chatbot, BarChart, Checklist, Plans, CardField, MemberList,
2
+ # PermissionMatrix, StampCard, Bell, Queue, PasswordField) render through. Call those parts, not this.
3
+ # shadcn-for-muten · Custom (kit core) · host JS is kit-widget.js.
4
+ part KitWidget(name: text) { Custom KitWidget inputs(name: $name) class("cx-kit-widget") }
@@ -1,7 +1,9 @@
1
- # LinkBtn - a navigation link that looks like a button (plug-and-play, no class props). For CTAs that go to a
2
- # route. Put the label/icon in the slot.
3
- # LinkBtn(to: "/signup", variant: "default") { Span "Get started" }
4
- # LinkBtn(to: "/docs", variant: "outline") { Span "Read the docs" Icon "lucide:arrow-right" class("size-4") }
5
- # variant: default | secondary | outline | ghost | destructive | link.
6
- # shadcn-for-muten · part · no deps · wraps the Link primitive + the .btn recipe.
7
- part LinkBtn(to: text, variant: text) { Link -> "{$to}" class("btn btn-{$variant}") { slot } }
1
+ # LinkBtn - a link to a route that looks like a Button: same variants and sizes as Btn, but it navigates (a real
2
+ # <a>, so it opens in a new tab with a middle click and reads as a link). Label and icon go in the slot.
3
+ # LinkBtn(to: "/signup") { Span "Crear cuenta" }
4
+ # LinkBtn(to: "/docs", variant: "outline", size: "sm") { Span "Ver la guía" Icon "lucide:arrow-right" }
5
+ # variant: default | outline | secondary | ghost | destructive | link · size: xs | sm | default | lg | xl (44) | 2xl (52)
6
+ # shadcn-for-muten · part · no deps · Nova classes (nova.css).
7
+ part LinkBtn(to: text, variant: text = "default", size: text = "default") {
8
+ Link -> "{$to}" class("cn-button cn-button-variant-{$variant} cn-button-size-{$size} group/button") data(slot: "button") { slot }
9
+ }