@muten/shadcn 0.1.15 → 0.2.1

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 (183) hide show
  1. package/AGENTS.md +88 -1
  2. package/globals.css +1 -0
  3. package/kit.css +348 -126
  4. package/motion.css +3 -0
  5. package/package.json +1 -1
  6. package/registry/accordion.muten +3 -3
  7. package/registry/agenda.js +20 -16
  8. package/registry/agenda.muten +14 -12
  9. package/registry/alert-dialog.muten +2 -2
  10. package/registry/alert.muten +1 -1
  11. package/registry/app-shell.muten +1 -1
  12. package/registry/attachment.muten +1 -1
  13. package/registry/avatar.muten +4 -4
  14. package/registry/badge.muten +14 -3
  15. package/registry/band.muten +2 -2
  16. package/registry/bar-chart.muten +2 -2
  17. package/registry/bio-play.muten +2 -2
  18. package/registry/booking-play.muten +5 -5
  19. package/registry/booking.muten +2 -2
  20. package/registry/breadcrumb.muten +4 -4
  21. package/registry/button.muten +9 -7
  22. package/registry/calendar.js +15 -10
  23. package/registry/calendar.muten +1 -1
  24. package/registry/card.muten +1 -1
  25. package/registry/carousel.muten +1 -1
  26. package/registry/chat-play.muten +8 -8
  27. package/registry/chatbot.muten +5 -4
  28. package/registry/checklist.muten +1 -1
  29. package/registry/choice-list.muten +3 -3
  30. package/registry/code.js +10 -4
  31. package/registry/collapsible.muten +2 -2
  32. package/registry/color-picker.js +37 -36
  33. package/registry/color-picker.muten +1 -1
  34. package/registry/combobox.js +1 -1
  35. package/registry/combobox.muten +2 -2
  36. package/registry/command-palette.muten +2 -2
  37. package/registry/command.js +1 -1
  38. package/registry/command.muten +7 -7
  39. package/registry/context-menu.muten +3 -3
  40. package/registry/count-up.js +33 -31
  41. package/registry/count-up.muten +6 -4
  42. package/registry/date-field.js +1 -1
  43. package/registry/date-field.muten +3 -3
  44. package/registry/day-plan.muten +5 -5
  45. package/registry/device-frame.muten +3 -3
  46. package/registry/dialog.muten +1 -1
  47. package/registry/dirty-bar.js +2 -2
  48. package/registry/dirty-bar.muten +3 -3
  49. package/registry/drawer.muten +1 -1
  50. package/registry/dropdown-menu.muten +4 -4
  51. package/registry/empty.muten +1 -1
  52. package/registry/feature-grid.muten +1 -1
  53. package/registry/feature-rows.muten +2 -2
  54. package/registry/field-behavior.js +13 -3
  55. package/registry/field-behavior.muten +3 -3
  56. package/registry/field.muten +5 -5
  57. package/registry/file-input.js +8 -4
  58. package/registry/file-input.muten +3 -3
  59. package/registry/fixed-card.muten +4 -4
  60. package/registry/folder-picker.js +6 -2
  61. package/registry/folder-picker.muten +2 -2
  62. package/registry/heatmap.muten +5 -5
  63. package/registry/image-upload.js +1 -1
  64. package/registry/image-upload.muten +2 -2
  65. package/registry/inbox-play.muten +5 -5
  66. package/registry/indicator.muten +1 -1
  67. package/registry/info-table.muten +1 -1
  68. package/registry/input-group.muten +11 -11
  69. package/registry/input-otp.muten +2 -2
  70. package/registry/input.muten +1 -1
  71. package/registry/inspector.muten +2 -2
  72. package/registry/item.muten +2 -2
  73. package/registry/kit/board.js +36 -34
  74. package/registry/kit/collections.js +24 -24
  75. package/registry/kit/core.js +231 -118
  76. package/registry/kit/dates.js +29 -27
  77. package/registry/kit/i18n.js +172 -0
  78. package/registry/kit/menus.js +14 -13
  79. package/registry/kit/overlays.js +7 -2
  80. package/registry/kit/structure.js +66 -61
  81. package/registry/kit/table.js +19 -17
  82. package/registry/kit/values.js +36 -28
  83. package/registry/kit-skeleton.muten +1 -1
  84. package/registry/kit-widget.js +9 -4
  85. package/registry/lang-scope.js +8 -0
  86. package/registry/lang-scope.muten +5 -0
  87. package/registry/legal-doc.muten +5 -5
  88. package/registry/link-btn.muten +1 -1
  89. package/registry/list-motion.js +46 -0
  90. package/registry/list-motion.muten +4 -0
  91. package/registry/live-stack.muten +2 -2
  92. package/registry/logo-cloud.muten +2 -2
  93. package/registry/marketing.muten +7 -7
  94. package/registry/member-list.muten +2 -2
  95. package/registry/menubar.muten +3 -3
  96. package/registry/message-scroller.muten +3 -3
  97. package/registry/message.muten +9 -9
  98. package/registry/messaging.muten +1 -1
  99. package/registry/modal-behavior.js +24 -0
  100. package/registry/modal.muten +4 -4
  101. package/registry/money-field.js +1 -1
  102. package/registry/money-field.muten +2 -2
  103. package/registry/nav-button.muten +4 -4
  104. package/registry/navigation-menu.muten +4 -4
  105. package/registry/navpill.muten +6 -6
  106. package/registry/new-appointment.js +1 -1
  107. package/registry/new-appointment.muten +4 -4
  108. package/registry/number-field.js +6 -4
  109. package/registry/number-field.muten +1 -1
  110. package/registry/order-board.js +1 -1
  111. package/registry/order-board.muten +2 -2
  112. package/registry/order-play.muten +4 -4
  113. package/registry/pagination-nav.js +1 -1
  114. package/registry/pagination-nav.muten +3 -3
  115. package/registry/pagination.muten +4 -4
  116. package/registry/password-field.muten +2 -2
  117. package/registry/permission-matrix.muten +4 -4
  118. package/registry/phone-bar.muten +6 -6
  119. package/registry/phone-field.js +1 -1
  120. package/registry/phone-field.muten +1 -1
  121. package/registry/phone-shell.js +1 -1
  122. package/registry/pill.muten +2 -2
  123. package/registry/plans.muten +1 -1
  124. package/registry/popover.muten +2 -2
  125. package/registry/pricing.muten +4 -4
  126. package/registry/product-play.muten +3 -3
  127. package/registry/progress.muten +3 -3
  128. package/registry/queue.muten +1 -1
  129. package/registry/radio-group.muten +1 -1
  130. package/registry/rank-item.muten +4 -4
  131. package/registry/resizable.muten +1 -1
  132. package/registry/row-menu.js +1 -1
  133. package/registry/row-menu.muten +2 -2
  134. package/registry/save-button.muten +5 -5
  135. package/registry/scale-slider.js +1 -1
  136. package/registry/scale-slider.muten +2 -2
  137. package/registry/screen-tour.muten +1 -1
  138. package/registry/share-bar.muten +2 -2
  139. package/registry/share-button.muten +4 -4
  140. package/registry/sheet.muten +1 -1
  141. package/registry/sidebar.muten +12 -6
  142. package/registry/site-footer.muten +6 -6
  143. package/registry/sortable-behavior.js +67 -20
  144. package/registry/sortable-list.js +3 -1
  145. package/registry/sortable-list.muten +2 -2
  146. package/registry/sortable.muten +5 -4
  147. package/registry/spinner.muten +2 -2
  148. package/registry/split-button.muten +2 -2
  149. package/registry/stacked-bars.js +3 -3
  150. package/registry/stacked-bars.muten +2 -2
  151. package/registry/stamp-card.muten +2 -2
  152. package/registry/stat-band.muten +3 -3
  153. package/registry/stat.muten +3 -3
  154. package/registry/status-bar.muten +2 -2
  155. package/registry/stepper.muten +4 -4
  156. package/registry/store-qr.js +1 -1
  157. package/registry/store-qr.muten +2 -2
  158. package/registry/swatch.muten +1 -1
  159. package/registry/switch.muten +8 -7
  160. package/registry/table-view.muten +3 -3
  161. package/registry/tabs-behavior.js +9 -1
  162. package/registry/tabs.muten +2 -2
  163. package/registry/tag-input.js +1 -1
  164. package/registry/tag-input.muten +2 -2
  165. package/registry/text.muten +2 -2
  166. package/registry/time-field.js +1 -1
  167. package/registry/time-field.muten +3 -3
  168. package/registry/timeline.muten +4 -4
  169. package/registry/toaster.muten +2 -2
  170. package/registry/toggle-group.muten +1 -1
  171. package/registry/toggle.muten +3 -3
  172. package/registry/toolbar.muten +4 -4
  173. package/registry/tooltip.muten +2 -2
  174. package/registry/tree-view.js +38 -25
  175. package/registry/tree-view.muten +1 -1
  176. package/registry/veil.muten +1 -1
  177. package/registry/virtual-list.js +1 -1
  178. package/registry/virtual-list.muten +2 -2
  179. package/registry/virtual-window.js +4 -4
  180. package/registry/virtual-window.muten +2 -2
  181. package/registry/wordline.muten +1 -1
  182. package/registry/zoom-control.muten +1 -1
  183. package/registry.json +22 -2
