@muten/shadcn 0.0.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/AGENTS.md +61 -6
  2. package/README.md +57 -4
  3. package/globals.css +30 -2
  4. package/kit.css +2763 -0
  5. package/motion.css +79 -0
  6. package/nova.css +1387 -0
  7. package/package.json +10 -2
  8. package/registry/accordion.muten +11 -10
  9. package/registry/agenda.js +35 -0
  10. package/registry/agenda.muten +10 -0
  11. package/registry/alert-dialog.muten +9 -12
  12. package/registry/alert.muten +8 -8
  13. package/registry/app-shell.js +9 -0
  14. package/registry/app-shell.muten +12 -0
  15. package/registry/attachment.muten +13 -9
  16. package/registry/avatar.muten +24 -5
  17. package/registry/badge.muten +23 -8
  18. package/registry/band.muten +16 -0
  19. package/registry/bar-chart.muten +7 -0
  20. package/registry/bell.muten +3 -0
  21. package/registry/bento.muten +6 -9
  22. package/registry/booking.js +21 -0
  23. package/registry/booking.muten +13 -0
  24. package/registry/breadcrumb.muten +9 -7
  25. package/registry/button-group.muten +13 -4
  26. package/registry/button.muten +21 -8
  27. package/registry/calendar.js +28 -93
  28. package/registry/calendar.muten +9 -8
  29. package/registry/canvas-viewport.js +12 -5
  30. package/registry/canvas-viewport.muten +9 -9
  31. package/registry/card-field.muten +4 -0
  32. package/registry/card.muten +12 -8
  33. package/registry/carousel.js +5 -36
  34. package/registry/carousel.muten +12 -5
  35. package/registry/chatbot.muten +9 -0
  36. package/registry/checklist.muten +6 -0
  37. package/registry/choice-list.muten +16 -0
  38. package/registry/code.js +14 -12
  39. package/registry/code.muten +6 -7
  40. package/registry/collapsible.muten +14 -10
  41. package/registry/color-picker.js +460 -41
  42. package/registry/color-picker.muten +7 -6
  43. package/registry/combobox.js +5 -0
  44. package/registry/combobox.muten +6 -23
  45. package/registry/command-palette.muten +8 -12
  46. package/registry/command.js +32 -0
  47. package/registry/command.muten +32 -11
  48. package/registry/context-menu.muten +9 -8
  49. package/registry/count-up.muten +5 -10
  50. package/registry/date-field.js +5 -0
  51. package/registry/date-field.muten +7 -0
  52. package/registry/device-frame.muten +8 -0
  53. package/registry/dialog.muten +6 -12
  54. package/registry/dirty-bar.js +19 -0
  55. package/registry/dirty-bar.muten +13 -0
  56. package/registry/drawer.muten +6 -11
  57. package/registry/dropdown-menu.muten +37 -13
  58. package/registry/empty.muten +11 -7
  59. package/registry/feature-grid.muten +10 -12
  60. package/registry/feature-rows.muten +12 -8
  61. package/registry/field-behavior.js +33 -0
  62. package/registry/field-behavior.muten +9 -0
  63. package/registry/field.muten +26 -7
  64. package/registry/file-input.js +36 -21
  65. package/registry/file-input.muten +8 -7
  66. package/registry/fixed-card.muten +25 -0
  67. package/registry/folder-picker.js +13 -15
  68. package/registry/folder-picker.muten +5 -8
  69. package/registry/gradient-blinds.muten +6 -13
  70. package/registry/hover-card.muten +7 -9
  71. package/registry/image-upload.js +5 -0
  72. package/registry/image-upload.muten +8 -0
  73. package/registry/indicator.muten +5 -0
  74. package/registry/input-group.muten +27 -4
  75. package/registry/input-otp.js +8 -35
  76. package/registry/input-otp.muten +6 -6
  77. package/registry/input.muten +8 -7
  78. package/registry/inspector.muten +13 -15
  79. package/registry/island.js +7 -0
  80. package/registry/island.muten +5 -0
  81. package/registry/item.muten +20 -7
  82. package/registry/kbd.muten +3 -3
  83. package/registry/kit/board.js +300 -0
  84. package/registry/kit/collections.js +148 -0
  85. package/registry/kit/core.js +989 -0
  86. package/registry/kit/dates.js +106 -0
  87. package/registry/kit/menus.js +90 -0
  88. package/registry/kit/overlays.js +114 -0
  89. package/registry/kit/paint.js +211 -0
  90. package/registry/kit/qrcode.js +2291 -0
  91. package/registry/kit/structure.js +393 -0
  92. package/registry/kit/table.js +198 -0
  93. package/registry/kit/values.js +179 -0
  94. package/registry/kit-layer.js +2 -0
  95. package/registry/kit-layer.muten +5 -0
  96. package/registry/kit-skeleton.js +24 -0
  97. package/registry/kit-skeleton.muten +16 -0
  98. package/registry/kit-widget.js +79 -0
  99. package/registry/kit-widget.muten +4 -0
  100. package/registry/link-btn.muten +9 -7
  101. package/registry/live-stack.js +29 -0
  102. package/registry/live-stack.muten +7 -0
  103. package/registry/logo-cloud.muten +6 -11
  104. package/registry/member-list.muten +6 -0
  105. package/registry/menubar.muten +10 -14
  106. package/registry/message-scroller.js +12 -0
  107. package/registry/message-scroller.muten +8 -4
  108. package/registry/message.muten +27 -10
  109. package/registry/messaging.muten +11 -0
  110. package/registry/modal-behavior.js +56 -0
  111. package/registry/modal-behavior.muten +4 -0
  112. package/registry/modal.muten +29 -0
  113. package/registry/money-field.js +5 -0
  114. package/registry/money-field.muten +7 -0
  115. package/registry/nav-button.muten +14 -0
  116. package/registry/navigation-menu.muten +16 -14
  117. package/registry/navpill.muten +15 -9
  118. package/registry/new-appointment.js +8 -0
  119. package/registry/new-appointment.muten +10 -0
  120. package/registry/number-field.js +34 -24
  121. package/registry/number-field.muten +8 -6
  122. package/registry/order-board.js +6 -0
  123. package/registry/order-board.muten +21 -0
  124. package/registry/overlay-behavior.js +13 -0
  125. package/registry/overlay-behavior.muten +4 -0
  126. package/registry/pagination-nav.js +6 -0
  127. package/registry/pagination-nav.muten +8 -0
  128. package/registry/pagination.muten +12 -9
  129. package/registry/password-field.muten +3 -0
  130. package/registry/permission-matrix.muten +6 -0
  131. package/registry/phone-field.js +5 -0
  132. package/registry/phone-field.muten +7 -0
  133. package/registry/phone-shell.js +7 -0
  134. package/registry/phone-shell.muten +9 -0
  135. package/registry/pill.muten +6 -5
  136. package/registry/plans.muten +4 -0
  137. package/registry/popover.muten +16 -9
  138. package/registry/pricing.muten +21 -21
  139. package/registry/progress.muten +5 -5
  140. package/registry/queue.muten +3 -0
  141. package/registry/radio-group.muten +16 -8
  142. package/registry/range-slider.js +9 -24
  143. package/registry/range-slider.muten +4 -5
  144. package/registry/resizable.js +34 -27
  145. package/registry/resizable.muten +16 -5
  146. package/registry/roving-behavior.js +16 -0
  147. package/registry/roving-behavior.muten +3 -0
  148. package/registry/row-menu.js +5 -0
  149. package/registry/row-menu.muten +5 -0
  150. package/registry/save-button.muten +11 -0
  151. package/registry/scale-slider.js +7 -0
  152. package/registry/scale-slider.muten +9 -0
  153. package/registry/scroll-area.muten +8 -4
  154. package/registry/segmented-control.muten +8 -8
  155. package/registry/separator.muten +3 -3
  156. package/registry/share-button.js +7 -0
  157. package/registry/share-button.muten +10 -0
  158. package/registry/sheet.muten +6 -10
  159. package/registry/sidebar.muten +23 -15
  160. package/registry/skeleton.muten +6 -4
  161. package/registry/slider.js +73 -27
  162. package/registry/slider.muten +6 -6
  163. package/registry/sortable-behavior.js +43 -0
  164. package/registry/sortable-behavior.muten +2 -0
  165. package/registry/sortable-list.js +10 -26
  166. package/registry/sortable-list.muten +6 -7
  167. package/registry/sortable.muten +15 -0
  168. package/registry/spark.js +14 -0
  169. package/registry/spark.muten +3 -0
  170. package/registry/spinner.muten +4 -3
  171. package/registry/split-button.muten +12 -10
  172. package/registry/stamp-card.muten +7 -0
  173. package/registry/stat-band.muten +6 -9
  174. package/registry/stat.muten +23 -0
  175. package/registry/status-bar.muten +7 -6
  176. package/registry/stepper.muten +17 -14
  177. package/registry/store-qr.js +5 -0
  178. package/registry/store-qr.muten +5 -0
  179. package/registry/swatch.muten +8 -8
  180. package/registry/switch.muten +14 -6
  181. package/registry/table-view.muten +10 -0
  182. package/registry/tabs-behavior.js +27 -0
  183. package/registry/tabs-behavior.muten +3 -0
  184. package/registry/tabs.muten +19 -10
  185. package/registry/tag-input.js +4 -27
  186. package/registry/tag-input.muten +6 -6
  187. package/registry/text.muten +7 -0
  188. package/registry/time-field.js +5 -0
  189. package/registry/time-field.muten +6 -0
  190. package/registry/timeline.muten +14 -14
  191. package/registry/toaster.js +34 -22
  192. package/registry/toaster.muten +10 -8
  193. package/registry/toggle-group.muten +11 -6
  194. package/registry/toggle.muten +8 -5
  195. package/registry/toolbar.muten +11 -6
  196. package/registry/tooltip.muten +6 -9
  197. package/registry/tree-view.js +27 -7
  198. package/registry/tree-view.muten +8 -13
  199. package/registry/vector-field.muten +9 -8
  200. package/registry/virtual-list.js +5 -0
  201. package/registry/virtual-list.muten +6 -0
  202. package/registry/weekly-hours.muten +4 -0
  203. package/registry/wordline.muten +6 -0
  204. package/registry/zoom-control.muten +9 -9
  205. package/registry.json +868 -424
  206. package/themes/bloom.css +53 -0
  207. package/themes/editorial.css +65 -0
  208. package/themes/noir.css +54 -0
  209. package/registry/chart.js +0 -34
  210. package/registry/chart.muten +0 -7
  211. package/registry/checkbox.muten +0 -8
  212. package/registry/context-menu.js +0 -34
  213. package/registry/select.muten +0 -21
