@muten/shadcn 0.1.14 → 0.2.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 (182) hide show
  1. package/AGENTS.md +88 -1
  2. package/globals.css +1 -0
  3. package/kit.css +329 -124
  4. package/motion.css +3 -0
  5. package/package.json +1 -1
  6. package/registry/accordion.muten +3 -3
  7. package/registry/agenda.js +19 -16
  8. package/registry/agenda.muten +10 -10
  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 +197 -114
  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.muten +4 -4
  100. package/registry/money-field.js +1 -1
  101. package/registry/money-field.muten +2 -2
  102. package/registry/nav-button.muten +4 -4
  103. package/registry/navigation-menu.muten +4 -4
  104. package/registry/navpill.muten +6 -6
  105. package/registry/new-appointment.js +1 -1
  106. package/registry/new-appointment.muten +4 -4
  107. package/registry/number-field.js +6 -4
  108. package/registry/number-field.muten +1 -1
  109. package/registry/order-board.js +1 -1
  110. package/registry/order-board.muten +2 -2
  111. package/registry/order-play.muten +4 -4
  112. package/registry/pagination-nav.js +1 -1
  113. package/registry/pagination-nav.muten +3 -3
  114. package/registry/pagination.muten +4 -4
  115. package/registry/password-field.muten +2 -2
  116. package/registry/permission-matrix.muten +4 -4
  117. package/registry/phone-bar.muten +6 -6
  118. package/registry/phone-field.js +1 -1
  119. package/registry/phone-field.muten +1 -1
  120. package/registry/phone-shell.js +1 -1
  121. package/registry/pill.muten +2 -2
  122. package/registry/plans.muten +1 -1
  123. package/registry/popover.muten +2 -2
  124. package/registry/pricing.muten +4 -4
  125. package/registry/product-play.muten +3 -3
  126. package/registry/progress.muten +3 -3
  127. package/registry/queue.muten +1 -1
  128. package/registry/radio-group.muten +1 -1
  129. package/registry/rank-item.muten +4 -4
  130. package/registry/resizable.muten +1 -1
  131. package/registry/row-menu.js +1 -1
  132. package/registry/row-menu.muten +2 -2
  133. package/registry/save-button.muten +5 -5
  134. package/registry/scale-slider.js +1 -1
  135. package/registry/scale-slider.muten +2 -2
  136. package/registry/screen-tour.muten +1 -1
  137. package/registry/share-bar.muten +2 -2
  138. package/registry/share-button.muten +4 -4
  139. package/registry/sheet.muten +1 -1
  140. package/registry/sidebar.muten +12 -6
  141. package/registry/site-footer.muten +6 -6
  142. package/registry/sortable-behavior.js +67 -20
  143. package/registry/sortable-list.js +3 -1
  144. package/registry/sortable-list.muten +2 -2
  145. package/registry/sortable.muten +5 -4
  146. package/registry/spinner.muten +2 -2
  147. package/registry/split-button.muten +2 -2
  148. package/registry/stacked-bars.js +3 -3
  149. package/registry/stacked-bars.muten +2 -2
  150. package/registry/stamp-card.muten +2 -2
  151. package/registry/stat-band.muten +3 -3
  152. package/registry/stat.muten +3 -3
  153. package/registry/status-bar.muten +2 -2
  154. package/registry/stepper.muten +4 -4
  155. package/registry/store-qr.js +1 -1
  156. package/registry/store-qr.muten +2 -2
  157. package/registry/swatch.muten +1 -1
  158. package/registry/switch.muten +8 -7
  159. package/registry/table-view.muten +3 -3
  160. package/registry/tabs-behavior.js +9 -1
  161. package/registry/tabs.muten +2 -2
  162. package/registry/tag-input.js +1 -1
  163. package/registry/tag-input.muten +2 -2
  164. package/registry/text.muten +2 -2
  165. package/registry/time-field.js +1 -1
  166. package/registry/time-field.muten +3 -3
  167. package/registry/timeline.muten +4 -4
  168. package/registry/toaster.muten +2 -2
  169. package/registry/toggle-group.muten +1 -1
  170. package/registry/toggle.muten +3 -3
  171. package/registry/toolbar.muten +4 -4
  172. package/registry/tooltip.muten +2 -2
  173. package/registry/tree-view.js +38 -25
  174. package/registry/tree-view.muten +1 -1
  175. package/registry/veil.muten +1 -1
  176. package/registry/virtual-list.js +1 -1
  177. package/registry/virtual-list.muten +2 -2
  178. package/registry/virtual-window.js +4 -4
  179. package/registry/virtual-window.muten +2 -2
  180. package/registry/wordline.muten +1 -1
  181. package/registry/zoom-control.muten +1 -1
  182. package/registry.json +22 -2
@@ -1,6 +1,6 @@
1
1
  # FeatureGrid - what a product does, as cards in a grid that wraps (three, two or one per row by the width). Each
2
2
  # FeatureCard has its icon in the slot, a title and a line.
3
- # FeatureGrid { FeatureCard(title: "Agenda", description: "Citas por persona y recordatorios.") { Icon "lucide:calendar" } … }
3
+ # FeatureGrid { FeatureCard(title: "Schedule", description: "Appointments per person and reminders.") { Icon "lucide:calendar" } … }
4
4
  # shadcn-for-muten · parts · no deps · kit.css (.cx-features).
5
5
  part FeatureGrid(columns: number = 3) { Stack class("cx-features") style(cols: "{$columns}") { slot } }