@@ -3,7 +3,7 @@
3
3
  // share Storio's share sheet inside the adaptive modal (link or product card, Copiar, brand targets, QR)
4
4
  // appShell Sidebar (store switcher, groups, counts, person) + page header + detail Sheet + Command (⌘K)
5
5
  // booking week strip + hours in one piece (dot = something left, closed days, a full day's empty state)
6
- // wizard «Nueva cita» in 4 steps inside the adaptive modal (Stepper, choice lists, day tiles, summary)
6
+ // wizard “New appointment” in 4 steps inside the adaptive modal (Stepper, choice lists, day tiles, summary)
7
7
 
8
8
  const list = (v) => (Array.isArray(v) ? v : []);
9
9
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
@@ -11,17 +11,17 @@ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<
11
11
  // ── phone shell ──
12
12
  // i: tabs [{label, icon, badge}], rows [{tab, section, title, subtitle, value}]; on.select(label)
13
13
  export function phoneShell(root, i, core, on) {
14
- const { $, $$ } = core;
14
+ const { $, $$ } = core, L = core.langOf(root), T = (k, ...a) => core.t(L, k, ...a);
15
15
  const tabs = list(i.tabs), rows = list(i.rows);
16
16
  root.innerHTML = `<div class="cx-device cx-ph-device" data-kind="phone"><div class="cx-device-screen"><div class="cx-ph-screen">
17
17
  <div class="cx-ph-status"><span>9:41</span><span class="cx-ph-island"></span><span>100 %</span></div>
18
18
  <header class="cx-ph-head"><div class="cx-ph-bar">
19
- <button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon group/button" aria-label="Atrás" title="Atrás"><svg><use href="#left"/></svg></button>
19
+ <button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon group/button" aria-label="${T('Back')}" title="${T('Back')}"><svg><use href="#left"/></svg></button>
20
20
  <b class="cx-ph-small"></b>
21
- <div class="cx-hstack" style="gap:2px;width:auto;flex-wrap:nowrap"><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon group/button" aria-label="Buscar" title="Buscar"><svg><use href="#search"/></svg></button><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon group/button" aria-label="Más opciones" title="Más opciones"><svg><use href="#more"/></svg></button></div>
21
+ <div class="cx-hstack" style="gap:2px;width:auto;flex-wrap:nowrap"><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon group/button" aria-label="${T('Search')}" title="${T('Search')}"><svg><use href="#search"/></svg></button><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon group/button" aria-label="${T('More options')}" title="${T('More options')}"><svg><use href="#more"/></svg></button></div>
22
22
  </div></header>
23
23
  <div class="cx-ph-body cx-scroll"></div>
24
- <nav class="cx-ph-nav" aria-label="Principal">${tabs.map((t, k) => `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ph-tab" data-tab="${k}" ${k === 0 ? 'aria-current="page"' : ''}>${t.badge ? `<span class="cx-ph-ico"><svg><use href="#${t.icon}"/></svg><i class="cx-ph-badge">${esc(t.badge)}</i></span>` : `<svg><use href="#${t.icon}"/></svg>`}<span>${esc(t.label)}</span></button>`).join('')}</nav>
24
+ <nav class="cx-ph-nav" aria-label="${T('Main')}">${tabs.map((t, k) => `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ph-tab" data-tab="${k}" ${k === 0 ? 'aria-current="page"' : ''}>${t.badge ? `<span class="cx-ph-ico"><svg><use href="#${t.icon}"/></svg><i class="cx-ph-badge">${esc(t.badge)}</i></span>` : `<svg><use href="#${t.icon}"/></svg>`}<span>${esc(t.label)}</span></button>`).join('')}</nav>
25
25
  </div></div></div>`;
26
26
  const head = $('.cx-ph-head', root), phBody = $('.cx-ph-body', root);
27
27
  const screen = (label) => {
@@ -53,8 +53,8 @@ const enc = encodeURIComponent;
53
53
  const targetsFor = (url, text) => [
54
54
  { name: 'QR', icon: 'qrc', plain: true, qr: true },
55
55
  { name: 'WhatsApp', icon: 'b-whatsapp', color: '#25D366', href: `https://wa.me/?text=${enc(text + ' ' + url)}` },
56
- { name: 'Instagram', icon: 'b-instagram', color: '#E4405F', copy: 'Enlace copiado. Pégalo en tu bio o en una historia de Instagram.' },
57
- { name: 'TikTok', icon: 'b-tiktok', color: '#000000', copy: 'Enlace copiado. Pégalo en tu bio de TikTok.' },
56
+ { name: 'Instagram', icon: 'b-instagram', color: '#E4405F', copy: 'Link copied. Paste it in your bio or in an Instagram story.' },
57
+ { name: 'TikTok', icon: 'b-tiktok', color: '#000000', copy: 'Link copied. Paste it in your TikTok bio.' },
58
58
  { name: 'Facebook', icon: 'b-facebook', color: '#0866FF', href: `https://www.facebook.com/sharer/sharer.php?u=${enc(url)}` },
59
59
  { name: 'X', icon: 'b-x', color: '#000000', href: `https://x.com/intent/post?text=${enc(text)}&url=${enc(url)}` },
60
60
  { name: 'Telegram', icon: 'b-telegram', color: '#26A5E4', href: `https://t.me/share/url?url=${enc(url)}&text=${enc(text)}` },
@@ -68,17 +68,17 @@ const dragScroll = (row) => {
68
68
  addEventListener('pointerup', () => { if (!d) return; const moved = d.moved; d = null; row.classList.remove('dragging'); if (moved) row.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }, { capture: true, once: true }); });
69
69
  };
