@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
@@ -1,19 +1,27 @@
1
- # AppSidebar - a collapsible nav panel (named AppSidebar, NOT Sidebar, so it never shadows muten's core `Sidebar`
2
- # <aside> primitive). The PAGE owns a collapsed bool + the active item:
3
- # state { collapsed = false : bool nav = "sprites" : text } action setNav(v: text) mutates nav { nav.set(v) }
4
- # AppSidebar(collapsed: collapsed) {
5
- # SidebarHeader { Span "Studio" }
6
- # SidebarGroup { SidebarGroupLabel(label: "Assets")
7
- # SidebarItem(label: "Sprites", value: "sprites", current: nav, onSelect: setNav) { Icon "lucide:image" class("size-4 shrink-0") } … }
1
+ # AppSidebar - a side menu of muten parts (shadcn Nova's Sidebar): a header, groups with their label, items with an
2
+ # icon and a count, a footer. It folds to icons only (the names show on hover). The page owns where you are and
3
+ # whether it is folded. (AppShell is the whole frame - sidebar, header, sheet, ⌘K - built from data.)
4
+ # AppSidebar(collapsed: folded) {
5
+ # SidebarHeader { … }
6
+ # SidebarGroup(label: "Principal") { SidebarItem(label: "Agenda", value: "agenda", current: place, count: "14", onSelect: go) { Icon "lucide:calendar" } }
7
+ # SidebarFooter { … }
8
8
  # }