6
6
  part FeatureCard(title: text, description: text) {
@@ -1,6 +1,6 @@
1
1
  # FeatureRows - a list of numbered rows split by hairlines: the number, a title and its explanation in three
2
- # columns on a wide screen, one under the other on a phone. For steps, a schedule, a «how it works».
3
- # FeatureRows { FeatureRow(index: "01", title: "Crea tu tienda", description: "Nombre, rubro y ciudad en un minuto.") … }
2
+ # columns on a wide screen, one under the other on a phone. For steps, a schedule, a “how it works”.
3
+ # FeatureRows { FeatureRow(index: "01", title: "Create your shop", description: "Nombre, rubro y ciudad en un minuto.") … }
4
4
  # shadcn-for-muten · parts · no deps · kit.css (.cx-frows).
5
5
  part FeatureRows() { Stack class("cx-frows") aria(role: "list") { slot } }
6
6
  part FeatureRow(index: text, title: text, description: text = "") {
@@ -1,22 +1,32 @@
1
1
  // FieldBehavior (muten Custom) - the reference artifact's field rules, on the muten field it sits in. The value stays
2
2
  // muten's (bind): every change here dispatches an input event so the page's state follows.
3
3
  export function mount(el, inputs) {
4
+ let I = null; import('@muten/shadcn/registry/kit/i18n.js').then((m) => { I = m; });
4
5
  const field = el.closest('.cn-field, .cn-input-group') || el.parentElement;
5
6
  const input = field.querySelector('input, textarea');
6
7
  if (!input) return () => {};
7
8
  const set = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); };
8
9
  const kind = inputs.kind;
9
10
  if (kind === 'money') {
11
+ // thousands while typing (not only on leaving: focusing used to drop the dots), and only digits get in: a letter is
12
+ // not written, the caret stays after the same digit
10
13
  const group = (d) => d.replace(/^0+(?=\d)/, '').replace(/\B(?=(\d{3})+(?!\d))/g, '.');
11
- input.addEventListener('focus', () => set(input.value.replace(/\D/g, '')));
12
- input.addEventListener('blur', () => set(group(input.value.replace(/\D/g, ''))));
14
+ input.inputMode = 'numeric';
15
+ input.addEventListener('beforeinput', (e) => { if (e.data && /\D/.test(e.data) && e.inputType.startsWith('insert')) e.preventDefault(); });
16
+ input.addEventListener('input', (e) => {
17
+ if (!e.isTrusted) return;
18
+ const caret = input.selectionStart ?? input.value.length, keep = input.value.slice(0, caret).replace(/\D/g, '').length;
19
+ const next = group(input.value.replace(/\D/g, '')); if (next === input.value) return;
20
+ set(next); let pos = 0, seen = 0; while (pos < next.length && seen < keep) { if (/\d/.test(next[pos])) seen++; pos++; }
21
+ input.setSelectionRange(pos, pos);
22
+ });
13
23
  set(group(input.value.replace(/\D/g, '')));
14
24
  }
15
25
  if (kind === 'email') {
16
26
  // the Field draws its description after its slot, so it is looked up when needed, not at mount
17
27
  let help = null;
18
28
  const ok = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v);
19
- const check = () => { const desc = field.querySelector('.cn-field-description'); if (desc && help === null) help = desc.textContent; const bad = input.value.trim() !== '' && !ok(input.value.trim()); input.setAttribute('aria-invalid', String(bad)); field.toggleAttribute('data-invalid', bad); if (desc) { desc.classList.add('cx-msg'); desc.textContent = bad ? inputs.message || 'Revisa el correo.' : help; } };
29
+ const check = () => { const desc = field.querySelector('.cn-field-description'); if (desc && help === null) help = desc.textContent; const bad = input.value.trim() !== '' && !ok(input.value.trim()); input.setAttribute('aria-invalid', String(bad)); field.toggleAttribute('data-invalid', bad); if (desc) { desc.classList.add('cx-msg'); desc.textContent = bad ? inputs.message || (I ? I.t(I.langOf(field), 'Check the email.') : 'Check the email.') : help; } };
20
30
  input.addEventListener('blur', check);
21
31
  input.addEventListener('input', () => { if (field.hasAttribute('data-invalid') && ok(input.value.trim())) check(); });
22
32
  }
@@ -1,9 +1,9 @@
1
1
  # FieldBehavior - what a field does that is not markup, put INSIDE its Field (after the control):
2
- # kind "money" → separates thousands when leaving the field (25000 → 25.000)
2
+ # kind "money" → separates thousands while typing (25000 → 25.000); letters do not get in
3
3
  # kind "email" → checks the address when leaving the field (not while typing); the error replaces the help text
4
4
  # in the same place (nothing jumps). message = the error.
5
5
  # kind "search" → Esc clears it.
6
- # kind "count" → «71/160» at the end of the description line (needs maxlength via `max`).
7
- # Field(label: "Correo para avisos", description: "Te llegan las reservas nuevas.") { Input(value: mail) FieldBehavior(kind: "email", message: "Falta algo: revisa que sea como nombre@dominio.co") }
6
+ # kind "count" → “71/160” at the end of the description line (needs maxlength via `max`).
7
+ # Field(label: "Email for notifications", description: "You get the new bookings.") { Input(value: mail) FieldBehavior(kind: "email", message: "Something is missing: check it looks like name@domain.co") }
8
8
  # shadcn-for-muten · Custom · host JS is field-behavior.js.