70
70
  export function share(root, i, core) {
71
- const { ui, $, openModal, CONTENT, body, foot, mdl, easeHeight, money0 } = core;
71
+ const { ui, $, openModal, CONTENT, body, foot, mdl, easeHeight, money0 } = core, L = core.langOf(root), T = (k, ...a) => core.t(L, k, ...a);
72
72
  const product = i.kind === 'product' && i.product ? i.product : null;
73
73
  const url = /^https?:\/\//.test(i.url || '') ? i.url : `https://${i.url || ''}`;
74
74
  const shown = url.replace(/^https?:\/\//, '');
75
- const text = i.text || (product ? `${product.name} en ${i.name}` : `Reserva en ${i.name}`);
76
- const title = product ? 'Compartir producto' : 'Compartir enlace';
75
+ const text = i.text || (product ? T('{0} at {1}', product.name, i.name) : T('Book at {0}', i.name));
76
+ const title = product ? T('Share product') : T('Share link');
77
77
  root.innerHTML = ui.button({ label: `${i.icon === 'none' ? '' : ui.icon('share')}${esc(i.label || 'Compartir')}`, variant: i.variant || 'default', size: 'default' });
78
78
  const msg = (t) => { const m = $('.cx-sh-msg', body); if (m) m.textContent = t; };
79
79
  const copy = (ok) => {
80
- const fb = () => { const u = $('.cx-sh-url', body); if (u) getSelection().selectAllChildren(u); msg('Selecciona el enlace y cópialo.'); };
81
- try { navigator.clipboard.writeText(url).then(() => { msg(ok); const b = $('.cx-sh-copybtn', body); if (b) { b.textContent = 'Copiado'; setTimeout(() => { b.textContent = 'Copiar'; }, 1600); } }, fb); } catch { fb(); }
80
+ const fb = () => { const u = $('.cx-sh-url', body); if (u) getSelection().selectAllChildren(u); msg(T('Select the link and copy it.')); };
81
+ try { navigator.clipboard.writeText(url).then(() => { msg(ok); const b = $('.cx-sh-copybtn', body); if (b) { b.innerHTML = `<span class="cx-swap-in">${T('Copied')}</span>`; setTimeout(() => { b.innerHTML = `<span class="cx-swap-in">${T('Copy')}</span>`; }, 1600); } }, fb); } catch { fb(); }
82
82
  };
83
83
  const main = () => {
84
84
  $('#mdl-title').textContent = title;
@@ -86,18 +86,18 @@ export function share(root, i, core) {
86
86
  ? `<div class="cx-sh-prod" style="--g:${esc(product.image || 'var(--muted)')}"><span class="cx-sh-tag">${money0(Number(product.price) || 0)}</span><div class="cx-sh-pinfo"><b>${esc(product.name)}</b><span>${esc(product.meta)}</span></div></div>`
87
87
  : `<div class="cx-sh-card"><span class="cx-sh-av">${esc(i.initials)}</span><b>${esc(i.name)}</b><span>${esc(shown)}</span></div>`;
88
88
  body.innerHTML = `<div class="cx-sh">${card}
89
- <div class="cx-sh-copy"><span class="cx-sh-url cx-num">${esc(shown)}</span><button class="cn-button cn-button-variant-default cn-button-size-sm cx-sh-copybtn" data-sh-copy>Copiar</button></div>
90
- <span class="cx-sh-lbl">Compartir en</span>
89
+ <div class="cx-sh-copy"><span class="cx-sh-url cx-num">${esc(shown)}</span><button class="cn-button cn-button-variant-default cn-button-size-sm cx-sh-copybtn" data-sh-copy>${T('Copy')}</button></div>
90
+ <span class="cx-sh-lbl">${T('Share on')}</span>
91
91
  <div class="cx-sh-targets">${targetsFor(url, text).map((t, k) => { const ico = `<span class="cx-sh-ic${t.plain ? ' plain' : ''}" style="${t.color ? `--brand:${t.color}` : ''}"><svg><use href="#${t.icon}"/></svg></span><span class="cx-sh-l">${t.name}</span>`; return t.href ? `<a class="cn-button cn-button-variant-ghost cx-size-tile-lg cx-sh-tg" href="${t.href}" target="_blank" rel="noopener" data-t="${k}">${ico}</a>` : `<button class="cn-button cn-button-variant-ghost cx-size-tile-lg cx-sh-tg" data-t="${k}">${ico}</button>`; }).join('')}</div>
92
92
  <p class="cx-note cx-sh-msg" aria-live="polite" style="text-align:left"></p></div>`;
93
93
  const row = $('.cx-sh-targets', body); dragScroll(row);
94
94
  const edge = () => { row.toggleAttribute('data-end', row.scrollLeft >= row.scrollWidth - row.clientWidth - 2); row.toggleAttribute('data-scrolled', row.scrollLeft > 2); };
95
95
  row.addEventListener('scroll', edge, { passive: true }); requestAnimationFrame(edge);
96
- row.addEventListener('click', (e) => { const t = e.target.closest('[data-t]'); if (!t) return; const x = targetsFor(url, text)[+t.dataset.t]; if (x.copy) copy(x.copy); if (x.qr) easeHeight(mdl, qr); });
96
+ row.addEventListener('click', (e) => { const t = e.target.closest('[data-t]'); if (!t) return; const x = targetsFor(url, text)[+t.dataset.t]; if (x.copy) copy(T(x.copy)); if (x.qr) easeHeight(mdl, qr); });
97
97
  };
98
98
  const qr = () => {
99
- $('#mdl-title').textContent = 'Código QR';
100
- body.innerHTML = `<div class="cx-sh cx-sh-qrview"><div class="cx-qr" style="width:15rem;max-width:72%;aspect-ratio:1;height:auto" role="img" aria-label="Código QR de ${esc(shown)}"><div class="cx-qr-mark"><span>${esc(i.initials)}</span></div></div><span class="cx-sh-qrurl cx-num">${esc(shown)}</span><button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button" data-sh-back><svg><use href="#left"/></svg>Volver</button></div>`;
99
+ $('#mdl-title').textContent = T('QR code');
100
+ body.innerHTML = `<div class="cx-sh cx-sh-qrview"><div class="cx-qr" style="width:15rem;max-width:72%;aspect-ratio:1;height:auto" role="img" aria-label="${esc(T('QR code of {0}', shown))}"><div class="cx-qr-mark"><span>${esc(i.initials)}</span></div></div><span class="cx-sh-qrurl cx-num">${esc(shown)}</span><button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button" data-sh-back><svg><use href="#left"/></svg>${T('Back')}</button></div>`;
101
101
  import('@muten/shadcn/registry/kit/qrcode.js').then(({ default: qrcode }) => {
102
102
  const q = qrcode(0, 'H'); q.addData(url); q.make(); const n = q.getModuleCount(); let d = '';
103
103
  for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (q.isDark(r, c)) d += `M${c} ${r}h1v1h-1z`;
@@ -108,13 +108,13 @@ export function share(root, i, core) {
108
108
  const kind = `share-${Math.random().toString(36).slice(2, 8)}`;
109
109
  CONTENT[kind] = () => {
110
110
  $('#mdl-desc').textContent = ''; main();
111
- foot.innerHTML = navigator.share ? ui.button({ label: `${ui.icon('share')}Compartir en otra app`, variant: 'default', size: 'default', cls: 'cx-go', attrs: 'data-sh-native' }) : '';
111
+ foot.innerHTML = navigator.share ? ui.button({ label: `${ui.icon('share')}${T('Share in another app')}`, variant: 'default', size: 'default', cls: 'cx-go', attrs: 'data-sh-native' }) : '';
112
112
  body.dataset.share = kind;
113
113
  };
114
114
  root.addEventListener('click', (e) => { if (e.target.closest('button')) openModal(kind, 'auto'); });
115
115
  document.addEventListener('click', async (e) => {
116
116
  if (body.dataset.share !== kind || !mdl.contains(e.target)) return;
117
- if (e.target.closest('[data-sh-copy]')) copy('Enlace copiado.');
117
+ if (e.target.closest('[data-sh-copy]')) copy(T('Link copied.'));
118
118
  if (e.target.closest('[data-sh-back]')) easeHeight(mdl, main);
119
119
  if (e.target.closest('[data-sh-native]')) { try { await navigator.share({ title: text, url }); } catch {} }
120
120
  });
@@ -126,7 +126,7 @@ export function share(root, i, core) {
126
126
  // badge, tone, amount, sheet [{icon, title, description, badge, tone, amount, photos}], timeline [{label, when}]}],
127
127
  // commands [{icon, label, page}]; on.navigate(page) · on.open(id) · on.action(label)
128
128
  export function appShell(root, i, core, on) {
129
- const { ui, $, $$, openModal, closeModal, CONTENT, body, foot, mdl, money0, fold } = core;
129
+ const { ui, $, $$, openModal, closeModal, CONTENT, body, foot, mdl, money0, fold } = core, L = core.langOf(root), T = (k, ...a) => core.t(L, k, ...a);
130
130
  const nav = list(i.nav), pages = list(i.pages), rows = list(i.rows), commands = list(i.commands);
131
131
  const groups = [...new Set(nav.map((n) => n.group))].map((g) => [g, nav.filter((n) => n.group === g)]);
132
132
  root.classList.add('cx-app'); const app = root;
@@ -136,7 +136,7 @@ export function appShell(root, i, core, on) {
136
136
  const pageBody = () => {
137
137
  const mine = rows.filter((r) => r.page === appPage);
138
138
  if (mine.length) return `<div data-slot="item-group" class="cn-item-group cx-app-list" role="list">${mine.map((r) => `<button class="cn-item cn-item-variant-outline cn-item-size-sm cx-app-row" ${r.sheet ? `data-open-row="${esc(r.id)}"` : ''}>${ui.itemMedia(ui.avatar(r.title), 'default')}<div class="cn-item-content"><div class="cn-item-title">${esc(r.title)}</div><p class="cn-item-description">${esc(r.description)}</p></div><div class="cn-item-actions">${aside(r)}</div></button>`).join('')}</div>`;
139
- return `<div data-slot="empty" class="cn-empty" style="padding:48px 12px"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon(nav.find((n) => n.label === appPage)?.icon || 'store')}</div><div class="cn-empty-title">${esc(appPage)}</div><div class="cn-empty-description">${esc(i.empty || 'Todavía no hay nada aquí.')}</div></div></div>`;
139
+ return `<div data-slot="empty" class="cn-empty" style="padding:48px 12px"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon(nav.find((n) => n.label === appPage)?.icon || 'store')}</div><div class="cn-empty-title">${esc(appPage)}</div><div class="cn-empty-description">${esc(i.empty || T('Nothing here yet.'))}</div></div></div>`;
140
140
  };
141
141
  const twoLine = (a, b) => `<span class="cx-sb-two">${ui.text(esc(a), 'heading')}${ui.text(esc(b), 'caption')}</span>`;
142
142
  const render = () => {
@@ -152,18 +152,18 @@ export function appShell(root, i, core, on) {
152
152
  <div class="cn-sheet-overlay cx-app-scrim" data-app-scrim hidden></div>
153
153
  <main data-slot="sidebar-inset" class="cn-sidebar-inset cx-app-main">
154
154
  <header class="cx-app-top">
155
- ${ui.button({ icon: 'menu-lines', variant: 'ghost', size: 'icon-sm', attrs: 'data-sb-toggle aria-label="Mostrar u ocultar el menú" title="Menú (Ctrl B)"' })}
155
+ ${ui.button({ icon: 'menu-lines', variant: 'ghost', size: 'icon-sm', attrs: `data-sb-toggle aria-label="${T('Show or hide the menu')}" title="${T('Menu')} (Ctrl B)"` })}
156
156
  <div data-slot="separator" class="cn-separator cn-separator-vertical cx-app-vsep"></div>
157
- <nav aria-label="Migas"><ol data-slot="breadcrumb-list" class="cn-breadcrumb-list"><li class="cn-breadcrumb-item"><a class="cn-breadcrumb-link" href="#" data-home>${esc(i.brand?.short || i.brand?.name)}</a></li><li class="cn-breadcrumb-separator" role="presentation">${ui.icon('right')}</li><li class="cn-breadcrumb-item"><span class="cn-breadcrumb-page" aria-current="page">${esc(meta.title)}</span></li></ol></nav>
157
+ <nav aria-label="${T('Breadcrumb')}"><ol data-slot="breadcrumb-list" class="cn-breadcrumb-list"><li class="cn-breadcrumb-item"><a class="cn-breadcrumb-link" href="#" data-home>${esc(i.brand?.short || i.brand?.name)}</a></li><li class="cn-breadcrumb-separator" role="presentation">${ui.icon('right')}</li><li class="cn-breadcrumb-item"><span class="cn-breadcrumb-page" aria-current="page">${esc(meta.title)}</span></li></ol></nav>
158
158
  <span class="cx-grow"></span>
159
- ${ui.button({ label: `${ui.icon('search')}<span class="cx-app-search-t">Buscar…</span><kbd data-slot="kbd" class="cn-kbd">⌘K</kbd>`, size: 'sm', cls: 'cx-app-search', attrs: 'data-cmd-open aria-keyshortcuts="Control+K Meta+K"' })}
159
+ ${ui.button({ label: `${ui.icon('search')}<span class="cx-app-search-t">${T('Search…')}</span><kbd data-slot="kbd" class="cn-kbd">⌘K</kbd>`, size: 'sm', cls: 'cx-app-search', attrs: 'data-cmd-open aria-keyshortcuts="Control+K Meta+K"' })}
160
160
  </header>
161
161
  <div class="cx-app-page">
162
162
  <div class="cx-app-head"><div class="cx-vstack" style="gap:2px">${ui.text(esc(meta.title), 'title', 'h1')}${meta.description ? ui.text(esc(meta.description), 'muted', 'p') : ''}</div><div class="cx-hstack">${list(meta.actions).map((a) => ui.button({ label: `${a.icon ? ui.icon(a.icon) : ''}${esc(a.label)}`, variant: a.variant || 'outline', size: 'sm', attrs: `data-app-act="${esc(a.label)}"` })).join('')}</div></div>
163
163
  ${pageBody()}
164
164
  </div>
165
165
  <div class="cn-sheet-overlay cx-app-scrim" data-sheet-scrim hidden></div>
166
- <aside data-slot="sheet-content" data-side="right" class="cn-sheet-content cx-app-sheet" role="dialog" aria-modal="false" aria-label="Detalle" hidden></aside>
166
+ <aside data-slot="sheet-content" data-side="right" class="cn-sheet-content cx-app-sheet" role="dialog" aria-modal="false" aria-label="${T('Detail')}" hidden></aside>
167
167
  </main>`;
168
168
  syncTips();
169
169
  };
@@ -174,8 +174,8 @@ export function appShell(root, i, core, on) {
174
174
  const photos = (x) => (list(x.photos).length ? `<div class="cx-hstack">${list(x.photos).map((bg) => ui.avatar('', { shape: 'square', size: 'lg', bg })).join('')}</div>` : '');
175
175
  const items = list(r.sheet).map((x) => ui.item({ media: ui.itemMedia(ui.icon(x.icon || 'info')), title: esc(x.title), description: esc(x.description || ''), actions: aside(x), footer: photos(x) }));
176
176
  const tl = list(r.timeline);
177
- if (tl.length) items.push(ui.item({ media: ui.itemMedia(ui.icon('clock')), title: 'Historial', footer: `<ol class="cx-timeline">${tl.map((t, k) => `<li ${k === tl.length - 1 ? 'data-now' : ''}>${ui.text(esc(t.label), k === tl.length - 1 ? 'label' : 'body')}${ui.text(esc(t.when), 'caption')}</li>`).join('')}</ol>` }));
178
- s.innerHTML = `<div data-slot="sheet-header" class="cn-sheet-header"><div class="cx-hstack" style="justify-content:space-between;flex-wrap:nowrap"><div class="cx-vstack" style="gap:2px">${ui.text(esc(r.sheetTitle || r.title), 'title', 'h2')}${ui.text(esc(r.sheetDescription || r.description), 'muted', 'p')}</div>${ui.button({ icon: 'x', variant: 'ghost', size: 'icon-sm', attrs: 'data-sheet-close aria-label="Cerrar" title="Cerrar"' })}</div></div>
177
+ if (tl.length) items.push(ui.item({ media: ui.itemMedia(ui.icon('clock')), title: T('History'), footer: `<ol class="cx-timeline">${tl.map((t, k) => `<li ${k === tl.length - 1 ? 'data-now' : ''}>${ui.text(esc(t.label), k === tl.length - 1 ? 'label' : 'body')}${ui.text(esc(t.when), 'caption')}</li>`).join('')}</ol>` }));
178
+ s.innerHTML = `<div data-slot="sheet-header" class="cn-sheet-header"><div class="cx-hstack" style="justify-content:space-between;flex-wrap:nowrap"><div class="cx-vstack" style="gap:2px">${ui.text(esc(r.sheetTitle || r.title), 'title', 'h2')}${ui.text(esc(r.sheetDescription || r.description), 'muted', 'p')}</div>${ui.button({ icon: 'x', variant: 'ghost', size: 'icon-sm', attrs: `data-sheet-close aria-label="${T('Close')}" title="${T('Close')}"` })}</div></div>
179
179
  <div class="cx-app-sheet-body"><div data-slot="item-group" class="cn-item-group">${items.join(ui.itemSeparator())}</div></div>
180
180
  ${r.primary || r.secondary ? `<div data-slot="sheet-footer" class="cn-sheet-footer">${r.secondary ? ui.button({ label: esc(r.secondary), size: 'default', cls: 'cx-exit', attrs: `data-app-act="${esc(r.secondary)}"` }) : ''}${r.primary ? ui.button({ label: `${esc(r.primary)}${ui.icon('right')}`, variant: 'default', size: 'default', cls: 'cx-go', attrs: `data-app-act="${esc(r.primary)}"` }) : ''}</div>` : ''}`;
181
181
  s.hidden = false; sheetScrim().hidden = false;
@@ -190,7 +190,8 @@ export function appShell(root, i, core, on) {
190
190
  if (app.dataset.collapsed) delete app.dataset.collapsed; else app.dataset.collapsed = '1';
191
191
  const g = $('.cx-app-sb', app); g.dataset.state = app.dataset.collapsed ? 'collapsed' : 'expanded'; g.dataset.collapsible = app.dataset.collapsed ? 'icon' : ''; syncTips();
192
192
  };
193
- const go = (page) => { appPage = page; const col = app.dataset.collapsed; render(); if (col) app.dataset.collapsed = col; app.removeAttribute('data-sb-open'); on.navigate?.(page); };
193
+ // a section opened from the menu comes in with a short fade (kit.css .cx-app-page[data-in]); the first one is just there
194
+ const go = (page) => { appPage = page; const col = app.dataset.collapsed; render(); if (col) app.dataset.collapsed = col; app.removeAttribute('data-sb-open'); app.querySelector('.cx-app-page')?.setAttribute('data-in', ''); on.navigate?.(page); };
194
195
  app.addEventListener('click', (e) => {
195
196
  if (e.target.closest('[data-sb-toggle]')) return toggleSidebar();
196
197
  if (e.target.closest('[data-app-scrim]')) return toggleSidebar();
@@ -211,7 +212,7 @@ export function appShell(root, i, core, on) {
211
212
 
212
213
  // Command (⌘K): groups of results filtered as you type, arrows + Enter
213
214
  const CMD = () => [
214
- ['Acciones', commands.map((c) => [c.icon || 'right', c.label, () => (c.page ? go(c.page) : on.action?.(c.label))])],
215
+ [T('Actions'), commands.map((c) => [c.icon || 'right', c.label, () => (c.page ? go(c.page) : on.action?.(c.label))])],
215
216
  ...[...new Set(rows.map((r) => r.page))].map((p) => [p, rows.filter((r) => r.page === p).map((r) => [nav.find((n) => n.label === p)?.icon || 'user', r.command || r.title, () => { go(p); if (r.sheet) { app.scrollIntoView({ block: 'center', behavior: 'smooth' }); openRow(r.id); } }])]),
216
217
  ];
217
218
  let cmdItems = [], cmdHl = 0;
@@ -219,15 +220,15 @@ export function appShell(root, i, core, on) {
219
220
  const paint = (q = '') => {
220
221
  const t = fold(q.trim()); cmdItems = [];
221
222
  const html = CMD().map(([g, items]) => { const hits = items.filter(([, l]) => !t || fold(l).includes(t)); if (!hits.length) return ''; return `<div data-slot="command-group" class="cn-command-group" role="group" aria-label="${esc(g)}"><div class="cx-cmd-label">${ui.text(esc(g), 'caption')}</div>${hits.map((h) => { cmdItems.push(h); const k = cmdItems.length - 1; return `<div data-slot="command-item" class="cn-command-item" role="option" id="cmd-${k}" data-ci="${k}" ${k === cmdHl ? 'data-selected="true" aria-selected="true"' : ''}>${ui.icon(h[0])}${ui.text(esc(h[1]))}</div>`; }).join('')}</div>`; }).join('');
222
- $('.cx-cmd-list', body).innerHTML = html || `<div data-slot="command-empty" class="cn-command-empty">Nada con «${esc(q)}»</div>`;
223
+ $('.cx-cmd-list', body).innerHTML = html || `<div data-slot="command-empty" class="cn-command-empty">${T('Nothing with “{0}”', esc(q))}</div>`;
223
224
  $('.cx-cmd-in', body).setAttribute('aria-activedescendant', cmdItems.length ? `cmd-${cmdHl}` : '');
224
225
  $(`#cmd-${cmdHl}`, body)?.scrollIntoView({ block: 'nearest' });
225
226
  };
226
227
  CONTENT[kind] = () => {
227
- $('#mdl-title').textContent = 'Buscar'; $('#mdl-desc').textContent = '';
228
+ $('#mdl-title').textContent = T('Search'); $('#mdl-desc').textContent = '';
228
229
  body.dataset.cmd = kind;
229
- body.innerHTML = `<div data-slot="command" class="cn-command cx-cmd"><div data-slot="command-input-wrapper" class="cn-command-input-wrapper"><div data-slot="input-group" class="cn-input-group cn-command-input-group"><input data-slot="input-group-control" class="cn-command-input cn-input-group-input cx-cmd-in" placeholder="${esc(i.placeholder || 'Busca o salta a…')}" role="combobox" aria-expanded="true" aria-controls="cmd-list" autocomplete="off"><div class="cn-input-group-addon cn-input-group-addon-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 cx-cmd-list" id="cmd-list" role="listbox"></div></div>`;
230
- foot.innerHTML = `<span class="cx-hstack cx-cmd-help">${ui.text('<kbd class="cn-kbd">↑</kbd><kbd class="cn-kbd">↓</kbd> moverte', 'caption')}${ui.text('<kbd class="cn-kbd">Enter</kbd> abrir', 'caption')}${ui.text('<kbd class="cn-kbd">Esc</kbd> cerrar', 'caption')}</span>`;
230
+ body.innerHTML = `<div data-slot="command" class="cn-command cx-cmd"><div data-slot="command-input-wrapper" class="cn-command-input-wrapper"><div data-slot="input-group" class="cn-input-group cn-command-input-group"><input data-slot="input-group-control" class="cn-command-input cn-input-group-input cx-cmd-in" placeholder="${esc(i.placeholder || T('Search or jump to…'))}" role="combobox" aria-expanded="true" aria-controls="cmd-list" autocomplete="off"><div class="cn-input-group-addon cn-input-group-addon-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 cx-cmd-list" id="cmd-list" role="listbox"></div></div>`;
231
+ foot.innerHTML = `<span class="cx-hstack cx-cmd-help">${ui.text(`<kbd class="cn-kbd">↑</kbd><kbd class="cn-kbd">↓</kbd> ${T('move')}`, 'caption')}${ui.text(`<kbd class="cn-kbd">Enter</kbd> ${T('open')}`, 'caption')}${ui.text(`<kbd class="cn-kbd">Esc</kbd> ${T('close')}`, 'caption')}</span>`;
231
232
  cmdHl = 0; paint();
232
233
  setTimeout(() => $('.cx-cmd-in', body)?.focus(), 30);
233
234
  };
@@ -251,9 +252,11 @@ export function appShell(root, i, core, on) {
251
252
  // ── booking picker ──
252
253
  // i: week [{days [0..6], hours ["09:00", …]}] (what the shop offers each weekday), taken [{day, hours}] (day = days
253
254
  // from today or "yyyy-mm-dd"; hours empty = the whole day is booked), wait (ms the server takes: shows the
254
- // skeleton), cta; on.select("yyyy-mm-dd HH:MM") on «Continuar»
255
+ // skeleton), cta; on.select("yyyy-mm-dd HH:MM") on “Continue”
255
256
  export function booking(root, i, core, on) {
256
- const { $, $$, today, addDays, DAY, MON, DAYLONG } = core;
257
+ const { $, $$, today, addDays } = core, L = core.langOf(root), T = (k, ...a) => core.t(L, k, ...a);
258
+ const W = core.kit.words(L), DAY = W.dayS, MON = W.mon, DAYLONG = W.dayL, es = L === 'es';
259
+ const longDay = (d) => (es ? `${DAYLONG[d.getDay()]} ${d.getDate()} de ${MON[d.getMonth()]}` : `${DAYLONG[d.getDay()]}, ${MON[d.getMonth()]} ${d.getDate()}`);
257
260
  const key = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
258
261
  const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
259
262
  const dayOf = (v) => (/^-?\d+$/.test(String(v)) ? addDays(today, Number(v)) : new Date(String(v) + 'T00:00:00'));
@@ -269,13 +272,13 @@ export function booking(root, i, core, on) {
269
272
  };
270
273
  root.innerHTML = `<div class="cx-book">
271
274
  <div class="cx-book-head">
272
- <button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-icon-sm group/button" data-wk="-1" aria-label="Semana anterior" title="Semana anterior"><svg><use href="#left"/></svg></button>
275
+ <button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-icon-sm group/button" data-wk="-1" aria-label="${T('Previous week')}" title="${T('Previous week')}"><svg><use href="#left"/></svg></button>
273
276
  <b class="cx-wk-label" aria-live="polite"></b>
274
- <button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-icon-sm group/button" data-wk="1" aria-label="Semana siguiente" title="Semana siguiente"><svg><use href="#right"/></svg></button>
277
+ <button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-icon-sm group/button" data-wk="1" aria-label="${T('Next week')}" title="${T('Next week')}"><svg><use href="#right"/></svg></button>
275
278
  </div>
276
- <div class="cx-week" role="group" aria-label="Día"></div>
279
+ <div class="cx-week" role="group" aria-label="${T('Day')}"></div>
277
280
  <div class="cx-hours" aria-live="polite"></div>
278
- <div class="cx-book-sum"><span class="cx-book-say">Elige un día y una hora</span><button data-slot="button" class="cn-button cn-button-variant-default cn-button-size-default group/button cx-book-go" disabled>${esc(i.cta || 'Continuar')}</button></div>
281
+ <div class="cx-book-sum"><span class="cx-book-say">${T('Pick a day and a time')}</span><button data-slot="button" class="cn-button cn-button-variant-default cn-button-size-default group/button cx-book-go" disabled>${esc(T(i.cta || 'Continue'))}</button></div>
279
282
  </div>`;
280
283
  let weekStart = addDays(today, -((today.getDay() + 6) % 7)); // monday
281
284
  let picked = { day: null, hour: null }, loadTimer = 0;
@@ -283,12 +286,12 @@ export function booking(root, i, core, on) {
283
286
  const firstOpen = (from) => { for (let k = 0; k < 60; k++) { const d = addDays(from, k); const s = slotsFor(d); if (s && s.length) return d; } return null; };
284
287
  const sum = () => {
285
288
  const d = picked.day;
286
- $('.cx-book-say', root).innerHTML = d ? `<b>${DAYLONG[d.getDay()]} ${d.getDate()} de ${MON[d.getMonth()]}</b>${picked.hour ? ` · <b>${picked.hour}</b>` : ' · elige una hora'}` : 'Elige un día y una hora';
289
+ $('.cx-book-say', root).innerHTML = d ? `<b>${longDay(d)}</b>${picked.hour ? ` · <b>${picked.hour}</b>` : ` · ${T('pick a time')}`}` : T('Pick a day and a time');
287
290
  go.disabled = !(d && picked.hour);
288
291
  };
289
292
  const paintWeek = () => {
290
293
  const end = addDays(weekStart, 6);
291
- $('.cx-wk-label', root).textContent = weekStart.getMonth() === end.getMonth() ? `${weekStart.getDate()} al ${end.getDate()} de ${MON[end.getMonth()]}` : `${weekStart.getDate()} de ${MON[weekStart.getMonth()]} al ${end.getDate()} de ${MON[end.getMonth()]}`;
294
+ $('.cx-wk-label', root).textContent = es ? (weekStart.getMonth() === end.getMonth() ? `${weekStart.getDate()} al ${end.getDate()} de ${MON[end.getMonth()]}` : `${weekStart.getDate()} de ${MON[weekStart.getMonth()]} al ${end.getDate()} de ${MON[end.getMonth()]}`) : (weekStart.getMonth() === end.getMonth() ? `${MON[end.getMonth()]} ${weekStart.getDate()}–${end.getDate()}` : `${MON[weekStart.getMonth()]} ${weekStart.getDate()} – ${MON[end.getMonth()]} ${end.getDate()}`);
292
295
  $('[data-wk="-1"]', root).disabled = weekStart <= today;
293
296
  weekEl.innerHTML = '';
294
297
  for (let k = 0; k < 7; k++) {
@@ -297,9 +300,9 @@ export function booking(root, i, core, on) {
297
300
  b.className = 'cn-button cn-button-variant-outline cx-size-tile cx-day'; b.disabled = s === null;
298
301
  if (s && !s.length) b.dataset.full = '';
299
302
  b.setAttribute('aria-pressed', !!picked.day && key(d) === key(picked.day));
300
- // the name starts with what the tile shows («vie 9»), so a voice user can say it; the long day and the state follow
301
- b.setAttribute('aria-label', `${key(d) === key(today) ? 'hoy' : DAY[d.getDay()]} ${d.getDate()}, ${DAYLONG[d.getDay()]}${s === null ? ', cerrado' : s.length ? `, ${s.length} horarios` : ', sin horarios'}`);
302
- b.innerHTML = `<small>${key(d) === key(today) ? 'hoy' : DAY[d.getDay()]}</small> <b>${d.getDate()}</b><i></i>`;
303
+ // the name starts with what the tile shows (“vie 9”), so a voice user can say it; the long day and the state follow
304
+ b.setAttribute('aria-label', `${key(d) === key(today) ? T('today') : DAY[d.getDay()]} ${d.getDate()}, ${DAYLONG[d.getDay()]}${s === null ? `, ${T('closed')}` : s.length ? `, ${T('{0} times', s.length)}` : `, ${T('no times')}`}`);
305
+ b.innerHTML = `<small>${key(d) === key(today) ? T('today') : DAY[d.getDay()]}</small> <b>${d.getDate()}</b><i></i>`;
303
306
  b.onclick = () => pickDay(d);
304
307
  weekEl.appendChild(b);
305
308
  }
@@ -308,17 +311,17 @@ export function booking(root, i, core, on) {
308
311
  const s = slotsFor(d) || [];
309
312
  if (!s.length) {
310
313
  const next = firstOpen(addDays(d, 1));
311
- hours.innerHTML = `<div class="cx-hours-empty"><svg><use href="#caloff"/></svg><span>No quedan horarios este día.</span>${next ? `<button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button" data-jump>Ver el ${DAYLONG[next.getDay()].toLowerCase()} ${next.getDate()}</button>` : ''}</div>`;
314
+ hours.innerHTML = `<div class="cx-hours-empty"><svg><use href="#caloff"/></svg><span>${T('No times left this day.')}</span>${next ? `<button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button" data-jump>${T('See {0} {1}', es ? `el ${DAYLONG[next.getDay()].toLowerCase()}` : DAYLONG[next.getDay()], next.getDate())}</button>` : ''}</div>`;
312
315
  if (next) $('[data-jump]', hours).onclick = () => { if (next > addDays(weekStart, 6)) weekStart = addDays(next, -((next.getDay() + 6) % 7)); pickDay(next); };
313
316
  return;
314
317
  }
315
318
  const am = s.filter((h) => h < '13:00'), pm = s.filter((h) => h >= '13:00');
316
319
  // the part of the day is a label, not a heading: the picker sits inside pages whose outline it must not break
317
320
  const grid = (label, hs) => hs.length ? `<p class="cx-hours-h">${label}</p><div class="cx-hour-grid">${hs.map((h) => `<button class="cn-button cn-button-variant-outline cn-button-size-sm cx-hour" aria-pressed="${h === picked.hour}">${h}</button>`).join('')}</div>` : '';
318
- hours.innerHTML = `<div class="cx-panel" style="display:flex;flex-direction:column;gap:10px">${grid('Mañana', am)}${grid('Tarde', pm)}</div>`;
321
+ hours.innerHTML = `<div class="cx-panel" style="display:flex;flex-direction:column;gap:10px">${grid(T('Morning'), am)}${grid(T('Afternoon'), pm)}</div>`;
319
322
  $$('.cx-hour', hours).forEach((b) => b.onclick = () => { picked.hour = b.textContent; $$('.cx-hour', hours).forEach((x) => x.setAttribute('aria-pressed', x === b)); sum(); });
320
323
  };
321
- const skeleton = () => { hours.innerHTML = `<h4 style="visibility:hidden">Mañana</h4><div class="cx-hour-grid">${'<div data-slot="skeleton" class="cn-skeleton cx-hour-skel"></div>'.repeat(8)}</div>`; };
324
+ const skeleton = () => { hours.innerHTML = `<h4 style="visibility:hidden">${T('Morning')}</h4><div class="cx-hour-grid">${'<div data-slot="skeleton" class="cn-skeleton cx-hour-skel"></div>'.repeat(8)}</div>`; };
322
325
  function pickDay(d) {
323
326
  if (!d) return;
324
327
  picked = { day: d, hour: null }; paintWeek(); sum();
@@ -331,12 +334,14 @@ export function booking(root, i, core, on) {
331
334
  paintWeek(); pickDay(firstOpen(today));
332
335
  }
333
336
 
334
- // ── «Nueva cita» in 4 steps ──
337
+ // ── “New appointment” in 4 steps ──
335
338
  // i: clients [{name, description}], services [{name, duration, price}], hours ["09:00", …], closed [0] (weekdays),
336
339
  // note (under the summary); opened by start(); on.create(json {client, service, day, hour}) · on.close()
337
340
  export function wizard(core, i, on) {
338
- const { ui, $, $$, openModal, closeModal, CONTENT, body, foot, mdl, easeHeight, today, addDays, DAY, MON, DAYLONG, money0 } = core;
339
- const STEPS = ['Cliente', 'Servicio', 'Día y hora', 'Confirmar'];
341
+ const { ui, $, $$, openModal, closeModal, CONTENT, body, foot, mdl, easeHeight, today, addDays, money0 } = core;
342
+ const L = i.lang || core.langOf(document.body), T = (k, ...a) => core.t(L, k, ...a), W = core.kit.words(L), DAY = W.dayS, MON = W.mon, DAYLONG = W.dayL;
343
+ const longDay = (d) => (L === 'es' ? `${DAYLONG[d.getDay()]} ${d.getDate()} de ${MON[d.getMonth()]}` : `${DAYLONG[d.getDay()]}, ${MON[d.getMonth()]} ${d.getDate()}`);
344
+ const STEPS = [T('Client'), T('Service'), T('Day and time'), T('Confirm')];
340
345
  const clients = list(i.clients), services = list(i.services), hoursList = list(i.hours), closed = list(i.closed);
341
346
  const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
342
347
  const days = () => Array.from({ length: 10 }, (_, k) => addDays(today, k + 1)).filter((d) => !closed.includes(d.getDay())).slice(0, 5);
@@ -344,29 +349,29 @@ export function wizard(core, i, on) {
344
349
  const kind = `wizard-${Math.random().toString(36).slice(2, 8)}`;
345
350
  const valid = () => [wiz.client != null, wiz.service != null, wiz.day != null && wiz.hour != null, true][wiz.at];
346
351
  const clientRows = () => clients.map((c, k) => [c, k]).filter(([c]) => !q || core.fold(`${c.name} ${c.description}`).includes(core.fold(q)))
347
- .map(([c, k]) => ui.choice({ media: ui.itemMedia(ui.avatar(c.name), 'default'), title: esc(c.name), description: esc(c.description), checked: wiz.client === k, attrs: `data-v="${k}"` })).join('') || ui.text(`Nadie con «${esc(q)}»`, 'muted', 'p');
352
+ .map(([c, k]) => ui.choice({ media: ui.itemMedia(ui.avatar(c.name), 'default'), title: esc(c.name), description: esc(c.description), checked: wiz.client === k, attrs: `data-v="${k}"` })).join('') || ui.text(T('Nobody with “{0}”', esc(q)), 'muted', 'p');
348
353
  const stepBody = () => {
349
354
  const at = wiz.at;
350
- if (at === 0) return `<div class="cx-vstack" style="gap:12px">${ui.field({ id: 'wz-q', label: 'Busca o elige', control: ui.input({ id: 'wz-q', placeholder: 'Nombre o teléfono', value: esc(q) }) })}<div class="cx-vstack" role="radiogroup" aria-label="Cliente" data-wz="client">${clientRows()}</div></div>`;
351
- if (at === 1) return `<div class="cx-vstack" role="radiogroup" aria-label="Servicio" data-wz="service">${services.map((s, k) => ui.choice({ media: ui.itemMedia(ui.icon(s.icon || 'scissors')), title: esc(s.name), description: esc(s.duration), aside: ui.text(money0(Number(s.price) || 0), 'strong'), checked: wiz.service === k, attrs: `data-v="${k}"` })).join('')}</div>`;
352
- if (at === 2) return `<div class="cx-vstack" style="gap:14px">${ui.text('Día', 'label')}<div class="cx-wz-days" role="group" aria-label="Día">${days().map((d, k) => `<button class="cn-button cn-button-variant-outline cx-size-tile cx-day" aria-pressed="${wiz.day === k}" data-wz-day="${k}"><small>${DAY[d.getDay()]}</small><b>${d.getDate()}</b><i></i></button>`).join('')}</div>${ui.text('Hora', 'label')}<div class="cx-hour-grid" role="group" aria-label="Hora">${hoursList.map((h) => `<button class="cn-button cn-button-variant-outline cn-button-size-sm cx-hour" aria-pressed="${wiz.hour === h}" data-wz-hour="${h}">${h}</button>`).join('')}</div></div>`;
355
+ if (at === 0) return `<div class="cx-vstack" style="gap:12px">${ui.field({ id: 'wz-q', label: T('Search or pick'), control: ui.input({ id: 'wz-q', placeholder: T('Name or phone'), value: esc(q) }) })}<div class="cx-vstack" role="radiogroup" aria-label="${T('Client')}" data-wz="client">${clientRows()}</div></div>`;
356
+ if (at === 1) return `<div class="cx-vstack" role="radiogroup" aria-label="${T('Service')}" data-wz="service">${services.map((s, k) => ui.choice({ media: ui.itemMedia(ui.icon(s.icon || 'scissors')), title: esc(s.name), description: esc(s.duration), aside: ui.text(money0(Number(s.price) || 0), 'strong'), checked: wiz.service === k, attrs: `data-v="${k}"` })).join('')}</div>`;
357
+ if (at === 2) return `<div class="cx-vstack" style="gap:14px">${ui.text(T('Day'), 'label')}<div class="cx-wz-days" role="group" aria-label="${T('Day')}">${days().map((d, k) => `<button class="cn-button cn-button-variant-outline cx-size-tile cx-day" aria-pressed="${wiz.day === k}" data-wz-day="${k}"><small>${DAY[d.getDay()]}</small><b>${d.getDate()}</b><i></i></button>`).join('')}</div>${ui.text('Hora', 'label')}<div class="cx-hour-grid" role="group" aria-label="${T('Hour')}">${hoursList.map((h) => `<button class="cn-button cn-button-variant-outline cn-button-size-sm cx-hour" aria-pressed="${wiz.hour === h}" data-wz-hour="${h}">${h}</button>`).join('')}</div></div>`;
353
358
  const c = clients[wiz.client], s = services[wiz.service], d = days()[wiz.day];
354
359
  return `<div data-slot="item-group" class="cn-item-group cx-wz-sum">${[
355
- ui.item({ media: ui.itemMedia(ui.avatar(c.name), 'default'), title: esc(c.name), description: 'Cliente' }),
360
+ ui.item({ media: ui.itemMedia(ui.avatar(c.name), 'default'), title: esc(c.name), description: T('Client') }),
356
361
  ui.item({ media: ui.itemMedia(ui.icon(s.icon || 'scissors')), title: esc(s.name), description: esc(s.duration), actions: ui.text(money0(Number(s.price) || 0), 'strong') }),
357
- ui.item({ media: ui.itemMedia(ui.icon('cal')), title: `${DAYLONG[d.getDay()]} ${d.getDate()} de ${MON[d.getMonth()]}`, description: wiz.hour }),
362
+ ui.item({ media: ui.itemMedia(ui.icon('cal')), title: longDay(d), description: wiz.hour }),
358
363
  ].join(ui.itemSeparator())}</div>${i.note ? ui.text(esc(i.note), 'caption', 'p') : ''}`;
359
364
  };
360
365
  const paintFoot = () => {
361
366
  const last = wiz.at === STEPS.length - 1;
362
- foot.innerHTML = ui.button({ label: wiz.at === 0 ? 'Cancelar' : 'Atrás', size: 'default', attrs: wiz.at === 0 ? 'data-dismiss' : 'data-wz-back', cls: 'cx-exit' })
363
- + ui.button({ label: last ? 'Crear cita' : 'Siguiente<svg><use href="#right"/></svg>', variant: 'default', size: 'default', attrs: `data-wz-next ${valid() ? '' : 'disabled'}`, cls: 'cx-go' });
367
+ foot.innerHTML = ui.button({ label: wiz.at === 0 ? T('Cancel') : T('Back'), size: 'default', attrs: wiz.at === 0 ? 'data-dismiss' : 'data-wz-back', cls: 'cx-exit' })
368
+ + ui.button({ label: last ? T('Create appointment') : `${T('Next')}<svg><use href="#right"/></svg>`, variant: 'default', size: 'default', attrs: `data-wz-next ${valid() ? '' : 'disabled'}`, cls: 'cx-go' });
364
369
  };
365
370
  const render = (dir = 0) => {
366
- $('#mdl-title').textContent = i.title || 'Nueva cita';
371
+ $('#mdl-title').textContent = T(i.title || 'New appointment');
367
372
  $('#mdl-desc').innerHTML = ui.stepper(STEPS, wiz.at);
368
373
  const paint = () => { body.innerHTML = stepBody(); };
369
- if (dir) { easeHeight(mdl, paint); body.animate([{ opacity: 0, transform: `translateX(${dir * 14}px)` }, { opacity: 1, transform: 'none' }], { duration: 200, easing: 'cubic-bezier(.22, 1, .36, 1)' }); } else paint();
374
+ if (dir) { easeHeight(mdl, paint); body.animate([{ opacity: 0, transform: `translateX(${dir * 14}px)` }, { opacity: 1, transform: 'none' }], { duration: 200, easing: 'cubic-bezier(.23, 1, .32, 1)' }); } else paint();
370
375
  paintFoot();
371
376
  };
372
377
  CONTENT[kind] = () => { wiz = { at: 0, client: null, service: null, day: null, hour: null }; q = ''; body.dataset.wizard = kind; render(); };
@@ -389,7 +394,7 @@ export function wizard(core, i, on) {
389
394
  setTimeout(() => { closeModal(); wiz = null; }, 700);
390
395
  }
391
396
  });
392
- // closing by Esc, the overlay or «Cancelar» tells the page, so its own «open» state can go back to false
397
+ // closing by Esc, the overlay or “Cancel” tells the page, so its own “open” state can go back to false
393
398
  new MutationObserver(() => { if (mdl.hidden && body.dataset.wizard === kind) { delete body.dataset.wizard; wiz = null; on.close?.(); } }).observe(mdl, { attributes: true, attributeFilter: ['hidden'] });
394
399
  return { start: () => openModal(kind, 'auto') };
395
400
  }
@@ -1,24 +1,26 @@
1
1
  // @muten/shadcn · DataTable (kit). The reference artifact's DataTable, made generic: columns, rows, filters, statuses
2
2
  // and bulk actions are data. Everything else is the artifact's: filters from their type (search · facet · date ·
3
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.
4
+ // header, selection with its own bulk bar, the phone's “search + Filters” sheet, and a narrow box becoming Items.
5
5
  // columns [{key, label, type: person|text|date|status|money, sub (person: the key under the name), sortable}]
6
6
  // rows [{id, …keys}] (a date is "yyyy-mm-ddTHH:MM"; a row's date may be "+N" days from today too)
7
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}]
8
+ // statuses [{id, label, tone: ok|wait|info|bad}] · noun ["appointment", "appointments"] · bulk [{id, label, variant}]
9
9
  // on.action(json {action, ids})
10
10
  export function dataTable(root, cfg, core, on) {
11
11
  const { ui, $, $$, lib, place, show, hide, easeHeight, setCheck, CB_CHECK, money0, fold, reformat, groupThousands, today, addDays, DAY, openModal, CONTENT, body, foot } = core;
12
12
  const list = (v) => (Array.isArray(v) ? v : []);
13
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'];
14
+ // its language: the nearest lang (the page's), English by default
15
+ const L = core.langOf(root), T = (k, ...a) => core.t(L, k, ...a), DAYS = core.kit.words(L).dayS;
16
+ const [ONE, MANY] = (list(cfg.noun).length === 2 ? cfg.noun : ['row', 'rows']).map((x) => T(x));
15
17
  const ST_ORDER = list(cfg.statuses).map((s) => s.id);
16
18
  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
19
  const DT = list(cfg.rows).map((r, i) => ({ id: r.id ?? i, ...r }));
18
20
  const colOf = (type) => COLS.find((c) => c.type === type);
19
21
  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 whenText = (v) => { const d = asDate(v); return `${DAYS[d.getDay()]} ${d.getDate()} · ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
23
+ const DATE_PRESETS = { today: [T('Today'), (d) => +d === +today], tomorrow: [T('Tomorrow'), (d) => +d === +addDays(today, 1)], week: [T('Next 7 days'), (d) => d >= today && d < addDays(today, 7)], later: [T('Later'), (d) => d >= addDays(today, 7)] };
22
24
  const dayOf = (v) => { const d = asDate(v); d.setHours(0, 0, 0, 0); return d; };
23
25
  const FILTERS = list(cfg.filters).map((f) => ({
24
26
  ...f,
@@ -38,7 +40,7 @@ export function dataTable(root, cfg, core, on) {
38
40
  const firstSort = dateCol ? { key: dateCol.key, dir: 1 } : { key: COLS[0]?.key, dir: 1 };
39
41
  const bulk = list(cfg.bulk);
40
42
  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>`;
43
+ root.innerHTML = `<div class="cx-dt-host"><div class="cx-dt-toolbar"><div class="cx-hstack cx-dt-tools" role="toolbar" aria-label="${T('Filters')}"></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: T('Clear selection'), 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(T('Select all'), 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
44
  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
45
  const dtSel = new Set(); let dtSort = firstSort;
44
46
  const passes = (r, except) => FILTERS.every((f) => f.id === except || PASS[f.type](f, fState[f.id], r));
@@ -51,37 +53,37 @@ export function dataTable(root, cfg, core, on) {
51
53
  const paintTools = () => {
52
54
  if (dtMobile) {
53
55
  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"' });
56
+ dtTools.innerHTML = searchHtml() + ui.button({ label: `${ui.icon('filter')}${T('Filters')}${n ? ui.badge(String(n), { variant: 'default' }) : ''}`, size: 'sm', cls: 'cx-fbtn-m', attrs: 'data-fmobile aria-haspopup="dialog"' });
55
57
  if (focused) { const i = dtTools.querySelector('[data-filter]'); i.focus(); i.setSelectionRange(i.value.length, i.value.length); }
56
58
  return;
57
59
  }
58
60
  dtTools.innerHTML = FILTERS.map((f) => (f.type === 'search' ? searchHtml()
59
61
  : 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' }) : '');
62
+ + (FILTERS.some(isActive) ? ui.button({ label: T('Clear'), size: 'sm', variant: 'ghost', attrs: 'data-fclear' }) : '');
61
63
  };
62
64
  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
65
  let fOpen = null, fBtn = null;
64
66
  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>`; };
67
+ const rangeInputs = (f, prefix) => { const v = fState[f.id] || [null, null]; return `<div class="cx-frange">${[T('From'), T('To')].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 ? T('No limit') : '0'}"></div>` })).join('')}</div>`; };
66
68
  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
69
  ? `<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
70
  : `<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
71
  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 === '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="${T('Search in {0}', 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>${T('Remove filter')}</button>` : ''}</div>`;
73
+ 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: T('Any date'), variant: 'ghost', size: 'sm', cls: 'cx-fill cx-left', attrs: 'data-fdate=""' }) : ''}</div>`;
72
74
  if (f.type === 'range') return `<div class="cx-vstack" style="gap:10px">${ui.text(f.label, 'label')}${rangeInputs(f, 'f-')}</div>`;
73
75
  return '';
74
76
  };
75
77
  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
78
  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
79
  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}`; };
80
+ const syncMobileCount = (n) => { mobileCount = n; const go = $('[data-fsheet-go]', foot); if (go && sheetOpen) go.innerHTML = T('See {0} {1}', n, n === 1 ? ONE : MANY); };
79
81
  const sheetBody = () => FILTERS.filter((f) => f.type !== 'search').map((f) => {
80
82
  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
83
  return `<section class="cx-vstack" style="gap:10px">${ui.text(f.label, 'label')}${ctrl}</section>`;
82
84
  }).join('<div data-slot="separator" class="cn-separator cn-separator-horizontal"></div>');
83
85
  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' }); };
86
+ CONTENT[sheetKind] = () => { sheetOpen = true; document.getElementById('mdl-title').textContent = T('Filters'); document.getElementById('mdl-desc').textContent = T('They apply as you pick.'); body.innerHTML = sheetBody(); foot.innerHTML = ui.button({ label: T('Clear'), size: 'default', attrs: 'data-fsheet-clear', cls: 'cx-exit' }) + ui.button({ label: T('See {0} {1}', mobileCount, mobileCount === 1 ? ONE : MANY), variant: 'default', size: 'default', attrs: 'data-dismiss data-fsheet-go', cls: 'cx-go' }); };
85
87
  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
88
  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
89
  body.addEventListener('click', (e) => {
@@ -126,7 +128,7 @@ export function dataTable(root, cfg, core, on) {
126
128
  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
129
  easeHeight(dtChips, () => {
128
130
  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"` })}`;
131
+ const html = `${t}${ui.button({ icon: 'x', variant: 'ghost', size: 'icon-xs', cls: 'cx-chip-x', attrs: `data-fchip="${k}" aria-label="${T('Remove {0}', t)}" title="${T('Remove')}"` })}`;
130
132
  const c = have.get(k);
131
133
  if (c) { if (c.dataset.text !== t) { c.innerHTML = html; c.dataset.text = t; } dtChips.appendChild(c); return; }
132
134
  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);
@@ -144,8 +146,8 @@ export function dataTable(root, cfg, core, on) {
144
146
  return `<td class="cx-dt-svc" ${dateCol ? `data-when="${whenText(r[dateCol.key])}"` : ''}>${v ?? ''}</td>`;
145
147
  };
146
148
  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
+ const rowCells = (r) => `<td class="cx-dt-check">${cbHtml(T('Select {0}', who(r)), dtSel.has(r.id))}</td>${COLS.map((c) => cell(c, r)).join('')}`;
150
+ const EASE = 'cubic-bezier(.23, 1, .32, 1)';
149
151
  let dtAnim = 0;
150
152
  const dtPaint = () => {
151
153
  const f = SORTERS[dtSort.key] || (() => 0);
@@ -160,7 +162,7 @@ export function dataTable(root, cfg, core, on) {
160
162
  const byId = new Map([...dtBody.children].map((tr) => [tr.dataset.id, tr]));
161
163
  const frag = document.createDocumentFragment(), entering = [];
162
164
  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); }
165
+ 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">${T('Nothing matches the filters')}</div><div class="cn-empty-description">${T('Remove one or clear them all.')}</div></div><div class="cn-empty-content">${ui.button({ label: T('Clear filters'), attrs: 'data-fclear-empty' })}</div></div></td>`; entering.push(tr); } frag.appendChild(tr); }
164
166
  dtBody.replaceChildren(frag);
165
167
  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
168
  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' }));