9
- # shadcn-for-muten · part · no deps · styles via the semantic .sidebar* classes in globals.css.
10
- part AppSidebar(collapsed: bool) { Stack class("sidebar", "sidebar-collapsed" when $collapsed) { slot } }
11
- part SidebarHeader() { Stack class("sidebar-header") { slot } }
12
- part SidebarGroup() { Stack class("sidebar-group") { slot } }
13
- part SidebarGroupLabel(label: text) { Span "{$label}" class("sidebar-group-label") }
14
- part SidebarItem(label: text, value: text, current: text, onSelect: action) {
15
- Button -> $onSelect($value) aria(pressed: $value == $current) class("sidebar-item", "sidebar-item-active" when $value == $current) {
9
+ # The name AppSidebar avoids muten's own Sidebar primitive. shadcn-for-muten · parts · no deps · Nova (.cn-sidebar-*) + kit.css.
10
+ part AppSidebar(collapsed: bool, label: text = "Menú") {
11
+ Nav class("cx-sidebar") data(slot: "sidebar", collapsed: $collapsed) aria(label: $label) { Stack class("cn-sidebar-inner cx-sb-inner") data(slot: "sidebar-inner") { slot } }
12
+ }
13
+ part SidebarHeader() { Stack class("cn-sidebar-header cx-sb-head") data(slot: "sidebar-header") { slot } }
14
+ part SidebarFooter() { Stack class("cn-sidebar-footer cx-sb-foot") data(slot: "sidebar-footer") { slot } }
15
+ part SidebarGroup(label: text = "") {
16
+ Stack class("cn-sidebar-group cx-sb-group") data(slot: "sidebar-group") aria(role: "group", label: $label) {
17
+ Span "{$label}" class("cn-sidebar-group-label cx-sb-glabel") data(slot: "sidebar-group-label")
18
+ Stack class("cn-sidebar-menu cx-sb-menu") data(slot: "sidebar-menu") { slot }
19
+ }
20
+ }
21
+ part SidebarItem(label: text, value: text, current: text, onSelect: action, count: text = "") {
22
+ Button -> $onSelect($value) class("cn-sidebar-menu-button cn-sidebar-menu-button-variant-default cn-sidebar-menu-button-size-default cx-sb-item") data(slot: "sidebar-menu-button", active: $value == $current, name: $label) aria(current: $value == $current) {
16
23
  slot
17
- Span "{$label}" class("sidebar-item-label truncate")
24
+ Span "{$label}" class("cx-sb-label")
25
+ Span "{$count}" class("cn-sidebar-menu-badge cx-sb-count") data(slot: "sidebar-menu-badge")
18
26
  }
19
27
  }
@@ -1,4 +1,6 @@
1
- # Skeleton - a loading placeholder. Add the size utilities via class (paramless part, so class() works):
2
- # Skeleton class("h-4 w-32") · Skeleton class("size-10 rounded-full")
3
- # shadcn-for-muten · part · no deps · uses .skeleton (pulse) from globals.css.
4
- part Skeleton() { Stack class("skeleton") { slot } }
1
+ # Skeleton - the shape of content that has not arrived. Size it with class(); shape: "" | circle | pill.
2
+ # Skeleton(shape: "circle") class("size-8") Skeleton() class("h-3 w-2/3")
3
+ # Build a whole placeholder from the SAME parts as the content (an Item with Skeletons in place of the text), so the
4
+ # swap changes pixels, not layout. Only while there is nothing to show: a refresh keeps the content.
5
+ # shadcn-for-muten · part · no deps · Nova (.cn-skeleton) + kit.css (shimmer).
6
+ part Skeleton(shape: text = "") { Stack class("cn-skeleton") data(slot: "skeleton", shape: $shape) aria(hidden: "true") }
@@ -1,31 +1,77 @@
1
- // Slider host component (muten Custom). inputs: { value, min, max }. handlers: { input(value) }.
2
- // Returns a reactive updater so the thumb follows the page's value when it changes elsewhere.
3
- export function mount(el, inputs, handlers) {
4
- const min = Number(inputs.min ?? 0);
5
- const max = Number(inputs.max ?? 100);
6
- const range = document.createElement('div'); range.className = 'slider-range';
7
- const thumb = document.createElement('div'); thumb.className = 'slider-thumb';
8
- el.append(range, thumb);
9
-
10
- let value = Number(inputs.value ?? min);
11
- const pct = (v) => (max === min ? 0 : ((v - min) / (max - min)) * 100);
12
- const paint = (v) => { const p = pct(v); range.style.width = p + '%'; thumb.style.left = p + '%'; };
13
- paint(value);
14
-
15
- const setFromX = (clientX) => {
16
- const r = el.getBoundingClientRect();
17
- if (!r.width) return;
18
- const ratio = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
19
- const v = Math.round(min + ratio * (max - min));
20
- if (v !== value) { value = v; paint(v); if (handlers.input) handlers.input(v); }
1
+ // The app-wide slider: a range input with a value bubble that follows the thumb and a filled track.
2
+ // A native muten `Range` can't render a following bubble or a cross-browser fill, so this is a Custom
3
+ // (a real widget the DSL can't express). Every slider in the app uses THIS, so they all look the same.
4
+ //
5
+ // inputs.value = current number (snapshot at mount; the returned updater syncs later external changes)
6
+ // inputs.min / inputs.max / inputs.step = numeric bounds (default 0 / 100 / 1)
7
+ // inputs.suffix = text appended in the bubble (e.g. "%"); default ""
8
+ // on.change(n) = fires with the NUMBER while dragging. The value is clamped to [min, max].
9
+ //
10
+ // Styling lives in styles.css (.mslider*); this file only wires behavior.
11
+
12
+ function sliderMount(el, inputs, on) {
13
+ const readNum = (v, fallback) => {
14
+ const n = Number(v);
15
+ return Number.isFinite(n) ? n : fallback;
16
+ };
17
+ const min = readNum(inputs && inputs.min, 0);
18
+ const max = readNum(inputs && inputs.max, 100);
19
+ const step = readNum(inputs && inputs.step, 1);
20
+ const suffix = String((inputs && inputs.suffix != null ? inputs.suffix : ''));
21
+
22
+ const clamp = (v) => {
23
+ if (!Number.isFinite(v)) return min;
24
+ return v < min ? min : (v > max ? max : v);
21
25
  };
26
+ let value = clamp(readNum(inputs && inputs.value, min));
22
27
 
23
- let dragging = false;
24
- el.addEventListener('pointerdown', (e) => { dragging = true; el.setPointerCapture(e.pointerId); setFromX(e.clientX); });
25
- el.addEventListener('pointermove', (e) => { if (dragging) setFromX(e.clientX); });
26
- const stop = () => { dragging = false; };
27
- el.addEventListener('pointerup', stop);
28
- el.addEventListener('pointercancel', stop);
28
+ el.innerHTML = '';
29
+ el.classList.add('mslider');
30
+ const bubble = document.createElement('span');
31
+ bubble.className = 'mslider-bubble';
32
+ const input = document.createElement('input');
33
+ input.type = 'range';
34
+ input.className = 'mslider-input';
35
+ input.min = String(min);
36
+ input.max = String(max);
37
+ input.step = String(step);
38
+ input.setAttribute('aria-label', 'Deslizador');
39
+ el.append(bubble, input);
29
40
 
30
- return (next) => { const v = Number(next.value ?? value); if (v !== value) { value = v; paint(v); } };
41
+ const pct = () => (max === min ? 0 : ((value - min) / (max - min)) * 100);
42
+
43
+ const paint = () => {
44
+ input.value = String(value);
45
+ const p = pct();
46
+ input.style.setProperty('--p', p + '%');
47
+ bubble.textContent = value + suffix;
48
+ // Keep the bubble centered over the thumb, accounting for the thumb width.
49
+ const w = input.getBoundingClientRect().width;
50
+ if (w > 0) {
51
+ const thumb = 21;
52
+ bubble.style.left = ((p / 100) * (w - thumb) + thumb / 2) + 'px';
53
+ }
54
+ };
55
+ paint();
56
+ // The width can be 0 when mounted inside a panel that is still opening; repaint once it has laid out.
57
+ requestAnimationFrame(paint);
58
+
59
+ input.addEventListener('input', () => {
60
+ value = clamp(readNum(input.value, min));
61
+ paint();
62
+ if (on && on.change) on.change(value);
63
+ });
64
+ window.addEventListener('resize', paint);
65
+ // the bubble needs the real width: repaint whenever the slider gets laid out or resized (not only on input)
66
+ if (window.ResizeObserver) new ResizeObserver(paint).observe(input);
67
+
68
+ return (next) => {
69
+ if (next && next.value != null) {
70
+ const v = clamp(readNum(next.value, value));
71
+ if (v !== value) { value = v; paint(); }
72
+ }
73
+ };
31
74
  }
75
+
76
+ // muten Custom entry: the plugin's Slider keeps its `input` handler name; Storio's slider calls `change`.
77
+ export function mount(el, inputs, on) { el.dataset.bubble = inputs.bubble || 'always'; return sliderMount(el, inputs, { change: (n) => (on.input || on.change)?.(n) }); }
@@ -1,7 +1,7 @@
1
- # Slider - a draggable range control. The PAGE owns the value (number) + the action that sets it:
2
- # state { vol = 50 : number } action setVol(v: number) mutates vol { vol.set(v) }
3
- # Slider(value: vol, min: 0, max: 100, onInput: setVol)
4
- # shadcn-for-muten · Custom · the host JS is src/components/Slider.js (copied by `muten add slider`).
5
- part Slider(value: number, min: number, max: number, onInput: action) {
6
- Custom Slider inputs(value: $value, min: $min, max: $max) on(input: $onInput) class("slider")
1
+ # Slider - Storio's slider: a range with the value in a bubble that follows the thumb and a filled track.
2
+ # Slider(value: deposit, min: 10, max: 100, step: 10, suffix: " %", onInput: setDeposit)
3
+ # bubble: always | drag (only while moving or focused) | off.
4
+ # shadcn-for-muten · Custom · host JS slider.js (Storio's Slider, unchanged).
5
+ part Slider(value: number, min: number = 0, max: number = 100, step: number = 1, suffix: text = "", bubble: text = "always", onInput: action) {
6
+ Custom Slider inputs(value: $value, min: $min, max: $max, step: $step, suffix: $suffix, bubble: $bubble) on(input: $onInput) class("cx-mslider-host")
7
7
  }
@@ -0,0 +1,43 @@
1
+ // SortableBehavior (muten Custom) - reorder the Items of a Sortable: pointer (mouse + touch) on the grip, the others
2
+ // slide to make room; or focus the grip and use Up/Down. Announces «<title>: posición n de m» and emits the keys in
3
+ // their new order. Same feel as the reference artifact's sortable list.
4
+ export function mount(el, inputs, on) {
5
+ const sort = el.parentElement;
6
+ const live = document.createElement('span'); live.className = 'cx-sr'; live.setAttribute('aria-live', 'polite'); sort.after(live);
7
+ const items = () => [...sort.querySelectorAll(':scope > .cx-item')];
8
+ sort.querySelectorAll('.cx-grip').forEach((g) => { g.tabIndex = 0; g.title = 'Arrastra para ordenar'; });
9
+ const done = (item) => {
10
+ const all = items(); live.textContent = `${item.querySelector('.cn-item-title')?.textContent || ''}: posición ${all.indexOf(item) + 1} de ${all.length}`;
11
+ on.reorder?.(all.map((x) => x.dataset.key).join(','));
12
+ };
13
+ sort.addEventListener('pointerdown', (e) => {
14
+ const grip = e.target.closest('.cx-grip'); if (!grip) return;
15
+ e.preventDefault();
16
+ const item = grip.closest('.cx-item'), all = items();
17
+ const rects = all.map((x) => x.getBoundingClientRect()), start = e.clientY, from = all.indexOf(item);
18
+ const step = rects[1] ? rects[1].top - rects[0].top : rects[0].height + 8;
19
+ let to = from;
20
+ item.classList.add('lifting'); grip.setPointerCapture(e.pointerId);
21
+ const move = (ev) => {
22
+ const dy = ev.clientY - start; item.style.transform = `translateY(${dy}px)`;
23
+ to = Math.max(0, Math.min(all.length - 1, from + Math.round(dy / step)));
24
+ all.forEach((x, i) => { if (x === item) return; let shift = 0; if (from < to && i > from && i <= to) shift = -step; if (from > to && i < from && i >= to) shift = step; x.style.transform = shift ? `translateY(${shift}px)` : ''; });
25
+ };
26
+ const up = () => {
27
+ grip.removeEventListener('pointermove', move); grip.removeEventListener('pointerup', up); grip.removeEventListener('pointercancel', up);
28
+ all.forEach((x) => { x.style.transition = 'none'; x.style.transform = ''; });
29
+ const ref = all[to]; if (to !== from) sort.insertBefore(item, from < to ? ref.nextSibling : ref);
30
+ item.classList.remove('lifting'); void sort.offsetWidth; all.forEach((x) => { x.style.transition = ''; });
31
+ if (to !== from) done(item);
32
+ };
33
+ grip.addEventListener('pointermove', move); grip.addEventListener('pointerup', up); grip.addEventListener('pointercancel', up);
34
+ });
35
+ sort.addEventListener('keydown', (e) => {
36
+ const grip = e.target.closest('.cx-grip'); if (!grip || !['ArrowUp', 'ArrowDown'].includes(e.key)) return;
37
+ e.preventDefault(); const item = grip.closest('.cx-item');
38
+ if (e.key === 'ArrowUp' && item.previousElementSibling?.classList.contains('cx-item')) sort.insertBefore(item, item.previousElementSibling);
39
+ else if (e.key === 'ArrowDown' && item.nextElementSibling?.classList.contains('cx-item')) sort.insertBefore(item.nextElementSibling, item);
40
+ else return;
41
+ grip.focus(); done(item);
42
+ });
43
+ }
@@ -0,0 +1,2 @@
1
+ # SortableBehavior - the drag (pointer + keys) of a Sortable. Used by Sortable. shadcn-for-muten · Custom.
2
+ part SortableBehavior(onReorder: action) { Custom SortableBehavior on(reorder: $onReorder) class("cx-sortable-behavior") }
@@ -1,27 +1,11 @@
1
- // SortableList host (muten Custom). inputs: { items (comma-joined) }. handlers: { reorder(commaJoined) }.
2
- // Drag a row by its grip to reorder; emits the new order. The page owns a comma-joined text (e.g. layer order).
3
- export function mount(el, inputs, handlers) {
4
- const GRIP = '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.4"/><circle cx="9" cy="12" r="1.4"/><circle cx="9" cy="18" r="1.4"/><circle cx="15" cy="6" r="1.4"/><circle cx="15" cy="12" r="1.4"/><circle cx="15" cy="18" r="1.4"/></svg>';
5
- const parse = (v) => String(v == null ? '' : v).split(',').map((s) => s.trim()).filter(Boolean);
6
- let items = parse(inputs.items);
7
- let dragIdx = -1;
8
-
9
- const emit = () => { if (handlers.reorder) handlers.reorder(items.join(',')); };
10
- const render = () => {
11
- el.innerHTML = '';
12
- items.forEach((it, i) => {
13
- const row = document.createElement('div'); row.className = 'sortable-row'; row.draggable = true;
14
- const handle = document.createElement('span'); handle.className = 'sortable-handle'; handle.innerHTML = GRIP;
15
- const label = document.createElement('span'); label.className = 'sortable-label'; label.textContent = it;
16
- row.appendChild(handle); row.appendChild(label);
17
- row.addEventListener('dragstart', () => { dragIdx = i; row.classList.add('sortable-dragging'); });
18
- row.addEventListener('dragend', () => row.classList.remove('sortable-dragging'));
19
- row.addEventListener('dragover', (e) => e.preventDefault());
20
- row.addEventListener('drop', (e) => { e.preventDefault(); if (dragIdx >= 0 && dragIdx !== i) { const [m] = items.splice(dragIdx, 1); items.splice(i, 0, m); dragIdx = -1; render(); emit(); } });
21
- el.appendChild(row);
22
- });
23
- };
24
-
25
- render();
26
- return (n) => { items = parse(n.items); render(); };
1
+ // SortableList (muten Custom) - a list of plain names you reorder (layers, steps), from comma-joined text. It draws
2
+ // the same rows as Sortable and lends them Sortable's behaviour (grip, pointer, Up/Down, the announcement).
3
+ // inputs: items ("Fondo,Jugador,Interfaz"), label. handlers: reorder(the names in their new order, comma-joined).
4
+ export function mount(el, inputs, on) {
5
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
6
+ const GRIP = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="9" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>';
7
+ const names = String(inputs.items || '').split(',').map((s) => s.trim()).filter(Boolean);
8
+ el.innerHTML = `<div class="cx-sortable" aria-label="${esc(inputs.label || 'Orden')}">${names.map((n) => `<div class="cn-item cn-item-variant-outline cn-item-size-sm group/item cx-item" data-slot="item" data-key="${esc(n)}"><span class="cn-button cn-button-variant-ghost cn-button-size-icon-xs cx-grip" role="button" aria-label="Mover ${esc(n)}" data-grip="true">${GRIP}</span><div class="cn-item-content"><span class="cn-item-title">${esc(n)}</span></div></div>`).join('')}<span class="cx-sl-anchor"></span></div>`;
9
+ import('@muten/shadcn/registry/sortable-behavior.js').then((b) => b.mount(el.querySelector('.cx-sl-anchor'), {}, { reorder: (v) => on.reorder?.(v) }));
10
+ return () => {};
27
11
  }
@@ -1,7 +1,6 @@
1
- # Sortable List - drag rows by the grip to reorder (layers, steps). The PAGE owns a comma-joined order:
2
- # state { layers = "Background,Player,UI" : text } action reorder(v: text) mutates layers { layers.set(v) }
3
- # SortableList(items: layers, onReorder: reorder)
4
- # shadcn-for-muten · Custom · host JS is src/components/SortableList.js (copied by `muten add sortable-list`).
5
- part SortableList(items: text, onReorder: action) {
6
- Custom SortableList inputs(items: $items) on(reorder: $onReorder) class("sortable-list")
7
- }
1
+ # SortableList - plain names you put in order (the layers of a design, the steps of a flow) from comma-joined text:
2
+ # the same rows and feel as Sortable (the grip, the others making room, Up/Down on the focused grip).
3
+ # state { layers = "Fondo,Jugador,Interfaz" : text } action reorder(v: text) mutates layers { layers.set(v) }
4
+ # SortableList(items: layers, label: "Capas", onReorder: reorder)
5
+ # For rows with a price or details use Sortable with SortItem. shadcn-for-muten · Custom · sortable-list.js.
6
+ part SortableList(items: text, label: text = "Orden", onReorder: action) { Custom SortableList inputs(items: $items, label: $label) on(reorder: $onReorder) class("cx-slist-host") }
@@ -0,0 +1,15 @@
1
+ # Sortable - Items you reorder: take the grip with a mouse or a finger, or focus it and use the arrows. The others
2
+ # make room as you drag; a screen reader hears the new position. onReorder gets the keys in their new order
3
+ # ("cut,beard,trim") so the page can save it.
4
+ # Sortable(label: "Orden de los servicios en tu tienda", onReorder: saveOrder) {
5
+ # SortItem(key: "cut", title: "Corte clásico", price: "$25.000")
6
+ # }
7
+ # shadcn-for-muten · part + Custom (SortableBehavior) · kit.css (.cx-sortable).
8
+ part Sortable(label: text, onReorder: action) { Stack class("cx-sortable") aria(label: $label) { slot SortableBehavior(onReorder: $onReorder) } }
9
+ part SortItem(key: text, title: text, price: text = "") {
10
+ Stack class("cn-item cn-item-variant-outline cn-item-size-sm group/item cx-item") data(slot: "item", key: $key) {
11
+ Stack class("cn-button cn-button-variant-ghost cn-button-size-icon-xs cx-grip") aria(role: "button", label: "Mover {$title}") data(grip: "true") { Icon "lucide:grip-vertical" }
12
+ Stack class("cn-item-content") data(slot: "item-content") { Span "{$title}" class("cn-item-title") data(slot: "item-title") }
13
+ Span "{$price}" class("cx-price")
14
+ }
15
+ }
@@ -0,0 +1,14 @@
1
+ // Spark (muten Custom) - a tiny line for a series, the kit's sparkline. inputs: series "3,5,4,6", tone ok|bad|info|wait.
2
+ // Same drawing as the artifact's kit.stat: 100x28 viewBox, stretched, a 2px non-scaling stroke in the tone's color.
3
+ export function mount(el, inputs) {
4
+ const TONE = { ok: 'success', bad: 'destructive', info: 'info', wait: 'warning' };
5
+ const draw = (series, tone) => {
6
+ const values = String(series || '').split(',').map(Number).filter((v) => !Number.isNaN(v));
7
+ if (values.length < 2) { el.innerHTML = ''; return; }
8
+ const max = Math.max(...values), min = Math.min(...values), w = 100, h = 28;
9
+ const points = values.map((v, i) => `${(i / (values.length - 1)) * w},${h - ((v - min) / (max - min || 1)) * (h - 4) - 2}`).join(' ');
10
+ el.innerHTML = `<svg class="cx-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true"><polyline points="${points}" fill="none" stroke="var(--${TONE[tone] || 'success'})" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round"/></svg>`;
11
+ };
12
+ draw(inputs.series, inputs.tone);
13
+ return (next) => draw(next.series, next.tone);
14
+ }
@@ -0,0 +1,3 @@
1
+ # Spark - a tiny line for a series of numbers (the StatCard's). Spark(series: "3,5,4,6,5,8,9", tone: "ok")
2
+ # tone: ok | bad | info | wait. shadcn-for-muten · Custom · host JS is spark.js.
3
+ part Spark(series: text, tone: text = "ok") { Custom Spark inputs(series: $series, tone: $tone) class("cx-spark-host") }
@@ -1,3 +1,4 @@
1
- # Spinner - a loading indicator (shadcn uses the lucide loader, spun with animate-spin).
2
- # shadcn-for-muten · part · no deps · styles via the semantic .spinner class in globals.css.
3
- part Spinner() { Icon "lucide:loader-circle" class("spinner") }
1
+ # Spinner - a loading mark for a button or a line, never for a whole screen (that is a Skeleton).
2
+ # Btn(onClick: save) disabled… { Spinner() Span "Guardando…" }
3
+ # shadcn-for-muten · part · no deps · Nova (.cn-spinner).
4
+ part Spinner() { Icon "lucide:loader-circle" class("cn-spinner animate-spin") aria(label: "Cargando", role: "status") }
@@ -1,12 +1,14 @@
1
- # Split Button - a main action + a caret that opens a dropdown. The PAGE owns the main action + the open bool:
2
- # state { open = false : bool } action t mutates open { open.toggle() }
3
- # SplitButton(label: "Export", open: open, onMain: doExport, onToggle: t) { DropdownItem(label: "Export as PNG", onSelect: …) … }
4
- # shadcn-for-muten · part · no deps · reuses the .btn + .dropdown* classes.
5
- part SplitButton(label: text, open: bool, onMain: action, onToggle: action) {
6
- Stack class("split-button") {
7
- Button "{$label}" -> $onMain class("btn btn-default split-button-main")
8
- Button -> $onToggle aria(expanded: $open) class("btn btn-default split-button-caret") { Icon "lucide:chevron-down" class("size-4") }
9
- when $open { Button -> $onToggle class("dropdown-backdrop") }
10
- Stack class("dropdown-content", "dropdown-content-open" when $open) { slot }
1
+ # SplitButton - one main action and, joined to it, a chevron with the other ways to do it (save / save as draft).
2
+ # The main action runs on its own; the menu takes DropdownItem parts.
3
+ # SplitButton(label: "Publicar", onClick: publish) { DropdownItem(label: "Guardar como borrador", onSelect: draft) DropdownItem(label: "Programar…", onSelect: schedule) }
4
+ # variant: as Btn. shadcn-for-muten · part over ButtonGroup + DropdownMenu.
5
+ part SplitButton(label: text, onClick: action, variant: text = "default", size: text = "default", menu: text = "Más opciones") {
6
+ ButtonGroup(label: $label) {
7
+ Btn(variant: $variant, size: $size, onClick: $onClick) { Span "{$label}" }
8
+ ButtonGroupSeparator()
9
+ DropdownMenu {
10
+ DropdownTrigger { TriggerBtn(variant: $variant, size: "icon", label: $menu) class("cx-split-caret") { Icon "lucide:chevron-down" } }
11
+ DropdownContent(align: "end", width: "14rem") { slot }
12
+ }
11
13
  }
12
14
  }
@@ -0,0 +1,7 @@
1
+ # StampCard - a loyalty card: stamps toward a reward (good for any shop).
2
+ # StampCard(store: "Barbería El Roble", owner: "Laura", total: 10, stamps: 7, reward: "un corte gratis")
3
+ # demo "on": it stamps itself one visit at a time and starts over, for a landing; the first touch stops it.
4
+ # shadcn-for-muten · part over KitWidget (kit core).
5
+ part StampCard(store: text, owner: text, total: number = 10, stamps: number, reward: text, demo: text = "") {
6
+ Custom KitWidget inputs(name: "stampCard", store: $store, owner: $owner, total: $total, stamps: $stamps, reward: $reward, demo: $demo) class("cx-stamp-host")
7
+ }
@@ -1,9 +1,6 @@
1
- # StatBand - a divided row of stats (KPIs), uniform + responsive (2-up on mobile). Each Stat holds its value in
2
- # the slot (a Span, or a `Custom CountUp` for the count-up animation) plus a label:
3
- # StatBand {
4
- # Stat(label: "trees planted") { Custom CountUp inputs(to: "2.4M") class("stat-value") }
5
- # Stat(label: "countries") { Span "58" class("stat-value") }
6
- # }
7
- # shadcn-for-muten · block · no deps · uses .stat-band / .stat / .stat-value / .stat-label from globals.css.
8
- part StatBand() { Stack class("stat-band") { slot } }
9
- part Stat(label: text) { Stack class("stat") { slot Span "{$label}" class("stat-label") } }
1
+ # StatBand - a row of big numbers split by hairlines (two per row on a phone). Each Stat holds its value in the slot
2
+ # (a Span, or a CountUp that counts up when it comes into view) and its label under it.
3
+ # StatBand { Stat(label: "citas al mes") { CountUp(to: "12,400") } Stat(label: "tiendas") { Span "850" } }
4
+ # shadcn-for-muten · parts · no deps · kit.css (.cx-statband).
5
+ part StatBand() { Stack class("cx-statband") { slot } }
6
+ part Stat(label: text) { Stack class("cx-statband-cell") { Stack class("cx-statband-value") { slot } Span "{$label}" class("cx-statband-label") } }
@@ -0,0 +1,23 @@
1
+ # StatCard - one number for any use (sales, bookings, wait time…): label, the value, the change against a period and
2
+ # its line. The icon goes in the slot (or leave it empty). `tone` says if the change is good (ok) or bad (bad) -
3
+ # for a cost or a wait, going down is the good one.
4
+ # StatCard(label: "Ventas", value: "$1.284.000", delta: "▲ 12 %", tone: "ok", compare: "vs. semana pasada", series: "3,5,4,6,5,8,9") { Icon "lucide:credit-card" }
5
+ # Lay several in a StatGrid. Spark draws the series alone: Spark(series: "3,5,4", tone: "success").
6
+ # shadcn-for-muten · part + Custom (Spark.js) · kit.css (.cx-stat).
7
+ part StatCard(label: text, value: text, delta: text, tone: text = "ok", compare: text = "", series: text = "") {
8
+ Section class("cn-card cx-stat") data(slot: "card", size: "sm") {
9
+ Stack class("cx-stat-body") {
10
+ Stack class("cx-stat-row") {
11
+ Span "{$label}" class("cx-text") data(slot: "text", variant: "muted")
12
+ Stack class("cx-stat-ic") { slot }
13
+ }
14
+ Span "{$value}" class("cx-text") data(slot: "text", variant: "title")
15
+ Stack class("cx-stat-row") {
16
+ Span "{$delta}" class("cn-badge cn-badge-variant-ghost group/badge") data(slot: "badge", variant: "status", tone: $tone)
17
+ Span "{$compare}" class("cx-text") data(slot: "text", variant: "caption")
18
+ }
19
+ Spark(series: $series, tone: $tone)
20
+ }
21
+ }
22
+ }
23
+ part StatGrid() { Stack class("cx-stats") { slot } }
@@ -1,6 +1,7 @@
1
- # Status Bar - a thin bottom bar of status items (zoom %, coords, dimensions). Use StatusSpacer to push items apart.
2
- # StatusBar { StatusItem(label: "Ready") StatusSpacer StatusItem(label: "32 x 32") StatusItem(label: "100%") }
3
- # shadcn-for-muten · part · no deps · styles via the semantic .status-* classes in globals.css.
4
- part StatusBar() { Stack class("status-bar") { slot } }
5
- part StatusItem(label: text) { Span "{$label}" class("status-item") }
6
- part StatusSpacer() { Stack class("status-spacer") }
1
+ # StatusBar - the thin line under an editor that says how things stand: the tool, the position, the size, the zoom,
2
+ # whether it is saved. StatusSpacer pushes what follows to the right; an item can carry an icon.
3
+ # StatusBar { StatusItem(label: "Listo") { Icon "lucide:circle-check" } StatusSpacer() StatusItem(label: "1080 × 1350") StatusItem(label: "100 %") }
4
+ # shadcn-for-muten · parts · no deps · kit.css (.cx-status).
5
+ part StatusBar(label: text = "Estado") { Stack class("cx-status") data(slot: "status-bar") aria(role: "status", label: $label) { slot } }
6
+ part StatusItem(label: text) { Stack class("cx-status-item") { slot Span "{$label}" class("cx-status-text") } }
7
+ part StatusSpacer() { Stack class("cx-status-spacer") }
@@ -1,17 +1,20 @@
1
- # Stepper - a horizontal (default) or vertical progress indicator. Wrap the steps in Stepper (or
2
- # StepperVertical) and render each with the Step part; state is "done" | "active" | "upcoming".
3
- # state { steps = query steps : list<StepItem> }
4
- # Stepper { each steps as s { Step(num: s.num, label: s.label, state: s.state) } }
5
- # A completed step shows a check (the number hides); the current step is ringed. Own it with `muten add stepper`.
6
- # shadcn-for-muten · part · no deps · styles via .stepper / .stepper-step* in globals.css.
7
- part Stepper() { Stack class("stepper") { slot } }
8
- part StepperVertical() { Stack class("stepper stepper-vertical") { slot } }
9
- part Step(num: text, label: text, state: text) {
10
- Stack class("stepper-step stepper-step-{$state}") {
11
- Stack class("stepper-indicator") {
12
- when $state == "done" { Icon "lucide:check" class("size-4") }
13
- Span "{$num}" class("stepper-num")
1
+ # Stepper - where you are in a flow of a few steps. Horizontal: one short bar per step under its name, as the «Nueva
2
+ # cita» modal shows it. Vertical: numbered circles joined by a line, each with what it asks. A done step fills its
3
+ # bar (or shows a check); the current one is marked and read out as the current step.
4
+ # Stepper(count: 4, label: "Paso 2 de 4: Servicio") { Step(num: "1", label: "Cliente", state: "done") Step(num: "2", label: "Servicio", state: "now") … }
5
+ # Stepper(count: 3, orientation: "vertical") { Step(num: "1", label: "Tu negocio", description: "Nombre y rubro", state: "done") … }
6
+ # look "list" (vertical): a numbered list for «how it works», ruled rows and a round number, the current one filled.
7
+ # state: done | now | "" (to come). shadcn-for-muten · part · no deps · kit.css (.cx-stepper).
8
+ part Stepper(count: number, orientation: text = "horizontal", label: text = "", look: text = "") {
9
+ Stack class("cx-stepper cx-stp") data(slot: "stepper", orientation: $orientation, look: $look) style(n: "{$count}") aria(role: "list", label: $label) { slot }
10
+ }
11
+ part Step(num: text, label: text, state: text = "", description: text = "") {
12
+ Stack class("cx-step cx-stp-step") data(state: $state) aria(role: "listitem", current: $state == "now") {
13
+ Stack class("cn-progress cx-stp-bar") data(slot: "progress") { Stack class("cn-progress-indicator") data(slot: "progress-indicator") }
14
+ Stack class("cx-stp-dot") { Span "{$num}" class("cx-stp-num") Icon "lucide:check" class("cx-stp-check") }
15
+ Stack class("cx-stp-text") {
16
+ Span "{$label}" class("cx-text cx-stp-label") data(slot: "text", variant: "caption")
17
+ Text "{$description}" class("cx-stp-desc")
14
18
  }
15
- Span "{$label}" class("stepper-label")
16
19
  }
17
20
  }
@@ -0,0 +1,5 @@
1
+ // StoreQr (muten Custom) - the kit's storeQr (kit/menus.js).
2
+ export function mount(el, inputs, on) {
3
+ Promise.all([import('@muten/shadcn/registry/kit/core.js'), import('@muten/shadcn/registry/kit/menus.js')]).then(([core, m]) => m.storeQr(el, inputs, core, on));
4
+ return () => {};
5
+ }
@@ -0,0 +1,5 @@
1
+ # StoreQr - the shop's QR with its mark in the middle, the name, the link and «Copiar enlace» (falls back to
2
+ # selecting the link when the clipboard says no).
3
+ # StoreQr(url: "https://storio.ink/el-roble", name: "Barbería El Roble", mark: "ER")
4
+ # shadcn-for-muten · Custom (kit menus + vendored qrcode-generator, MIT) · host JS store-qr.js.
5
+ part StoreQr(url: text, name: text = "", mark: text = "") { Custom StoreQr inputs(url: $url, name: $name, mark: $mark) class("cx-qr-host") }
@@ -1,10 +1,10 @@
1
- # Swatch / Swatch Palette - a grid of selectable color swatches (recent colors, a pixel palette). The PAGE owns
2
- # the selected hex + the list of colors:
3
- # state { colors = ["#ef4444", "#3b82f6", "#22c55e"] : list<text> active = "#3b82f6" : text }
1
+ # Swatch / SwatchPalette - colours to pick from at a glance: the last ones used, the brand's, a fixed palette. The
2
+ # chosen one carries a ring in the skin's --selected colour and reads as pressed. The page owns the list and the pick.
3
+ # state { colors = ["#6B46F2", "#0EA5E9", "#22C55E"] : list<text> active = "#6B46F2" : text }
4
4
  # action pick(c: text) mutates active { active.set(c) }
5
- # SwatchPalette { each colors as c { Swatch(color: c, current: active, onSelect: pick) } }
6
- # shadcn-for-muten · part · no deps · the swatch background is set via style(bg).
7
- part SwatchPalette() { Stack class("swatch-palette") { slot } }
8
- part Swatch(color: text, current: text, onSelect: action) {
9
- Button -> $onSelect($color) aria(pressed: $color == $current) class("swatch", "swatch-active" when $color == $current) style(bg: "{$color}")
5
+ # SwatchPalette(label: "Colores recientes") { each colors as c { Swatch(color: c, current: active, onSelect: pick) } }
6
+ # size: sm | default | lg. shadcn-for-muten · part · no deps · kit.css (.cx-swatch).
7
+ part SwatchPalette(label: text = "Colores") { Stack class("cx-swatches-row") aria(role: "group", label: $label) { slot } }
8
+ part Swatch(color: text, current: text, onSelect: action, size: text = "default") {
9
+ Button -> $onSelect($color) class("cx-swatch") data(size: $size, name: $color) aria(pressed: $color == $current, label: $color) style(bg: "{$color}")
10
10
  }
@@ -1,8 +1,16 @@
1
- # Switch - a toggle. The PAGE owns the bool + the action that flips it (container/presentational):
2
- # state { on = false : bool } action toggle mutates on { on.toggle() } Switch(on: on, onToggle: toggle)
3
- # shadcn-for-muten · part · no deps · styles via the semantic .switch* classes in globals.css.
4
- part Switch(on: bool, onToggle: action) {
5
- Button -> $onToggle aria(pressed: $on) class("switch", "switch-on" when $on, "switch-off" when not $on) {
6
- Stack class("switch-thumb", "switch-thumb-on" when $on, "switch-thumb-off" when not $on)
1
+ # Switch - on/off; the page owns the bool and the action. SwitchRow is the settings line: label, switch, the why.
2
+ # Switch(on: dark, onToggle: toggleDark, label: "Modo oscuro")
3
+ # SwitchRow(on: home, onToggle: toggleHome, label: "También a domicilio", description: "Pide la verificación de identidad…")
4
+ # size: default | sm. shadcn-for-muten · part · no deps · Nova (.cn-switch reads data-checked / data-unchecked).
5
+ part Switch(on: bool, onToggle: action, label: text = "", size: text = "default") {
6
+ Button -> $onToggle class("cn-switch group/switch") data(slot: "switch", size: $size, checked: $on, unchecked: not $on) aria(role: "switch", checked: $on, label: $label) {
7
+ Span "" class("cn-switch-thumb") data(slot: "switch-thumb", checked: $on, unchecked: not $on)
8
+ }
9
+ }
10
+ part SwitchRow(on: bool, onToggle: action, label: text, description: text = "") {
11
+ Stack class("cx-switch-row") {
12
+ Span "{$label}" class("cn-label") data(slot: "label")
13
+ Switch(on: $on, onToggle: $onToggle, label: $label)
14
+ Text "{$description}" class("cx-switch-why")
7
15
  }
8
16
  }
@@ -0,0 +1,10 @@
1
+ # TableView - the DataTable: filters as data (search · facet · date · range) with counts and chips, rows that fade /
2
+ # slide / enter, the height easing, sort by header, selection with a bulk bar in its own space, the phone's
3
+ # «buscar + Filtros» sheet, and Items when its box is narrow. (`DataTable` is muten's raw table primitive.)
4
+ # TableView(columns: @cols, rows: @appointments, filters: @filters, statuses: @statuses, bulk: @bulk, one: "cita", many: "citas", onAction: act)
5
+ # columns {key, label, type: person|text|date|status|money, sub} · filters {id, type, label, key | keys} ·
6
+ # statuses {id, label, tone} · bulk {id, label, variant, set {…}} → onAction gets {"action", "ids"}.
7
+ # shadcn-for-muten · part over KitWidget (kit core + kit/table.js).
8
+ part TableView(columns: list, rows: list, filters: list, statuses: list, bulk: list, one: text = "fila", many: text = "filas", onAction: action) {
9
+ Custom KitWidget inputs(name: "table", columns: $columns, rows: $rows, filters: $filters, statuses: $statuses, bulk: $bulk, one: $one, many: $many) on(action: $onAction) class("cx-table-host")
10
+ }
@@ -0,0 +1,27 @@
1
+ // TabsBehavior (muten Custom) - the indicator under the chosen tab slides to the next one (the kit's 260 ms
2
+ // transition), and Arrow/Home/End move between tabs and pick them. The chosen tab is the page's state (aria-selected
3
+ // or aria-pressed); this only follows it. Same behaviour as the reference artifact's tabs.
4
+ export function mount(el) {
5
+ const list = el.parentElement;
6
+ const ind = document.createElement('span'); ind.className = 'cx-tab-ind'; ind.setAttribute('aria-hidden', 'true'); list.prepend(ind);
7
+ const tabs = () => [...list.querySelectorAll(':scope > .cx-tab')];
8
+ const chosen = () => tabs().find((t) => t.getAttribute('aria-selected') === 'true' || t.getAttribute('aria-pressed') === 'true');
9
+ let first = true;
10
+ const place = () => {
11
+ const t = chosen(); if (!t) { ind.style.opacity = '0'; return; }
12
+ ind.style.opacity = '';
13
+ if (first) ind.style.transition = 'none';
14
+ ind.style.left = t.offsetLeft + 'px'; ind.style.width = t.offsetWidth + 'px';
15
+ if (first) { void ind.offsetWidth; ind.style.transition = ''; first = false; }
16
+ tabs().forEach((x) => { x.tabIndex = x === t ? 0 : -1; });
17
+ if (list.scrollWidth > list.clientWidth) t.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
18
+ };
19
+ new MutationObserver(place).observe(list, { subtree: true, attributes: true, attributeFilter: ['aria-selected', 'aria-pressed'] });
20
+ new ResizeObserver(place).observe(list);
21
+ list.addEventListener('keydown', (e) => {
22
+ const all = tabs(), i = all.indexOf(document.activeElement); if (i < 0) return;
23
+ const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: all.length - 1 }[e.key]; if (to === undefined) return;
24
+ e.preventDefault(); const t = all[(to + all.length) % all.length]; t.focus(); t.click();
25
+ });
26
+ document.fonts?.ready.then(place); requestAnimationFrame(place);
27
+ }