9
9
  part FieldBehavior(kind: text, message: text = "", max: number = 0) { Custom FieldBehavior inputs(kind: $kind, message: $message, max: $max) class("cx-field-behavior") }
@@ -1,13 +1,13 @@
1
1
  # Field - a labelled control: the label, the control (slot), a description under it, an error when there is one.
2
- # Field(label: "Nombre") { Input(value: name, placeholder: "Corte clásico") }
3
- # Field(label: "Descripción", description: "Se ve en tu tienda.") { TextArea(value: desc) }
2
+ # Field(label: "Name") { Input(value: name, placeholder: "Classic cut") }
3
+ # Field(label: "Description", description: "Shows in your shop.") { TextArea(value: desc) }
4
4
  # Two short fields side by side: FieldRow { Field(…) { … } Field(…) { … } } (one column on a phone).
5
5
  # error: a message that replaces the description in the same place (nothing jumps) and marks the field invalid.
6
6
  # orientation: vertical | horizontal.
7
7
  # FieldSet - sets the size and the look of every field inside it, the kit's own too (PasswordField, PhoneField…):
8
- # FieldSet(size: "2xl", variant: "filled") { Field(label: "Correo") { … } PasswordField(label: "Contraseña") }
9
- # FieldSeparator - a line with a word in the middle, between two ways in: FieldSeparator(label: "o con tu correo")
10
- # FieldHead - a label with an action beside it: FieldHead(label: "Contraseña") { LinkBtn(to: "/recover", variant: "link", size: "sm") { Span "¿La olvidaste?" } }
8
+ # FieldSet(size: "2xl", variant: "filled") { Field(label: "Email") { … } PasswordField(label: "Password") }
9
+ # FieldSeparator - a line with a word in the middle, between two ways in: FieldSeparator(label: "or with your email")
10
+ # FieldHead - a label with an action beside it: FieldHead(label: "Password") { LinkBtn(to: "/recover", variant: "link", size: "sm") { Span "Forgot it?" } }
11
11
  # shadcn-for-muten · part · no deps · Nova (.cn-field) + kit.css.