@@ -0,0 +1,3 @@
1
+ # TabsBehavior - the sliding indicator and the arrow keys of a Tabs / SegmentedControl. Used by them; never by hand.
2
+ # shadcn-for-muten · Custom · host JS is tabs-behavior.js.
3
+ part TabsBehavior() { Custom TabsBehavior class("cx-tabs-behavior") }
@@ -1,12 +1,21 @@
1
- # Tabs - a tab bar + panels. The PAGE owns a `text` state (the active tab) + an action that sets it:
2
- # state { tab = "account" : text } action setTab(v: text) mutates tab { tab.set(v) }
3
- # TabsList { TabsTrigger(value: "account", current: tab, label: "Account", onSelect: setTab) … }
4
- # TabsContent(value: "account", current: tab) { … }
5
- # shadcn-for-muten · part · no deps · styles via the semantic .tabs* classes in globals.css.
6
- part TabsList() { Stack class("tabs-list") { slot } }
7
- part TabsTrigger(value: text, current: text, label: text, onSelect: action) {
8
- Button "{$label}" -> $onSelect($value) aria(selected: $value == $current) class("tabs-trigger", "tabs-trigger-active" when $value == $current)
1
+ # Tabs - the indicator slides from one tab to the next (260 ms). variant "" = segmented (a pill on a muted track),
2
+ # "line" = an underline with a count per tab that scrolls sideways on a phone. Arrows, Home and End move between tabs.
3
+ # The page owns the current value; TabsContent shows the panel of the current one.
4
+ # Tabs(label: "Periodo") { TabsTrigger(value: "day", current: period, label: "Hoy", onSelect: setPeriod) TabsTrigger(value: "week", current: period, label: "Semana", onSelect: setPeriod) }
5
+ # Tabs(label: "Pedidos", variant: "line") { TabsTrigger(value: "new", current: lane, label: "Nuevos", count: "4", onSelect: setLane) … }
6
+ # TabsContent(value: "new", current: lane) { … }
7
+ # shadcn-for-muten · part + Custom (TabsBehavior: the indicator + keys) · kit.css (.cx-tabs).
8
+ part Tabs(label: text, variant: text = "") {
9
+ Stack class("cx-tabs {$variant}") data(slot: "tabs") aria(role: "tablist", label: $label) {
10
+ slot
11
+ TabsBehavior()
12
+ }
9
13
  }
10
- part TabsContent(value: text, current: text) {
11
- Stack class("tabs-content", "hidden" when $value != $current) { slot }
14
+ part TabsList() { Stack class("cx-tabs") data(slot: "tabs") aria(role: "tablist") { slot TabsBehavior() } }
15
+ part TabsTrigger(value: text, current: text, label: text, onSelect: action, count: text = "") {
16
+ Button -> $onSelect($value) class("cn-button cn-button-variant-ghost cn-button-size-sm cx-tab") aria(role: "tab", selected: $value == $current) {
17
+ Span "{$label}"
18
+ when $count != "" { Span "{$count}" class("cx-count") }
19
+ }
12
20
  }
21
+ part TabsContent(value: text, current: text) { Stack class("cx-tab-panel", "hidden" when $value != $current) aria(role: "tabpanel") { slot } }
@@ -1,28 +1,5 @@
1
- // TagInput host (muten Custom). inputs: { value (comma-joined), placeholder }. handlers: { change(commaJoined) }.
2
- // Type + Enter adds a chip; the x or Backspace (on an empty field) removes one. The page owns a comma-joined text.
3
- export function mount(el, inputs, handlers) {
4
- const parse = (v) => String(v == null ? '' : v).split(',').map((s) => s.trim()).filter(Boolean);
5
- let tags = parse(inputs.value);
6
- const input = document.createElement('input'); input.className = 'tag-input-field';
7
- input.placeholder = inputs.placeholder == null ? 'Add tag…' : String(inputs.placeholder);
8
-
9
- const emit = () => { if (handlers.change) handlers.change(tags.join(',')); render(); };
10
- const render = () => {
11
- el.innerHTML = '';
12
- for (const t of tags) {
13
- const chip = document.createElement('span'); chip.className = 'tag-chip'; chip.textContent = t;
14
- const x = document.createElement('span'); x.className = 'tag-chip-x'; x.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'; // lucide x
15
- x.addEventListener('click', (e) => { e.stopPropagation(); tags = tags.filter((z) => z !== t); emit(); });
16
- chip.appendChild(x); el.appendChild(chip);
17
- }
18
- el.appendChild(input);
19
- };
20
- input.addEventListener('keydown', (e) => {
21
- if (e.key === 'Enter' && input.value.trim()) { e.preventDefault(); const v = input.value.trim(); if (!tags.includes(v)) tags.push(v); input.value = ''; emit(); }
22
- else if (e.key === 'Backspace' && !input.value && tags.length) { tags.pop(); emit(); }
23
- });
24
- el.addEventListener('click', () => input.focus());
25
-
26
- render();
27
- return (n) => { tags = parse(n.value); render(); };
1
+ // TagInput (muten Custom) - the kit's tags field (kit/collections.js tagInput): chips in/out, height eases, a cap.
2
+ export function mount(el, inputs, on) {
3
+ Promise.all([import('@muten/shadcn/registry/kit/core.js'), import('@muten/shadcn/registry/kit/collections.js')]).then(([core, c]) => c.tagInput(el, { ...inputs, tags: inputs.value ?? inputs.tags }, core, on));
4
+ return () => {};
28
5
  }
@@ -1,7 +1,7 @@
1
- # Tag Input - chips you add by typing + Enter, remove with x / Backspace. The PAGE owns a comma-joined text:
2
- # state { tags = "sprite,enemy" : text } action setTags(v: text) mutates tags { tags.set(v) }
3
- # TagInput(value: tags, placeholder: "Add tag…", onChange: setTags)
4
- # shadcn-for-muten · Custom · host JS is src/components/TagInput.js (copied by `muten add tag-input`).
5
- part TagInput(value: text, placeholder: text, onChange: action) {
6
- Custom TagInput inputs(value: $value, placeholder: $placeholder) on(change: $onChange) class("tag-input")
1
+ # TagInput - tags as chips: Enter or a comma adds, Backspace marks then removes the last, a repeated tag flashes the
2
+ # one already there, suggestions to tap, a cap with its count. The value is comma-joined text.
3
+ # TagInput(value: tags, label: "Etiquetas del servicio", suggestions: "Fade,Navaja,Niños", max: 8, hint: "Ayudan a que te encuentren.", onChange: setTags)
4
+ # shadcn-for-muten · Custom (kit collections) · host JS tag-input.js.
5
+ part TagInput(value: text, placeholder: text = "", label: text = "Etiquetas", suggestions: text = "", max: number = 8, hint: text = "", onChange: action) {
6
+ Custom TagInput inputs(value: $value, placeholder: $placeholder, label: $label, suggestions: $suggestions, max: $max, hint: $hint) on(change: $onChange) class("cx-tags-host")
7
7
  }
@@ -0,0 +1,7 @@
1
+ # Text (part name Txt - `Text` is a muten primitive) - the one text atom, in a few roles. Everything that shows words
2
+ # in the kit is built from it, so a role changes in one place.
3
+ # Txt(value: "Corte clásico", variant: "heading")
4
+ # Txt(value: "{count} citas", variant: "muted")
5
+ # variant: body | title | heading | strong | label | muted | caption (default: body)
6
+ # shadcn-for-muten · part · no deps · styled by kit.css (.cx-text).
7
+ part Txt(value: text, variant: text = "body") { Span "{$value}" class("cx-text") data(slot: "text", variant: $variant) }
@@ -0,0 +1,5 @@
1
+ // TimeField (muten Custom) - the kit's timeField (kit/dates.js).
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.timeField(el, inputs, core, on));
4
+ return () => {};
5
+ }
@@ -0,0 +1,6 @@
1
+ # TimeField - a field-like button with the time (and mañana/tarde); it opens the hour / minute wheels.
2
+ # TimeField(label: "Hora", value: "10:30", from: 9, to: 19, step: 15, onChange: setTime) → "HH:MM"
3
+ # shadcn-for-muten · Custom (kit dates) · host JS time-field.js.
4
+ part TimeField(label: text = "Hora", value: text = "", from: number = 9, to: number = 19, step: number = 15, onChange: action) {
5
+ Custom TimeField inputs(label: $label, value: $value, from: $from, to: $to, step: $step) on(change: $onChange) class("cx-tf-host")
6
+ }
@@ -1,16 +1,16 @@
1
- # Timeline - a vertical list of events, each a dot on a connector line + content (title, time, description).
2
- # state { events = query events : list<Event> }
3
- # Timeline { each events as e { TimelineItem(title: e.title, time: e.time, desc: e.desc) } }
4
- # Own it with `muten add timeline`.
5
- # shadcn-for-muten · part · no deps · styles via .timeline / .timeline-* in globals.css.
6
- part Timeline() { Stack class("timeline") { slot } }
7
- part TimelineItem(title: text, time: text, desc: text) {
8
- Stack class("timeline-item") {
9
- Stack class("timeline-dot")
10
- Stack class("timeline-content") {
11
- Span "{$time}" class("timeline-time")
12
- Span "{$title}" class("timeline-title")
13
- Span "{$desc}" class("timeline-desc")
14
- }
1
+ # Timeline - what happened, in order: a dot and a line join the events; the last one (where things stand now) is
2
+ # marked. Each event has what happened, when, and an optional detail.
3
+ # Timeline(label: "Historial del pedido") {
4
+ # TimelineItem(title: "Pedido recibido", time: "hoy · 10:38")
5
+ # TimelineItem(title: "Esperando tu revisión", time: "ahora", state: "now")
6
+ # }
7
+ # state: "" (past) | now (where it stands) | next (still to come, faded).
8
+ # shadcn-for-muten · part · no deps · kit.css (.cx-timeline).
9
+ part Timeline(label: text = "") { Stack class("cx-timeline cx-tl") aria(role: "list", label: $label) { slot } }
10
+ part TimelineItem(title: text, time: text = "", description: text = "", state: text = "") {
11
+ Stack class("cx-tl-item") data(state: $state) aria(role: "listitem") {
12
+ Span "{$title}" class("cx-text cx-tl-title") data(slot: "text", variant: "body")
13
+ Text "{$description}" class("cx-tl-desc")
14
+ Span "{$time}" class("cx-text") data(slot: "text", variant: "caption")
15
15
  }
16
16
  }
@@ -1,25 +1,37 @@
1
- // Toaster host component (muten Custom). inputs: { trigger, title, description, variant, duration }.
2
- // The page bumps `trigger` (a counter) to fire a toast with the current title/description; the host shows it,
3
- // slides it in, and auto-dismisses it (click to dismiss early). No id bookkeeping on the page.
4
- export function mount(el, inputs, handlers) {
5
- let last = Number(inputs.trigger ?? 0);
6
-
7
- const show = (title, description, variant, duration) => {
8
- const node = document.createElement('div');
9
- node.className = 'toast' + (variant === 'destructive' ? ' toast-destructive' : '');
10
- const t = document.createElement('div'); t.className = 'toast-title'; t.textContent = title || 'Notification';
11
- node.appendChild(t);
12
- if (description) { const d = document.createElement('div'); d.className = 'toast-description'; d.textContent = description; node.appendChild(d); }
13
- el.appendChild(node);
14
- requestAnimationFrame(() => node.classList.add('toast-show'));
15
- const remove = () => { node.classList.remove('toast-show'); setTimeout(() => node.remove(), 200); };
16
- const timer = setTimeout(remove, Number(duration ?? 3500));
17
- node.addEventListener('click', () => { clearTimeout(timer); remove(); });
1
+ // Toaster (muten Custom) - shadcn's Sonner stack, from the reference artifact: toasts pile at the bottom right, the
2
+ // newest in front and up to three peeking behind; hovering the pile fans them out and pauses their timers; each one
3
+ // leaves on its own after `duration` ms or with its ×. inputs: trigger (bump it to show one), title, description,
4
+ // action (a button label, optional), duration. handlers: action() when its button is tapped.
5
+ export function mount(el, inputs, on) {
6
+ const viewport = document.createElement('section'); viewport.className = 'pg-toasts'; viewport.setAttribute('aria-label', 'Avisos'); viewport.setAttribute('aria-live', 'polite');
7
+ document.body.appendChild(viewport);
8
+ const toasts = [];
9
+ let last = Number(inputs.trigger) || 0;
10
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
11
+ const layout = () => {
12
+ let off = 0; const frontH = toasts[0] ? toasts[0].h : 0;
13
+ toasts.forEach((t, i) => {
14
+ t.el.style.setProperty('--i', i); t.el.style.setProperty('--h', t.h + 'px'); t.el.style.setProperty('--front-h', frontH + 'px'); t.el.style.setProperty('--off', off + 'px');
15
+ off += t.h;
16
+ if (i > 0) t.el.dataset.behind = ''; else delete t.el.dataset.behind;
17
+ if (i >= 3) t.el.dataset.limited = ''; else delete t.el.dataset.limited;
18
+ });
18
19
  };
19
-
20
- // The effect re-runs this whenever a reactive input changes; a higher trigger means "show a new toast".
21
- return (next) => {
22
- const tr = Number(next.trigger ?? 0);
23
- if (tr > last) { last = tr; show(next.title, next.description, next.variant, next.duration); }
20
+ const dismiss = (t) => { if (t.gone) return; t.gone = true; clearTimeout(t.timer); t.el.dataset.end = ''; setTimeout(() => { t.el.remove(); const k = toasts.indexOf(t); if (k >= 0) toasts.splice(k, 1); layout(); }, 500); };
21
+ const arm = (t, ms) => { clearTimeout(t.timer); t.timer = setTimeout(() => dismiss(t), ms); };
22
+ viewport.addEventListener('pointerenter', () => { viewport.dataset.expanded = ''; toasts.forEach((t) => clearTimeout(t.timer)); });
23
+ viewport.addEventListener('pointerleave', () => { delete viewport.dataset.expanded; toasts.forEach((t) => arm(t, t.ms)); });
24
+ const show = (i) => {
25
+ const node = document.createElement('div');
26
+ node.className = 'pg-toast'; node.setAttribute('role', 'status'); node.dataset.start = '';
27
+ node.innerHTML = `<div class="pg-toast-c"><div style="flex:1;min-width:0"><div class="pg-toast-t">${esc(i.title)}</div>${i.description ? `<div class="pg-toast-d">${esc(i.description)}</div>` : ''}</div>${i.action ? `<button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button" data-toast-act>${esc(i.action)}</button>` : ''}<button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon-sm group/button" aria-label="Cerrar" style="color:var(--muted-foreground)"><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>`;
28
+ node.style.setProperty('--i', 0); node.style.visibility = 'hidden';
29
+ viewport.prepend(node);
30
+ const t = { el: node, h: node.querySelector('.pg-toast-c').scrollHeight + 2, ms: Number(i.duration) || 5000 };
31
+ toasts.unshift(t); layout(); node.style.visibility = '';
32
+ requestAnimationFrame(() => requestAnimationFrame(() => delete node.dataset.start));
33
+ node.querySelectorAll('button').forEach((b) => b.addEventListener('click', () => { if (b.hasAttribute('data-toast-act')) on.action?.(); dismiss(t); }));
34
+ arm(t, t.ms);
24
35
  };
36
+ return (next) => { const tr = Number(next.trigger) || 0; if (tr > last) { last = tr; show(next); } };
25
37
  }
@@ -1,9 +1,11 @@
1
- # Toaster - fixed toast notifications (bottom-right). The PAGE bumps a `trigger` counter to fire one:
2
- # state { toastN = 0 : number } action notify mutates toastN { toastN.set(toastN + 1) }
3
- # Button "Notify" -> notify
4
- # Toaster(trigger: toastN, title: "Saved", description: "Your changes are saved.", variant: "default")
5
- # Auto-dismisses; click a toast to close it early. Use state title/description and change them before bumping for dynamic messages.
6
- # shadcn-for-muten · Custom · host JS is src/components/Toaster.js (copied by `muten add toaster`).
7
- part Toaster(trigger: number, title: text, description: text, variant: text) {
8
- Custom Toaster inputs(trigger: $trigger, title: $title, description: $description, variant: $variant) class("toaster")
1
+ # Toaster - shadcn's Sonner: toasts that pile at the bottom right, the newest in front and three peeking behind;
2
+ # hovering the pile fans them out and pauses them; each one leaves on its own (duration) or with its ×, and can carry
3
+ # one action (Deshacer). The page bumps a counter to show one, with the title it has at that moment:
4
+ # state { toasts = 0 : number toastTitle = "" : text }
5
+ # action saved() mutates toasts, toastTitle { toastTitle.set("Cita creada") toasts.set(toasts + 1) }
6
+ # Toaster(trigger: toasts, title: toastTitle, description: "Jueves 9 a las 10:30", action: "Deshacer", onAction: undo)
7
+ # For what the person just did on this screen (saving, saved) the Island is lighter; a toast is for news that can wait.
8
+ # shadcn-for-muten · Custom · kit.css (.pg-toasts, .pg-toast) · host JS toaster.js.
9
+ part Toaster(trigger: number, title: text, description: text = "", action: text = "", duration: number = 5000, onAction: action) {
10
+ Custom Toaster inputs(trigger: $trigger, title: $title, description: $description, action: $action, duration: $duration) on(action: $onAction) class("cx-toaster-host")
9
11
  }
@@ -1,8 +1,13 @@
1
- # Toggle Group - single-select toolbar. The PAGE owns a `text` state + an action that sets it:
1
+ # ToggleGroup - toggles in a row (shadcn Nova). One choice: ToggleGroupItem with the page's text state. Several
2
+ # choices: put Toggle parts inside, each with its own bool. spacing "0" joins them into one control; "2" separates.
2
3
  # state { align = "left" : text } action setAlign(v: text) mutates align { align.set(v) }
3
- # ToggleGroup { ToggleGroupItem(value: "left", current: align, onSelect: setAlign) { Icon "lucide:align-left" } … }
4
- # shadcn-for-muten · part · no deps · styles via the semantic .toggle-group + .toggle* classes in globals.css.
5
- part ToggleGroup() { Stack class("toggle-group") { slot } }
6
- part ToggleGroupItem(value: text, current: text, onSelect: action) {
7
- Button -> $onSelect($value) aria(pressed: $value == $current) class("toggle", "toggle-on" when $value == $current) { slot }
4
+ # ToggleGroup(label: "Alineación") { ToggleGroupItem(value: "left", current: align, onSelect: setAlign, label: "Izquierda") { Icon "lucide:align-left" } … }
5
+ # ToggleGroup(variant: "outline", spacing: "2") { Toggle(on: bold, onToggle: b, variant: "outline") { Icon "lucide:bold" } … }
6
+ # variant: default | outline · size: sm | default | lg · spacing: 0 | 1 | 2 · orientation: horizontal | vertical
7
+ # shadcn-for-muten · part · no deps · Nova (.cn-toggle-group) + kit.css (joined/vertical).
8
+ part ToggleGroup(variant: text = "default", size: text = "default", spacing: text = "0", orientation: text = "horizontal", label: text = "") {
9
+ Stack class("cn-toggle-group group/toggle-group") data(slot: "toggle-group", variant: $variant, size: $size, spacing: $spacing, orientation: $orientation) aria(role: "group", label: $label) { slot }
10
+ }
11
+ part ToggleGroupItem(value: text, current: text, onSelect: action, variant: text = "default", size: text = "default", label: text = "") {
12
+ Button -> $onSelect($value) class("cn-toggle cn-toggle-variant-{$variant} cn-toggle-size-{$size} cn-toggle-group-item") data(slot: "toggle-group-item", variant: $variant, size: $size, name: $label) aria(pressed: $value == $current, label: $label) { slot }
8
13
  }
@@ -1,6 +1,9 @@
1
- # Toggle - a two-state button. The PAGE owns the bool + the toggle action. Put an Icon or text in the slot.
2
- # state { bold = false : bool } action b mutates bold { bold.toggle() } Toggle(on: bold, onToggle: b) { Icon "lucide:bold" }
3
- # shadcn-for-muten · part · no deps · styles via the semantic .toggle* classes in globals.css.
4
- part Toggle(on: bool, onToggle: action) {
5
- Button -> $onToggle aria(pressed: $on) class("toggle", "toggle-on" when $on) { slot }
1
+ # Toggle - a button that stays pressed (bold, favourite, «solo hoy»). The page owns the bool and the action.
2
+ # state { bold = false : bool } action toggleBold() mutates bold { bold.toggle() }
3
+ # Toggle(on: bold, onToggle: toggleBold, label: "Negrita") { Icon "lucide:bold" }
4
+ # Toggle(on: today, onToggle: toggleToday, variant: "outline") { Icon "lucide:calendar" Span "Solo hoy" }
5
+ # variant: default | outline · size: sm | default | lg · label: the name of an icon-only toggle.
6
+ # shadcn-for-muten · part · no deps · Nova (.cn-toggle, pressed = aria-pressed).
7
+ part Toggle(on: bool, onToggle: action, variant: text = "default", size: text = "default", label: text = "") {
8
+ Button -> $onToggle class("cn-toggle cn-toggle-variant-{$variant} cn-toggle-size-{$size} cn-toggle-group-item") data(slot: "toggle", variant: $variant, size: $size, name: $label) aria(pressed: $on, label: $label) { slot }
6
9
  }
@@ -1,6 +1,11 @@
1
- # Toolbar - a grouped button bar with separators. Put icon buttons inside (class("btn btn-ghost btn-icon")).
2
- # Toolbar { ToolbarGroup { Button { Icon "lucide:mouse-pointer-2" } class("btn btn-ghost btn-icon") … } ToolbarSeparator ToolbarGroup { … } }
3
- # shadcn-for-muten · part · no deps · styles via the semantic .toolbar* classes in globals.css.
4
- part Toolbar() { Stack class("toolbar") { slot } }
5
- part ToolbarGroup() { Stack class("toolbar-group") { slot } }
6
- part ToolbarSeparator() { Stack class("toolbar-separator") }
1
+ # Toolbar - the tools of an editor in one bar: groups of icon buttons (Btn, Toggle, ToggleGroup) split by thin lines.
2
+ # It floats over a canvas (CanvasViewport) or sits on top of a panel.
3
+ # Toolbar(label: "Herramientas") {
4
+ # ToolbarGroup { ToggleGroup(label: "Herramienta") { ToggleGroupItem(value: "select", current: tool, onSelect: setTool, label: "Seleccionar") { Icon "lucide:mouse-pointer-2" } … } }
5
+ # ToolbarSeparator()
6
+ # ToolbarGroup { Btn(variant: "ghost", size: "icon-sm", label: "Deshacer", onClick: undo) { Icon "lucide:undo-2" } }
7
+ # }
8
+ # shadcn-for-muten · parts · no deps · kit.css (.cx-toolbar).
9
+ part Toolbar(label: text = "Herramientas") { Stack class("cx-toolbar") data(slot: "toolbar") aria(role: "toolbar", label: $label) { slot } }
10
+ part ToolbarGroup() { Stack class("cx-toolbar-group") data(slot: "toolbar-group") { slot } }
11
+ part ToolbarSeparator() { Stack class("cx-toolbar-sep") data(slot: "toolbar-separator") aria(role: "separator", orientation: "vertical") }
@@ -1,9 +1,6 @@
1
- # Tooltip - shows on hover (pure CSS, no state needed). The slot is the trigger.
2
- # Tooltip(label: "Add to library") { Button "Hover me" class("btn btn-outline") }
3
- # shadcn-for-muten · part · no deps · styles via the semantic .tooltip* classes in globals.css.
4
- part Tooltip(label: text) {
5
- Stack class("tooltip-wrap") {
6
- slot
7
- Span "{$label}" class("tooltip-content")
8
- }
9
- }
1
+ # Tooltip - a short name that shows while the pointer rests on something (or it gets the focus): what an icon-only
2
+ # button does, the full text of a cut-off line. Only a label: never a place for something the person must read.
3
+ # The kit's single tooltip (KitLayer) does it for anything with a name; Tooltip just gives that name.
4
+ # Tooltip(text: "Editar servicio") { Btn(variant: "ghost", size: "icon", onClick: edit) { Icon "lucide:pencil" } }
5
+ # A Btn with label already has one. shadcn-for-muten · part · the kit's tooltip (.cx-tip).
6
+ part Tooltip(text: text) { Stack class("cx-tip-wrap") data(slot: "tooltip-trigger", name: $text) { slot } }
@@ -67,14 +67,14 @@ export function mount(el, inputs, handlers) {
67
67
  const path = parent + '/' + node.name;
68
68
  const isFolder = Array.isArray(node.children);
69
69
  const id = idOf(node, path);
70
- const row = document.createElement('button'); row.type = 'button'; row.className = 'tree-row';
71
- row.style.paddingLeft = (depth * 16 + 6) + 'px';
72
- if (!isFolder && id === selected) row.classList.add('tree-row-selected');
73
- const chev = document.createElement('span'); chev.className = 'tree-chevron' + (isFolder ? '' : ' tree-chevron-hidden');
70
+ const row = document.createElement('button'); row.type = 'button'; row.className = 'cx-tree-row'; row.setAttribute('role', 'treeitem'); row.tabIndex = -1; row.dataset.path = path; if (isFolder) row.setAttribute('aria-expanded', expanded.has(path)); row.setAttribute('aria-level', depth + 1);
71
+ row.style.paddingLeft = (depth * 16 + 8) + 'px';
72
+ if (!isFolder && id === selected) { row.setAttribute('aria-selected', 'true'); row.tabIndex = 0; }
73
+ const chev = document.createElement('span'); chev.className = 'cx-tree-chev' + (isFolder ? '' : ' cx-tree-chev-none');
74
74
  // lucide chevron-down (open) / chevron-right (closed)
75
75
  chev.innerHTML = isFolder ? '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="' + (expanded.has(path) ? 'm6 9 6 6 6-6' : 'm9 18 6-6-6-6') + '"/></svg>' : '';
76
- const ic = document.createElement('span'); ic.className = 'tree-icon'; ic.innerHTML = isFolder ? FOLDER : FILE;
77
- const label = document.createElement('span'); label.className = 'tree-label'; label.textContent = node.name;
76
+ const ic = document.createElement('span'); ic.className = 'cx-tree-ic'; ic.innerHTML = isFolder ? FOLDER : FILE;
77
+ const label = document.createElement('span'); label.className = 'cx-tree-label'; label.textContent = node.name;
78
78
  row.appendChild(chev); row.appendChild(ic); row.appendChild(label);
79
79
  row.addEventListener('click', () => {
80
80
  if (isFolder) { expanded.has(path) ? expanded.delete(path) : expanded.add(path); } // folders: expand only
@@ -85,7 +85,27 @@ export function mount(el, inputs, handlers) {
85
85
  if (isFolder && expanded.has(path)) build(node.children, container, depth + 1, path);
86
86
  }
87
87
  };
88
- const render = () => { el.innerHTML = ''; const root = document.createElement('div'); root.className = 'tree'; build(data, root, 0, ''); el.appendChild(root); };
88
+ let focusPath = null;
89
+ const render = () => {
90
+ el.innerHTML = ''; const root = document.createElement('div'); root.className = 'cx-tree'; root.setAttribute('role', 'tree'); root.setAttribute('aria-label', inputs.label || 'Archivos');
91
+ build(data, root, 0, ''); el.appendChild(root);
92
+ const rows = [...root.querySelectorAll('.cx-tree-row')];
93
+ if (!rows.some((r) => r.tabIndex === 0) && rows[0]) rows[0].tabIndex = 0;
94
+ if (focusPath) { const r = rows.find((x) => x.dataset.path === focusPath); if (r) { rows.forEach((x) => { x.tabIndex = -1; }); r.tabIndex = 0; r.focus({ preventScroll: true }); } }
95
+ };
96
+ // keyboard (WAI tree): Up/Down move, Right opens a folder (or steps in), Left closes it (or goes to its parent), Enter/Space pick
97
+ el.addEventListener('keydown', (e) => {
98
+ const rows = [...el.querySelectorAll('.cx-tree-row')], k = rows.indexOf(document.activeElement); if (k < 0) return;
99
+ const row = rows[k], folder = row.hasAttribute('aria-expanded'), open = row.getAttribute('aria-expanded') === 'true';
100
+ const go = (r) => { if (!r) return; rows.forEach((x) => { x.tabIndex = -1; }); r.tabIndex = 0; r.focus(); focusPath = r.dataset.path; };
101
+ if (e.key === 'ArrowDown') { e.preventDefault(); go(rows[k + 1]); }
102
+ else if (e.key === 'ArrowUp') { e.preventDefault(); go(rows[k - 1]); }
103
+ else if (e.key === 'Home') { e.preventDefault(); go(rows[0]); }
104
+ else if (e.key === 'End') { e.preventDefault(); go(rows[rows.length - 1]); }
105
+ else if (e.key === 'ArrowRight' && folder) { e.preventDefault(); if (!open) { focusPath = row.dataset.path; row.click(); } else go(rows[k + 1]); }
106
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); if (folder && open) { focusPath = row.dataset.path; row.click(); } else { const parent = row.dataset.path.split('/').slice(0, -1).join('/'); go(rows.find((x) => x.dataset.path === parent)); } }
107
+ });
108
+ el.addEventListener('click', (e) => { const r = e.target.closest('.cx-tree-row'); if (r) focusPath = r.dataset.path; }, true);
89
109
 
90
110
  render();
91
111
  return (n) => {
@@ -1,14 +1,9 @@
1
- # Tree View - a file/folder tree (Custom). TWO modes:
2
- # nested (default): pass a nested `data` of { name, children }; leaves emit their "/path". Leave the keys "".
3
- # TreeView(data: fileTree, labelKey: "", valueKey: "", groupKey: "", selected: "", onSelect: pick)
4
- # flat (pass keys): feed a FLAT list (e.g. a `query` of rows) and let the tree group + key it for you -
5
- # groupKey buckets rows into folders, labelKey is the text, valueKey is the payload a leaf emits:
6
- # state { tiles = query tilesets : list<Tile> tileUrl = "" : text }
7
- # action pick(u: text) mutates tileUrl { tileUrl.set(u) }
8
- # TreeView(data: tiles, labelKey: "tname", valueKey: "url", groupKey: "folder", selected: tileUrl, onSelect: pick)
9
- # `selected` highlights + auto-opens that leaf. Folders only expand; only leaves emit select. (muten parts need
10
- # every arg, so pass "" for the keys you don't use - an empty key = nested mode.)
11
- # shadcn-for-muten · Custom · host JS is src/components/TreeView.js (copied by `muten add tree-view`).
12
- part TreeView(data: list, labelKey: text, valueKey: text, groupKey: text, selected: text, onSelect: action) {
13
- Custom TreeView inputs(data: $data, labelKey: $labelKey, valueKey: $valueKey, groupKey: $groupKey, selected: $selected) on(select: $onSelect) class("tree-view")
1
+ # TreeView - folders and files: a folder opens and closes, a file is picked. Keyboard as a real tree: Up/Down move,
2
+ # Right opens (or steps in), Left closes (or goes to the parent), Enter picks. Two ways to feed it:
3
+ # nested: data [{name, children}] - a file emits its "/path". TreeView(data: @files, onSelect: open)
4
+ # flat: a list of rows grouped by a key - groupKey makes the folders, labelKey the text, valueKey what a file emits.
5
+ # TreeView(data: @tiles, labelKey: "name", valueKey: "url", groupKey: "folder", selected: tileUrl, onSelect: pick)
6
+ # selected highlights that file and opens the folders above it. shadcn-for-muten · Custom · kit.css (.cx-tree) · tree-view.js.
7
+ part TreeView(data: list, labelKey: text = "", valueKey: text = "", groupKey: text = "", selected: text = "", label: text = "Archivos", onSelect: action) {
8
+ Custom TreeView inputs(data: $data, labelKey: $labelKey, valueKey: $valueKey, groupKey: $groupKey, selected: $selected, label: $label) on(select: $onSelect) class("cx-tree-host")
14
9
  }
@@ -1,10 +1,11 @@
1
- # Vector Field - paired NumberFields (X / Y) for position or size. The PAGE owns two number states + two actions:
2
- # state { px = 0 : number py = 0 : number } action setX(v: number) mutates px { px.set(v) } action setY(v: number) mutates py { py.set(v) }
3
- # VectorField(x: px, y: py, min: -9999, max: 9999, step: 1, onX: setX, onY: setY)
4
- # shadcn-for-muten · part · depends on number-field · styles via the semantic .vector* classes.
5
- part VectorField(x: number, y: number, min: number, max: number, step: number, onX: action, onY: action) {
6
- Stack class("vector-field") {
7
- Stack class("vector-axis") { Span "X" class("vector-axis-label") NumberField(value: $x, min: $min, max: $max, step: $step, onInput: $onX) }
8
- Stack class("vector-axis") { Span "Y" class("vector-axis-label") NumberField(value: $y, min: $min, max: $max, step: $step, onInput: $onY) }
1
+ # VectorField - two numbers that go together: a position (X, Y) or a size (W, H), each a NumberField with its axis
2
+ # inside. The page owns both values and both actions.
3
+ # VectorField(x: px, y: py, min: 0, max: 1920, onX: setX, onY: setY)
4
+ # VectorField(x: w, y: h, min: 1, max: 4096, xLabel: "W", yLabel: "H", unit: "px", onX: setW, onY: setH)
5
+ # shadcn-for-muten · part over NumberField · kit.css (.cx-vector).
6
+ part VectorField(x: number, y: number, min: number, max: number, step: number = 1, xLabel: text = "X", yLabel: text = "Y", unit: text = "", onX: action, onY: action) {
7
+ Stack class("cx-vector") {
8
+ NumberField(value: $x, min: $min, max: $max, step: $step, prefix: $xLabel, unit: $unit, onInput: $onX)
9
+ NumberField(value: $y, min: $min, max: $max, step: $step, prefix: $yLabel, unit: $unit, onInput: $onY)
9
10
  }
10
11
  }
@@ -0,0 +1,5 @@
1
+ // VirtualList (muten Custom) - only the visible rows exist in the DOM (kit/collections.js virtualList).
2
+ export function mount(el, inputs) {
3
+ Promise.all([import('@muten/shadcn/registry/kit/core.js'), import('@muten/shadcn/registry/kit/collections.js')]).then(([core, c]) => c.virtualList(el, inputs, core));
4
+ return () => {};
5
+ }
@@ -0,0 +1,6 @@
1
+ # VirtualList - thousands of rows, only the visible ones (+ a margin) in the DOM; arrows, Page Up/Down, Home/End.
2
+ # VirtualList(rows: @clients, label: "Clientes", jump: 5000) rows: {title, subtitle, value}
3
+ # total > rows.length repeats the rows up to it (a long demo). shadcn-for-muten · Custom (kit collections).
4
+ part VirtualList(rows: list, total: number = 0, rowHeight: number = 52, label: text = "Lista", jump: number = 0) {
5
+ Custom VirtualList inputs(rows: $rows, total: $total, rowHeight: $rowHeight, label: $label, jump: $jump) class("cx-vl-host")
6
+ }
@@ -0,0 +1,4 @@
1
+ # WeeklyHours - when the business opens: a switch per day, ranges with the wheel time picker, add a range, copy to all.
2
+ # WeeklyHours(days: @week, onChange: saveHours) days: {name, open, ranges [["09:00", "13:00"], ["14:00", "19:00"]]}
3
+ # shadcn-for-muten · part over KitWidget (kit core).
4
+ part WeeklyHours(days: list, onChange: action) { Custom KitWidget inputs(name: "weeklyHours", days: $days) on(action: $onChange) class("cx-wh-host") }
@@ -0,0 +1,6 @@
1
+ # WordLine - a line of words set as big type: who a product is for, what a shop does. The words sit in a soft grey,
2
+ # joined by dots in ink; a Word with tone "strong" stands out in ink too. It wraps like text.
3
+ # WordLine(label: "Para quién es") { Word(label: "Barberías") Word(label: "Clases", tone: "strong") Word(label: "Spa") }
4
+ # shadcn-for-muten · parts · no deps · kit.css (.cx-wordline).
5
+ part WordLine(label: text = "") { Stack class("cx-wordline") aria(label: $label) { slot } }
6
+ part Word(label: text, tone: text = "") { Span "{$label}" class("cx-word") data(tone: $tone) }
@@ -1,11 +1,11 @@
1
- # Zoom Control - a - 100% + widget. The PAGE owns the zoom number + the -/+ actions:
2
- # state { zoom = 100 : number } action zOut mutates zoom { zoom.set(zoom - 10) } action zIn mutates zoom { zoom.set(zoom + 10) }
3
- # ZoomControl(value: zoom, onOut: zOut, onIn: zIn)
4
- # shadcn-for-muten · part · no deps · styles via the semantic .zoom-* classes in globals.css.
5
- part ZoomControl(value: number, onOut: action, onIn: action) {
6
- Stack class("zoom-control") {
7
- Button -> $onOut class("zoom-btn") { Icon "lucide:minus" class("size-4") }
8
- Span "{$value}%" class("zoom-value")
9
- Button -> $onIn class("zoom-btn") { Icon "lucide:plus" class("size-4") }
1
+ # ZoomControl - minus, the zoom in percent, plus. Tapping the number puts it back to 100 %. The page owns the number
2
+ # and the three actions (and the limits: switch a side off at its end with Btn's own rules).
3
+ # ZoomControl(value: zoom, onOut: zoomOut, onIn: zoomIn, onReset: zoomReset)
4
+ # shadcn-for-muten · part · no deps · kit.css (.cx-zoom).
5
+ part ZoomControl(value: number, onOut: action, onIn: action, onReset: action) {
6
+ Stack class("cx-zoom") data(slot: "zoom-control") aria(role: "group", label: "Zoom") {
7
+ Button -> $onOut class("cn-button cn-button-variant-ghost cn-button-size-icon-sm group/button") data(name: "Alejar") aria(label: "Alejar") { Icon "lucide:minus" }
8
+ Button "{$value} %" -> $onReset class("cn-button cn-button-variant-ghost cn-button-size-sm group/button cx-zoom-value") data(name: "Volver a 100 %") aria(label: "Zoom {$value} %, volver a 100")
9
+ Button -> $onIn class("cn-button cn-button-variant-ghost cn-button-size-icon-sm group/button") data(name: "Acercar") aria(label: "Acercar") { Icon "lucide:plus" }
10
10
  }
11
11
  }