12
12
  part Field(label: text = "", description: text = "", error: text = "", orientation: text = "vertical") {
13
13
  Stack class("cn-field cn-field-orientation-{$orientation} group/field") data(slot: "field", orientation: $orientation, invalid: $error != "") {
@@ -3,10 +3,14 @@
3
3
  // with its size and a × to take it away; a file of the wrong type or too heavy is refused there, with the reason.
4
4
  export function mount(el, inputs, on) {
5
5
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
6
+ // its words: English, said in the language of the nearest lang (or inputs.lang) once kit/i18n.js is in
7
+ let I = null; const T = (k, ...a) => (I ? I.t(I.langOf(el), k, ...a) : a.reduce((s, v, n) => s.replace(`{${n}}`, v), k));
8
+ if (inputs && inputs.lang) el.setAttribute('lang', inputs.lang);
9
+ import('@muten/shadcn/registry/kit/i18n.js').then((m) => { I = m; m.localizeTree(el); });
6
10
  const multiple = inputs.multiple === 'on', maxMb = Number(inputs.maxMb) || 10, accept = String(inputs.accept || '');
7
- el.innerHTML = `<div class="cx-drop cx-file-drop" tabindex="0" role="button" aria-label="${esc(inputs.label || 'Subir archivo')}">
11
+ el.innerHTML = `<div class="cx-drop cx-file-drop" tabindex="0" role="button" aria-label="${esc(inputs.label || 'Upload a file')}" data-trl="${esc(inputs.label || 'Upload a file')}">
8
12
  <input type="file" hidden ${accept ? `accept="${esc(accept)}"` : ''} ${multiple ? 'multiple' : ''}>
9
- <div class="cx-drop-empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg><b>${esc(inputs.label || 'Sube un archivo')}</b><span>${esc(inputs.hint || 'Toca, arrastra o pega aquí')}</span></div>
13
+ <div class="cx-drop-empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg><b data-tr="${esc(inputs.label || 'Upload a file')}">${esc(inputs.label || 'Upload a file')}</b><span data-tr="${esc(inputs.hint || 'Tap, drag or paste here')}">${esc(inputs.hint || 'Tap, drag or paste here')}</span></div>
10
14
  </div>
11
15
  <p class="cx-note cx-file-msg" aria-live="polite"></p>
12
16
  <div data-slot="item-group" class="cn-item-group cx-file-list" role="list"></div>`;
@@ -15,14 +19,14 @@ export function mount(el, inputs, on) {
15
19
  const size = (b) => (b < 1024 ? `${b} B` : b < 1048576 ? `${Math.round(b / 1024)} KB` : `${(b / 1048576).toFixed(1)} MB`);
16
20
  const fits = (f) => { if (!accept) return true; return accept.split(',').map((a) => a.trim()).some((a) => (a.endsWith('/*') ? f.type.startsWith(a.slice(0, -1)) : a.startsWith('.') ? f.name.toLowerCase().endsWith(a.toLowerCase()) : f.type === a)); };
17
21
  const paint = () => {
18
- list.innerHTML = files.map((f, i) => `<div data-slot="item" class="cn-item cn-item-variant-outline cn-item-size-sm group/item" role="listitem"><div class="cn-item-media cn-item-media-variant-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg></div><div class="cn-item-content"><div class="cn-item-title cx-ellipsis">${esc(f.name)}</div><p class="cn-item-description">${size(f.size)}</p></div><div class="cn-item-actions"><button type="button" class="cn-button cn-button-variant-ghost cn-button-size-icon-xs" data-drop="${i}" aria-label="Quitar ${esc(f.name)}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div></div>`).join('');
22
+ list.innerHTML = files.map((f, i) => `<div data-slot="item" class="cn-item cn-item-variant-outline cn-item-size-sm group/item" role="listitem"><div class="cn-item-media cn-item-media-variant-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg></div><div class="cn-item-content"><div class="cn-item-title cx-ellipsis">${esc(f.name)}</div><p class="cn-item-description">${size(f.size)}</p></div><div class="cn-item-actions"><button type="button" class="cn-button cn-button-variant-ghost cn-button-size-icon-xs" data-drop="${i}" aria-label="${esc(T('Remove {0}', f.name))}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div></div>`).join('');
19
23
  on.files?.(files.map((f) => f.name).join(', '));
20
24
  };
21
25
  const take = (fileList) => {
22
26
  const incoming = Array.from(fileList || []); if (!incoming.length) return;
23
27
  const bad = incoming.filter((f) => !fits(f)), heavy = incoming.filter((f) => fits(f) && f.size > maxMb * 1048576);
24
28
  const good = incoming.filter((f) => fits(f) && f.size <= maxMb * 1048576);
25
- msg.textContent = bad.length ? `${bad[0].name} no es de un tipo que se pueda subir aquí.` : heavy.length ? `${heavy[0].name} pesa más de ${maxMb} MB.` : '';
29
+ msg.textContent = bad.length ? T('{0} is not a type of file that can be uploaded here.', bad[0].name) : heavy.length ? T('{0} is over {1} MB.', heavy[0].name, maxMb) : '';
26
30
  files = multiple ? files.concat(good) : good.slice(0, 1).length ? good.slice(0, 1) : files;
27
31
  paint();
28
32
  };
@@ -1,9 +1,9 @@
1
1
  # FileInput - a drop zone for any file: tap to pick, drag and drop, or paste. Under it, the picked files with their
2
2
  # size and a × each; a file of the wrong type or too heavy is refused in the same place, with the reason. For a
3
3
  # photo that has to be cropped and shrunk before uploading, use ImageUpload.
4
- # FileInput(label: "Sube el comprobante", hint: "PDF o imagen · hasta 5 MB", accept: "image/*,.pdf", maxMb: 5, onFiles: setFiles)
5
- # FileInput(label: "Documentos", multiple: "on", onFiles: setFiles)
4
+ # FileInput(label: "Upload the receipt", hint: "PDF or image · up to 5 MB", accept: "image/*,.pdf", maxMb: 5, onFiles: setFiles)
5
+ # FileInput(label: "Documents", multiple: "on", onFiles: setFiles)
6
6
  # onFiles gets the names, joined by ", ". shadcn-for-muten · Custom · kit.css (.cx-drop) · host JS file-input.js.
7
- part FileInput(label: text = "Sube un archivo", hint: text = "Toca, arrastra o pega aquí", accept: text = "", multiple: text = "", maxMb: number = 10, onFiles: action) {
7
+ part FileInput(label: text = "Upload a file", hint: text = "Tap, drag or paste here", accept: text = "", multiple: text = "", maxMb: number = 10, onFiles: action) {
8
8
  Custom FileInput inputs(label: $label, hint: $hint, accept: $accept, multiple: $multiple, maxMb: $maxMb) on(files: $onFiles) class("cx-file-host")
9
9
  }
@@ -1,9 +1,9 @@
1
- # FixedCard - a card that never grows with the data: at most three rows and a «Ver todas» that opens the full list
1
+ # FixedCard - a card that never grows with the data: at most three rows and a “See all” that opens the full list
2
2
  # (in the adaptive Modal). Empty, it keeps the same shape. Rows are Row3 (time · who and what · its state).
3
- # FixedCard(title: "Citas de hoy", description: "Jueves · 14 en total") {
4
- # Row3(time: "09:00", title: "Andrés Rojas", subtitle: "Corte clásico") { ToneBadge(label: "Confirmada", tone: "ok") }
3
+ # FixedCard(title: "Today's appointments", description: "Thursday · 14 in total") {
4
+ # Row3(time: "09:00", title: "Andrés Rojas", subtitle: "Classic cut") { ToneBadge(label: "Confirmed", tone: "ok") }
5
5
  # }
6
- # …then its CardFooter { Btn(variant: "outline", size: "sm", onClick: openAll) class("cx-fill") { Span "Ver todas (14)" } }
6
+ # …then its CardFooter { Btn(variant: "outline", size: "sm", onClick: openAll) class("cx-fill") { Span "See all (14)" } }
7
7
  # EmptyMini - the empty line inside it: EmptyMini(text: "Entregaste todo.") { Icon "lucide:circle-check" }
8
8
  # shadcn-for-muten · part · no deps · kit.css (.cx-fixed-card, .cx-row3, .cx-empty-mini).
9
9
  part FixedCard(title: text, description: text = "") {
@@ -3,11 +3,15 @@
3
3
  // inputs: label. handlers: pick(folder name). Where the browser cannot, the button says so instead of failing.
4
4
  export function mount(el, inputs, on) {
5
5
  const ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>';
6
- const idle = inputs.label || 'Conectar carpeta';
6
+ // its words: English, said in the language of the nearest lang (or inputs.lang)
7
+ if (inputs.lang) el.setAttribute('lang', inputs.lang);
8
+ let I = null; const T = (k, ...a) => (I ? I.t(I.langOf(el), k, ...a) : k);
9
+ const idle = inputs.label || 'Connect a folder';
7
10
  el.innerHTML = `<button type="button" data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-default group/button cx-folder-btn">${ICON}<span></span></button><p class="cx-note cx-folder-msg" aria-live="polite"></p>`;
8
11
  const btn = el.querySelector('button'), label = btn.querySelector('span'), msg = el.querySelector('.cx-folder-msg');
9
12
  label.textContent = idle;
10
- if (!window.showDirectoryPicker) { btn.disabled = true; msg.textContent = 'Este navegador no deja elegir carpetas: usa Chrome o Edge.'; }
13
+ import('@muten/shadcn/registry/kit/i18n.js').then((m) => { I = m; if (!btn.dataset.connected) label.textContent = T(idle); if (btn.disabled) msg.textContent = T('This browser cannot pick folders: use Chrome or Edge.'); });
14
+ if (!window.showDirectoryPicker) { btn.disabled = true; msg.textContent = T('This browser cannot pick folders: use Chrome or Edge.'); }
11
15
  btn.addEventListener('click', async () => {
12
16
  try { const handle = await window.showDirectoryPicker(); label.textContent = handle.name; btn.dataset.connected = ''; el._folder = handle; on.pick?.(handle.name); }
13
17
  catch { /* the person closed the picker */ }
@@ -1,5 +1,5 @@
1
1
  # FolderPicker - connects a folder of the computer (Chrome and Edge): a button that shows the folder's name once it
2
2
  # is chosen; where the browser cannot, it says so under the button instead of failing.
3
- # FolderPicker(label: "Conectar carpeta", onPick: setFolder)
3
+ # FolderPicker(label: "Connect folder", onPick: setFolder)
4
4
  # shadcn-for-muten · Custom · host JS folder-picker.js.
5
- part FolderPicker(label: text = "Conectar carpeta", onPick: action) { Custom FolderPicker inputs(label: $label) on(pick: $onPick) class("cx-folder-host") }
5
+ part FolderPicker(label: text = "Connect a folder", onPick: action) { Custom FolderPicker inputs(label: $label) on(pick: $onPick) class("cx-folder-host") }
@@ -1,12 +1,12 @@
1
1
  # Heatmap - how much happens where two things cross: rows × columns (weekday × hour, week × day), each cell darker the
2
- # more it holds, a «less … more» scale under it. A cell says its count to a screen reader and on hover. With `empty`,
2
+ # more it holds, a “less … more” scale under it. A cell says its count to a screen reader and on hover. With `empty`,
3
3
  # a calm note sits over the grid until there is something to show (emptyTitle on top of it). busy: true shows the grid
4
4
  # as a skeleton while it loads.
5
- # Heatmap(rows: days, cols: hours, cells: heat, less: "Menos", more: "Más citas", label: "Citas por día y hora")
5
+ # Heatmap(rows: days, cols: hours, cells: heat, less: "Less", more: "More appointments", label: "Appointments by day and hour")
6
6
  # rows {id, label} · cols {label} (an empty label keeps the column unlabelled) · cells {row, level 0-6, tip}, row by row,
7
7
  # as many per row as there are cols. tone: the scale's colour (brand by default; coral | teal | amber | blue | pink).
8
8
  # shadcn-for-muten · part · no deps · kit.css (.cx-heat).
9
- part Heatmap(rows: list, cols: list, cells: list, label: text = "", less: text = "Menos", more: text = "Más", empty: text = "", emptyTitle: text = "", tone: text = "brand", busy: bool = false) {
9
+ part Heatmap(rows: list, cols: list, cells: list, label: text = "", less: text = "Less", more: text = "More", empty: text = "", emptyTitle: text = "", tone: text = "brand", busy: bool = false) {
10
10
  Stack class("cx-heat") data(tone: $tone, busy: $busy) aria(busy: $busy) {
11
11
  Stack class("cx-heat-grid") style(n: "{$cols.length}") aria(role: "img", label: $label) {
12
12
  Span "" class("cx-heat-corner")
@@ -18,13 +18,13 @@ part Heatmap(rows: list, cols: list, cells: list, label: text = "", less: text =
18
18
  when $empty != "" and not $busy { Stack class("cx-veil-note") { Stack class("cx-veil-box") { Span "{$emptyTitle}" class("cx-veil-t") Span "{$empty}" class("cx-veil-s") } } }
19
19
  }
20
20
  Stack class("cx-heat-scale") aria(hidden: true) {
21
- Span "{$less}"
21
+ Span "{$less}" data(tr: $less)
22
22
  Span "" class("cx-heat-c") data(level: 0)
23
23
  Span "" class("cx-heat-c") data(level: 2)
24
24
  Span "" class("cx-heat-c") data(level: 4)
25
25
  Span "" class("cx-heat-c") data(level: 5)
26
26
  Span "" class("cx-heat-c") data(level: 6)
27
- Span "{$more}"
27
+ Span "{$more}" data(tr: $more)
28
28
  }
29
29
  }
30
30
  }
@@ -1,5 +1,5 @@
1
1
  // ImageUpload (muten Custom) - the kit's imageUpload (kit/dates.js).
2
2
  export function mount(el, inputs, on) {
3
- Promise.all([import('@muten/shadcn/registry/kit/core.js'), import('@muten/shadcn/registry/kit/dates.js')]).then(([core, m]) => m.imageUpload(el, inputs, core, on));
3
+ Promise.all([import('@muten/shadcn/registry/kit/core.js'), import('@muten/shadcn/registry/kit/dates.js')]).then(([core, m]) => core.relang(el, inputs, () => m.imageUpload(el, inputs, core, on)));
4
4
  return () => {};
5
5
  }
@@ -1,8 +1,8 @@
1
1
  # ImageUpload - tap, drag or paste an image: it is checked (type, size), decoded BEFORE drawing (no black squares),
2
2
  # cropped square, shrunk to WebP and uploaded with real progress to uploadUrl - or handed back as a data URL.
3
3
  # onReady gets {url | dataUrl, width, height, kb} ("" when removed).
4
- # ImageUpload(title: "Sube la foto del servicio", uploadUrl: "/media", onReady: setPhoto)
4
+ # ImageUpload(title: "Upload the service photo", uploadUrl: "/media", onReady: setPhoto)
5
5
  # shadcn-for-muten · Custom (kit dates) · host JS image-upload.js.
6
- part ImageUpload(title: text = "Sube la foto", hint: text = "", maxMb: number = 10, size: number = 1200, uploadUrl: text = "", sample: text = "", onReady: action) {
6
+ part ImageUpload(title: text = "Upload the photo", hint: text = "", maxMb: number = 10, size: number = 1200, uploadUrl: text = "", sample: text = "", onReady: action) {
7
7
  Custom ImageUpload inputs(title: $title, hint: $hint, maxMb: $maxMb, size: $size, uploadUrl: $uploadUrl, sample: $sample) on(ready: $onReady) class("cx-up-host")
8
8
  }
@@ -1,12 +1,12 @@
1
1
  # InboxPlay - a messages list shown, not used: the shop's inbox as the channel's app draws it (skin: instagram |
2
- # whatsapp), with the unread ones in bold and their dot. For a landing, beside «nobody answered them»; the real inbox
2
+ # whatsapp), with the unread ones in bold and their dot. For a landing, beside “nobody answered them”; the real inbox
3
3
  # is Messaging. The rows are a picture (aria-hidden); `label` says what it shows.
4
- # InboxPlay(title: "brumabarberia", tabs: "Principal", requests: "Solicitudes (12)", label: "…") {
5
- # InboxPlayRow(name: "lau.mtz", text: "precio??", time: "2 h", unread: "yes")
6
- # InboxPlayRow(name: "carlos.r", text: "Visto", time: "1 d", photo: "/face.webp")
4
+ # InboxPlay(title: "brumabarberia", tabs: "Primary", requests: "Requests (12)", label: "…") {
5
+ # InboxPlayRow(name: "lau.mtz", text: "price??", time: "2 h", unread: "yes")
6
+ # InboxPlayRow(name: "carlos.r", text: "Seen", time: "1 d", photo: "/face.webp")
7
7
  # }
8
8
  # search (a placeholder) draws the app's search bar; InboxPlayNotes, InboxPlayTabs and InboxPlayFilters go in the list
9
- # in that order. A row takes ring (yes: a story ring), badge (WhatsApp's unread count), tag (who answered: «Asistente»);
9
+ # in that order. A row takes ring (yes: a story ring), badge (WhatsApp's unread count), tag (who answered: “Asistente”);
10
10
  # and Instagram rows show the camera at the end.
11
11
  # shadcn-for-muten · part · no deps · kit.css (.cx-ip).
12
12
  part InboxPlay(title: text, tabs: text = "", requests: text = "", skin: text = "instagram", label: text = "", search: text = "") {
@@ -1,4 +1,4 @@
1
- # Indicator - the dot on a corner that says «something here needs you». Inside a Btn with a tone, or on an Avatar.
1
+ # Indicator - the dot on a corner that says “something here needs you”. Inside a Btn with a tone, or on an Avatar.
2
2
  # Btn(size: "icon-sm", variant: "ghost", tone: "note", onClick: open) { Icon "lucide:quote" Indicator(tone: "note") }
3
3
  # tone: "" (selected) | wait | bad | info | note
4
4
  # shadcn-for-muten · part · no deps · kit.css (.cx-indicator).
@@ -3,7 +3,7 @@
3
3
  # softer, stacked with their column's name on a phone); a cell's tone picks "strong" or "soft" inside it.
4
4
  # InfoTable(variant: "compare") {
5
5
  # InfoHeadRow { InfoHead(label: "") InfoHead(label: "Storio") InfoHead(label: "Linktree") }
6
- # InfoRow { InfoCell(label: "Agenda", tone: "strong") InfoCell(label: "Incluida") InfoCell(label: "No", tone: "soft") }
6
+ # InfoRow { InfoCell(label: "Schedule", tone: "strong") InfoCell(label: "Included") InfoCell(label: "No", tone: "soft") }
7
7
  # }
8
8
  # shadcn-for-muten · parts · no deps · Nova (.cn-table) + kit.css (.cx-mk-vs-table).
9
9
  part InfoTable(variant: text = "default") {
@@ -1,18 +1,18 @@
1
1
  # InputGroup - a field with things inside its border: a prefix ($, an icon), a suffix (COP), a clear button.
2
2
  # InputGroup { InputAddon { Span "$" } GroupInput(value: price) InputAddon(align: "inline-end") { Span "COP" } }
3
3
  # SearchInput - the search with a magnifier and a × that shows only with text (Esc also clears).
4
- # SearchInput(value: q, placeholder: "Nombre o teléfono", onClear: clearQ)
5
- # Anything goes in an addon: an Icon, InputGroupText («https://», «COP», «@»), an InputGroupButton, a Kbd, a Spinner.
4
+ # SearchInput(value: q, placeholder: "Name or phone", onClear: clearQ)
5
+ # Anything goes in an addon: an Icon, InputGroupText (“https://”, “COP”, “@”), an InputGroupButton, a Kbd, a Spinner.
6
6
  # align: inline-start | inline-end (beside the text) | block-start | block-end (a row above / below, for a GroupTextArea).
7
- # InputGroup { GroupTextArea(value: msg, placeholder: "Escribe…") InputAddon(align: "block-end") { InputGroupText(label: "0/160") InputGroupButton(label: "Enviar", onClick: send) { Icon "lucide:send" } } }
7
+ # InputGroup { GroupTextArea(value: msg, placeholder: "Escribe…") InputAddon(align: "block-end") { InputGroupText(label: "0/160") InputGroupButton(label: "Send", onClick: send) { Icon "lucide:send" } } }
8
8
  # InputGroup and SearchInput take size (default | xl | 2xl) and variant (default | filled) like Input.
9
9
  # variant "tile": the field as a tile, its icon on a soft tile, its label inside above the value, a helper at the end:
10
- # InputGroup(variant: "tile") { InputAddon { Icon "lucide:store" } InputAddon(align: "block-start") { InputGroupText(label: "Nombre del negocio") }
11
- # GroupInput(value: name, placeholder: "Estudio Luna") InputAddon(align: "inline-end") { StatusBadge(label: "Libre", tone: "ok") } }
10
+ # InputGroup(variant: "tile") { InputAddon { Icon "lucide:store" } InputAddon(align: "block-start") { InputGroupText(label: "Business name") }
11
+ # GroupInput(value: name, placeholder: "Estudio Luna") InputAddon(align: "inline-end") { StatusBadge(label: "Free", tone: "ok") } }
12
12
  # A picker as a tile (it opens the page's Drawer): the value is an InputGroupText (class cx-placeholder while empty) and
13
13
  # the tile goes inside a Button with class cx-tile-btn:
14
14
  # Button "" -> openCountry class("cx-tile-btn") { InputGroup(variant: "tile") { InputAddon { Icon "lucide:globe" }
15
- # InputAddon(align: "block-start") { InputGroupText(label: "País") } InputGroupText(label: countryName)
15
+ # InputAddon(align: "block-start") { InputGroupText(label: "Country") } InputGroupText(label: countryName)
16
16
  # InputAddon(align: "inline-end") { Icon "lucide:chevron-down" } } }
17
17
  # shadcn-for-muten · part · no deps · Nova (.cn-input-group).
18
18
  part InputGroup(size: text = "default", variant: text = "default") { Stack class("cn-input-group group/input-group") data(slot: "input-group", size: $size, variant: $variant) aria(role: "group") { slot } }
@@ -22,18 +22,18 @@ part InputAddon(align: text = "inline-start") { Stack class("cn-input-group-addo
22
22
  part GroupInput(value: text, placeholder: text = "", label: text = "") { SearchField bind($value) "{$placeholder}" class("cn-input cn-input-group-input cx-num") data(slot: "input-group-control") aria(label: $label) }
23
23
  # GroupPassword - a password inside an InputGroup; `shown` (the page's bool) shows what was typed. Put the eye in an
24
24
  # InputAddon(align: "inline-end") with an InputGroupButton that flips that bool.
25
- # InputGroup { InputAddon { Icon "lucide:lock" } GroupPassword(value: pass, shown: showPass, placeholder: "Tu contraseña")
26
- # InputAddon(align: "inline-end") { InputGroupButton(onClick: toggleShow, size: "icon-xs", label: "Mostrar contraseña") { Icon "lucide:eye" } } }
25
+ # InputGroup { InputAddon { Icon "lucide:lock" } GroupPassword(value: pass, shown: showPass, placeholder: "Your password")
26
+ # InputAddon(align: "inline-end") { InputGroupButton(onClick: toggleShow, size: "icon-xs", label: "Show password") { Icon "lucide:eye" } } }
27
27
  part GroupPassword(value: text, shown: bool = false, placeholder: text = "", label: text = "") {
28
28
  when $shown { SearchField bind($value) "{$placeholder}" class("cn-input cn-input-group-input") data(slot: "input-group-control") aria(label: $label) }
29
29
  when not $shown { Password bind($value) "{$placeholder}" class("cn-input cn-input-group-input") data(slot: "input-group-control") aria(label: $label) }
30
30
  }
31
- part SearchInput(value: text, placeholder: text = "Buscar", onClear: action, size: text = "default", variant: text = "default") {
31
+ part SearchInput(value: text, placeholder: text = "Search", onClear: action, size: text = "default", variant: text = "default") {
32
32
  Stack class("cn-input-group group/input-group") data(slot: "input-group", size: $size, variant: $variant) aria(role: "group") {
33
33
  Stack class("cn-input-group-addon cn-input-group-addon-align-inline-start") data(slot: "input-group-addon", align: "inline-start") { Icon "lucide:search" }
34
- SearchField bind($value) "{$placeholder}" class("cn-input cn-input-group-input") data(slot: "input-group-control")
34
+ SearchField bind($value) "{$placeholder}" class("cn-input cn-input-group-input") data(slot: "input-group-control", trp: $placeholder)
35
35
  Stack class("cn-input-group-addon cn-input-group-addon-align-inline-end") data(slot: "input-group-addon", align: "inline-end") {
36
- when $value != "" { Button -> $onClear class("cn-button cn-button-variant-ghost cn-button-size-icon-xs group/button cx-clear") aria(label: "Limpiar búsqueda") { Icon "lucide:x" } }
36
+ Button -> $onClear class("cn-button cn-button-variant-ghost cn-button-size-icon-xs group/button cx-clear") class("cx-off" when $value == "") data(trl: "Clear search") aria(label: "Clear search") { Icon "lucide:x" }
37
37
  }
38
38
  FieldBehavior(kind: "search", message: "")
39
39
  }
@@ -1,6 +1,6 @@
1
- # InputOtp - the verification code: slots in two groups, a caret, «Verificando…», ok / bad, a resend countdown. The
1
+ # InputOtp - the verification code: slots in two groups, a caret, “Verificando…”, ok / bad, a resend countdown. The
2
2
  # PAGE checks the code: onInput gets it when complete; answer with state ("ok" | "bad") and message.
3
- # InputOtp(value: code, length: 6, hint: "Te lo enviamos al +57 300 123 4567", state: otpState, message: otpMsg, onInput: check)
3
+ # InputOtp(value: code, length: 6, hint: "We sent it to +57 300 123 4567", state: otpState, message: otpMsg, onInput: check)
4
4
  # shadcn-for-muten · Custom (kit values) · host JS input-otp.js.
5
5
  part InputOtp(value: text = "", length: number = 6, hint: text = "", state: text = "", message: text = "", resendIn: number = 30, onInput: action) {
6
6
  Custom InputOtp inputs(value: $value, length: $length, hint: $hint, state: $state, message: $message, resendIn: $resendIn) on(input: $onInput) class("cx-otp-host")
@@ -1,6 +1,6 @@
1
1
  # Input - a text field bound to a page text state. TextArea - the same for several lines (`Textarea` is muten's
2
2
  # primitive, so the part is TextArea).
3
- # Input(value: name, placeholder: "Nombre o teléfono") TextArea(value: notes, placeholder: "Notas para el equipo")
3
+ # Input(value: name, placeholder: "Name or phone") TextArea(value: notes, placeholder: "Notes for the team")
4
4
  # size: default (32) | xl (44) | 2xl (52) · variant: default (a line) | filled (the soft field of a landing: muted fill, no line).
5
5
  # For a whole form, set them once on a FieldSet around it instead (field.muten).
6
6
  # shadcn-for-muten · part · no deps · Nova (.cn-input, .cn-textarea).
@@ -3,11 +3,11 @@
3
3
  # whether each section is open.
4
4
  # Inspector {
5
5
  # InspectorSection(label: "Transform", open: tOpen, onToggle: toggleT) {
6
- # InspectorRow(label: "Posición") { VectorField(…) } InspectorRow(label: "Rotación") { NumberField(…) }
6
+ # InspectorRow(label: "Position") { VectorField(…) } InspectorRow(label: "Rotation") { NumberField(…) }
7
7
  # }
8
8
  # }
9
9
  # shadcn-for-muten · parts · no deps · kit.css (.cx-insp, the Accordion's fold).
10
- part Inspector(label: text = "Propiedades") { Stack class("cx-insp") data(slot: "inspector") aria(label: $label) { slot } }
10
+ part Inspector(label: text = "Properties") { Stack class("cx-insp") data(slot: "inspector", trl: $label) aria(label: $label) { slot } }
11
11
  part InspectorSection(label: text, open: bool, onToggle: action) {
12
12
  Stack class("cx-insp-sec") data(open: $open) {
13
13
  Button -> $onToggle class("cx-insp-head") aria(expanded: $open) { Span "{$label}" Icon "lucide:chevron-down" class("cx-acc-chev") }
@@ -1,13 +1,13 @@
1
1
  # Item - a row: media, content (title + description), actions, an optional footer. Lists are Items in an ItemGroup
2
2
  # with ItemSeparator between them.
3
3
  # ItemGroup {
4
- # Item { ItemMedia { Avatar(name: "Ana") } ItemContent { ItemTitle(label: "Ana") ItemDescription(label: "Corte · 9:00") } ItemActions { Txt(value: "$25.000", variant: "strong") } }
4
+ # Item { ItemMedia { Avatar(name: "Ana") } ItemContent { ItemTitle(label: "Ana") ItemDescription(label: "Haircut · 9:00") } ItemActions { Txt(value: "$25.000", variant: "strong") } }
5
5
  # ItemSeparator()
6
6
  # }
7
7
  # Item variant: default | outline | muted · size: default | sm | xs · ItemMedia variant: default | icon | image
8
8
  # shadcn-for-muten · part · no deps · Nova (.cn-item).
9
9
  part Item(variant: text = "default", size: text = "default") { Stack class("cn-item cn-item-variant-{$variant} cn-item-size-{$size} group/item") data(slot: "item", size: $size) { slot } }
10
- part ItemGroup() { Stack class("cn-item-group group/item-group") data(slot: "item-group") aria(role: "list") { slot } }
10
+ part ItemGroup() { Stack class("cn-item-group group/item-group") data(slot: "item-group") aria(role: "list") { slot ListMotion() } }
11
11
  part ItemSeparator() { Stack class("cn-separator cn-separator-horizontal cn-item-separator") data(slot: "item-separator", orientation: "horizontal") aria(role: "separator") }
12
12
  part ItemMedia(variant: text = "default") { Stack class("cn-item-media cn-item-media-variant-{$variant}") data(slot: "item-media", variant: $variant) { slot } }
13
13
  part ItemContent() { Stack class("cn-item-content") data(slot: "item-content") { slot } }