@muten/shadcn 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -0
- package/globals.css +379 -0
- package/package.json +22 -0
- package/registry/accordion.muten +16 -0
- package/registry/alert-dialog.muten +13 -0
- package/registry/alert.muten +8 -0
- package/registry/attachment.muten +12 -0
- package/registry/avatar.muten +7 -0
- package/registry/badge.muten +9 -0
- package/registry/breadcrumb.muten +7 -0
- package/registry/calendar.js +93 -0
- package/registry/calendar.muten +9 -0
- package/registry/card.muten +8 -0
- package/registry/carousel.js +36 -0
- package/registry/carousel.muten +6 -0
- package/registry/chart.js +34 -0
- package/registry/chart.muten +7 -0
- package/registry/checkbox.muten +8 -0
- package/registry/collapsible.muten +14 -0
- package/registry/combobox.muten +24 -0
- package/registry/command.muten +16 -0
- package/registry/context-menu.js +34 -0
- package/registry/context-menu.muten +8 -0
- package/registry/dialog.muten +13 -0
- package/registry/drawer.muten +12 -0
- package/registry/dropdown-menu.muten +15 -0
- package/registry/empty.muten +9 -0
- package/registry/field.muten +7 -0
- package/registry/hover-card.muten +9 -0
- package/registry/input-otp.js +36 -0
- package/registry/input-otp.muten +7 -0
- package/registry/item.muten +7 -0
- package/registry/menubar.muten +14 -0
- package/registry/message-scroller.muten +4 -0
- package/registry/message.muten +11 -0
- package/registry/navigation-menu.muten +16 -0
- package/registry/pagination.muten +9 -0
- package/registry/popover.muten +11 -0
- package/registry/progress.muten +7 -0
- package/registry/radio-group.muten +11 -0
- package/registry/resizable.js +29 -0
- package/registry/resizable.muten +6 -0
- package/registry/scroll-area.muten +4 -0
- package/registry/select.muten +21 -0
- package/registry/separator.muten +3 -0
- package/registry/sheet.muten +11 -0
- package/registry/slider.js +31 -0
- package/registry/slider.muten +7 -0
- package/registry/spinner.muten +3 -0
- package/registry/switch.muten +8 -0
- package/registry/tabs.muten +12 -0
- package/registry/toaster.js +25 -0
- package/registry/toaster.muten +9 -0
- package/registry/toggle-group.muten +8 -0
- package/registry/toggle.muten +6 -0
- package/registry/tooltip.muten +9 -0
- package/registry.json +63 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Breadcrumb + sub-components.
|
|
2
|
+
# Compose: Breadcrumb { BreadcrumbLink(label,href) BreadcrumbSeparator BreadcrumbLink(...) BreadcrumbSeparator BreadcrumbPage(label) }
|
|
3
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .breadcrumb* classes in globals.css.
|
|
4
|
+
part Breadcrumb() { Nav class("breadcrumb") aria(label: "breadcrumb") { slot } }
|
|
5
|
+
part BreadcrumbLink(label: text, href: text) { Link "{$label}" -> "{$href}" class("breadcrumb-link") }
|
|
6
|
+
part BreadcrumbPage(label: text) { Span "{$label}" class("breadcrumb-page") }
|
|
7
|
+
part BreadcrumbSeparator() { Icon "lucide:chevron-right" class("breadcrumb-separator") }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Calendar host (muten Custom). Variants via inputs:
|
|
2
|
+
// mode: "single" | "range" | "multiple" months: 1 | 2 caption: "label" | "dropdown"
|
|
3
|
+
// `selected` encodes the value: single -> "yyyy-mm-dd"; range -> "from/to"; multiple -> "d1,d2,...".
|
|
4
|
+
// handlers: { select(encoded) }.
|
|
5
|
+
export function mount(el, inputs, handlers) {
|
|
6
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
7
|
+
const DOW = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
8
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
9
|
+
const iso = (d) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
|
10
|
+
const parse = (s) => { const d = new Date(s + 'T00:00:00'); return isNaN(d.getTime()) ? null : d; };
|
|
11
|
+
const same = (a, b) => a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
12
|
+
const num = (d) => d.getFullYear() * 10000 + d.getMonth() * 100 + d.getDate();
|
|
13
|
+
|
|
14
|
+
const mode = inputs.mode || 'single';
|
|
15
|
+
const months = Math.min(2, Math.max(1, Number(inputs.months) || 1));
|
|
16
|
+
const dropdown = inputs.caption === 'dropdown';
|
|
17
|
+
const today = new Date();
|
|
18
|
+
|
|
19
|
+
let selected = [];
|
|
20
|
+
const readSelected = (val) => {
|
|
21
|
+
selected = [];
|
|
22
|
+
const s = String(val || '');
|
|
23
|
+
if (!s) return;
|
|
24
|
+
const parts = mode === 'range' ? s.split('/') : mode === 'multiple' ? s.split(',') : [s];
|
|
25
|
+
for (const p of parts) { const d = parse(p); if (d) selected.push(d); }
|
|
26
|
+
};
|
|
27
|
+
readSelected(inputs.selected);
|
|
28
|
+
const anchor = selected[0] || today;
|
|
29
|
+
let vy = anchor.getFullYear();
|
|
30
|
+
let vm = anchor.getMonth();
|
|
31
|
+
|
|
32
|
+
const emit = () => {
|
|
33
|
+
const out = mode === 'range' ? selected.map(iso).join('/') : mode === 'multiple' ? selected.map(iso).join(',') : (selected[0] ? iso(selected[0]) : '');
|
|
34
|
+
if (handlers.select) handlers.select(out);
|
|
35
|
+
};
|
|
36
|
+
const isSel = (d) => selected.some((s) => same(s, d));
|
|
37
|
+
const inRange = (d) => { if (mode !== 'range' || selected.length < 2) return false; const x = num(d), a = num(selected[0]), b = num(selected[1]); return x > Math.min(a, b) && x < Math.max(a, b); };
|
|
38
|
+
const pick = (d) => {
|
|
39
|
+
if (mode === 'single') selected = [d];
|
|
40
|
+
else if (mode === 'multiple') { const i = selected.findIndex((s) => same(s, d)); if (i >= 0) selected.splice(i, 1); else selected.push(d); }
|
|
41
|
+
else { if (selected.length !== 1) selected = [d]; else { const a = selected[0]; selected = num(d) < num(a) ? [d, a] : [a, d]; } }
|
|
42
|
+
render(); emit();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const monthGrid = (y, m) => {
|
|
46
|
+
const wrap = document.createElement('div'); wrap.className = 'calendar-month';
|
|
47
|
+
const cap = document.createElement('div'); cap.className = 'calendar-caption';
|
|
48
|
+
if (dropdown) {
|
|
49
|
+
const ms = document.createElement('select'); ms.className = 'calendar-select';
|
|
50
|
+
MONTHS.forEach((name, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = name; if (i === m) o.selected = true; ms.appendChild(o); });
|
|
51
|
+
ms.addEventListener('change', () => { vm = Number(ms.value); render(); });
|
|
52
|
+
const ys = document.createElement('select'); ys.className = 'calendar-select';
|
|
53
|
+
for (let yy = y - 10; yy <= y + 10; yy++) { const o = document.createElement('option'); o.value = String(yy); o.textContent = String(yy); if (yy === y) o.selected = true; ys.appendChild(o); }
|
|
54
|
+
ys.addEventListener('change', () => { vy = Number(ys.value); render(); });
|
|
55
|
+
cap.appendChild(ms); cap.appendChild(ys);
|
|
56
|
+
} else {
|
|
57
|
+
const l = document.createElement('div'); l.className = 'calendar-label'; l.textContent = MONTHS[m] + ' ' + y; cap.appendChild(l);
|
|
58
|
+
}
|
|
59
|
+
wrap.appendChild(cap);
|
|
60
|
+
|
|
61
|
+
const grid = document.createElement('div'); grid.className = 'calendar-grid';
|
|
62
|
+
for (const d of DOW) { const c = document.createElement('div'); c.className = 'calendar-dow'; c.textContent = d; grid.appendChild(c); }
|
|
63
|
+
const first = new Date(y, m, 1).getDay();
|
|
64
|
+
const days = new Date(y, m + 1, 0).getDate();
|
|
65
|
+
for (let i = 0; i < first; i++) grid.appendChild(document.createElement('div'));
|
|
66
|
+
for (let day = 1; day <= days; day++) {
|
|
67
|
+
const d = new Date(y, m, day);
|
|
68
|
+
const cell = document.createElement('button'); cell.type = 'button'; cell.className = 'calendar-day'; cell.textContent = String(day);
|
|
69
|
+
if (isSel(d)) cell.classList.add('calendar-day-selected');
|
|
70
|
+
else if (inRange(d)) cell.classList.add('calendar-day-range');
|
|
71
|
+
else if (same(d, today)) cell.classList.add('calendar-day-today');
|
|
72
|
+
cell.addEventListener('click', () => pick(d));
|
|
73
|
+
grid.appendChild(cell);
|
|
74
|
+
}
|
|
75
|
+
wrap.appendChild(grid);
|
|
76
|
+
return wrap;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function render() {
|
|
80
|
+
el.innerHTML = '';
|
|
81
|
+
const prev = document.createElement('button'); prev.type = 'button'; prev.className = 'calendar-nav-prev'; prev.textContent = '‹';
|
|
82
|
+
const next = document.createElement('button'); next.type = 'button'; next.className = 'calendar-nav-next'; next.textContent = '›';
|
|
83
|
+
prev.addEventListener('click', () => { vm--; if (vm < 0) { vm = 11; vy--; } render(); });
|
|
84
|
+
next.addEventListener('click', () => { vm++; if (vm > 11) { vm = 0; vy++; } render(); });
|
|
85
|
+
el.appendChild(prev); el.appendChild(next);
|
|
86
|
+
const wrap = document.createElement('div'); wrap.className = 'calendar-months';
|
|
87
|
+
for (let i = 0; i < months; i++) { let yy = vy, mm = vm + i; while (mm > 11) { mm -= 12; yy++; } wrap.appendChild(monthGrid(yy, mm)); }
|
|
88
|
+
el.appendChild(wrap);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
render();
|
|
92
|
+
return (n) => { readSelected(n.selected); render(); };
|
|
93
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Calendar - a month grid with variants. The PAGE owns the selected value (text) per mode:
|
|
2
|
+
# mode: "single" | "range" | "multiple" months: 1 | 2 caption: "label" | "dropdown"
|
|
3
|
+
# selected encodes the value: single -> "yyyy-mm-dd"; range -> "from/to"; multiple -> "d1,d2,...".
|
|
4
|
+
# state { date = "" : text } action pick(d: text) mutates date { date.set(d) }
|
|
5
|
+
# Calendar(selected: date, mode: "single", months: 1, caption: "label", onSelect: pick)
|
|
6
|
+
# shadcn-for-muten · Custom · host JS is src/components/Calendar.js (copied by `muten add calendar`).
|
|
7
|
+
part Calendar(selected: text, mode: text, months: number, caption: text, onSelect: action) {
|
|
8
|
+
Custom Calendar inputs(selected: $selected, mode: $mode, months: $months, caption: $caption) on(select: $onSelect) class("calendar")
|
|
9
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Card + its sub-components. Compose: Card { CardHeader { CardTitle(label) CardDescription(label) } CardContent { … } CardFooter { … } }
|
|
2
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .card* classes in globals.css.
|
|
3
|
+
part Card() { Section class("card") { slot } }
|
|
4
|
+
part CardHeader() { Stack class("card-header") { slot } }
|
|
5
|
+
part CardTitle(label: text) { Title "{$label}" h3 class("card-title") }
|
|
6
|
+
part CardDescription(label: text) { Span "{$label}" class("card-description") }
|
|
7
|
+
part CardContent() { Stack class("card-content") { slot } }
|
|
8
|
+
part CardFooter() { Stack class("card-footer") { slot } }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Carousel host component (muten Custom). inputs: { items } (a list). Renders one slide per item with prev/next.
|
|
2
|
+
// Slides show the item as text; edit this file for image/rich slides (e.g. an <img> per item).
|
|
3
|
+
export function mount(el, inputs, handlers) {
|
|
4
|
+
const viewport = document.createElement('div'); viewport.className = 'carousel-viewport';
|
|
5
|
+
const track = document.createElement('div'); track.className = 'carousel-track';
|
|
6
|
+
viewport.appendChild(track); el.appendChild(viewport);
|
|
7
|
+
const prev = document.createElement('button'); prev.type = 'button'; prev.className = 'carousel-prev'; prev.textContent = '‹';
|
|
8
|
+
const next = document.createElement('button'); next.type = 'button'; next.className = 'carousel-next'; next.textContent = '›';
|
|
9
|
+
el.appendChild(prev); el.appendChild(next);
|
|
10
|
+
|
|
11
|
+
let idx = 0;
|
|
12
|
+
let items = [];
|
|
13
|
+
// Move by exact viewport widths (px). A `%` translate is relative to the multi-slide track, not the viewport.
|
|
14
|
+
const paint = () => {
|
|
15
|
+
const w = viewport.clientWidth || el.clientWidth || 0;
|
|
16
|
+
for (const s of track.children) s.style.width = w + 'px';
|
|
17
|
+
track.style.transform = 'translateX(' + (-idx * w) + 'px)';
|
|
18
|
+
};
|
|
19
|
+
const build = (list) => {
|
|
20
|
+
items = Array.isArray(list) ? list : [];
|
|
21
|
+
track.innerHTML = '';
|
|
22
|
+
for (const it of items) {
|
|
23
|
+
const s = document.createElement('div'); s.className = 'carousel-slide';
|
|
24
|
+
s.textContent = (it && typeof it === 'object') ? (it.label ?? it.title ?? '') : String(it);
|
|
25
|
+
track.appendChild(s);
|
|
26
|
+
}
|
|
27
|
+
if (idx > items.length - 1) idx = Math.max(0, items.length - 1);
|
|
28
|
+
paint();
|
|
29
|
+
};
|
|
30
|
+
window.addEventListener('resize', paint);
|
|
31
|
+
prev.addEventListener('click', () => { if (items.length) { idx = (idx - 1 + items.length) % items.length; paint(); } });
|
|
32
|
+
next.addEventListener('click', () => { if (items.length) { idx = (idx + 1) % items.length; paint(); } });
|
|
33
|
+
|
|
34
|
+
build(inputs.items);
|
|
35
|
+
return (n) => build(n.items);
|
|
36
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Carousel - a slide carousel over a list. The PAGE owns the slides (list<text>):
|
|
2
|
+
# state { slides = ["1", "2", "3", "4", "5"] : list<text> } Carousel(items: slides)
|
|
3
|
+
# shadcn-for-muten · Custom · host JS is src/components/Carousel.js. Slides render as text; edit the .js for images/rich slides.
|
|
4
|
+
part Carousel(items: list) {
|
|
5
|
+
Custom Carousel inputs(items: $items) class("carousel")
|
|
6
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Chart host component (muten Custom). inputs: { data } (a list of { label, value }). Renders an SVG bar chart.
|
|
2
|
+
// Edit this file for line/area/pie variants; this default is a clean bar chart driven by the page's data.
|
|
3
|
+
export function mount(el, inputs, handlers) {
|
|
4
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
5
|
+
const render = (data) => {
|
|
6
|
+
el.innerHTML = '';
|
|
7
|
+
const items = Array.isArray(data) ? data : [];
|
|
8
|
+
const W = 480, H = 200, pad = 24, gap = 10;
|
|
9
|
+
const max = Math.max(1, ...items.map((d) => Number(d.value) || 0));
|
|
10
|
+
const bw = items.length ? (W - pad * 2 - gap * (items.length - 1)) / items.length : 0;
|
|
11
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
12
|
+
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
|
|
13
|
+
svg.setAttribute('class', 'chart-svg');
|
|
14
|
+
items.forEach((d, i) => {
|
|
15
|
+
const v = Number(d.value) || 0;
|
|
16
|
+
const bh = (v / max) * (H - pad * 2);
|
|
17
|
+
const x = pad + i * (bw + gap);
|
|
18
|
+
const y = H - pad - bh;
|
|
19
|
+
const rect = document.createElementNS(NS, 'rect');
|
|
20
|
+
rect.setAttribute('x', x); rect.setAttribute('y', y);
|
|
21
|
+
rect.setAttribute('width', bw); rect.setAttribute('height', bh);
|
|
22
|
+
rect.setAttribute('rx', 4); rect.setAttribute('class', 'chart-bar');
|
|
23
|
+
svg.appendChild(rect);
|
|
24
|
+
const t = document.createElementNS(NS, 'text');
|
|
25
|
+
t.setAttribute('x', x + bw / 2); t.setAttribute('y', H - pad + 14);
|
|
26
|
+
t.setAttribute('text-anchor', 'middle'); t.setAttribute('class', 'chart-label');
|
|
27
|
+
t.textContent = d.label == null ? '' : String(d.label);
|
|
28
|
+
svg.appendChild(t);
|
|
29
|
+
});
|
|
30
|
+
el.appendChild(svg);
|
|
31
|
+
};
|
|
32
|
+
render(inputs.data);
|
|
33
|
+
return (n) => render(n.data);
|
|
34
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Chart - an SVG bar chart over a list of { label, value }. The PAGE owns the data:
|
|
2
|
+
# entity Metric { label text value number } state { data = query data : list<Metric> } mock { data: [ … ] }
|
|
3
|
+
# Chart(data: data)
|
|
4
|
+
# shadcn-for-muten · Custom · host JS is src/components/Chart.js (edit it for line/area/pie variants).
|
|
5
|
+
part Chart(data: list) {
|
|
6
|
+
Custom Chart inputs(data: $data) class("chart")
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Checkbox - the PAGE owns the bool + the action that flips it (container/presentational):
|
|
2
|
+
# state { agree = false : bool } action toggle mutates agree { agree.toggle() } Checkbox(checked: agree, onToggle: toggle)
|
|
3
|
+
# shadcn-for-muten · part · no deps · the check always renders and scales/fades in via .checkbox-icon-on.
|
|
4
|
+
part Checkbox(checked: bool, onToggle: action) {
|
|
5
|
+
Button -> $onToggle aria(checked: $checked) class("checkbox", "checkbox-checked" when $checked) {
|
|
6
|
+
Icon "lucide:check" class("checkbox-icon", "checkbox-icon-on" when $checked)
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Collapsible - a trigger + content that animates open. The PAGE owns the bool + the toggle action.
|
|
2
|
+
# state { open = false : bool } action t mutates open { open.toggle() } Collapsible(label: "…", open: open, onToggle: t) { … }
|
|
3
|
+
# shadcn-for-muten · part · no deps · the content always renders and animates open via the .collapsible-panel grid trick.
|
|
4
|
+
part Collapsible(label: text, open: bool, onToggle: action) {
|
|
5
|
+
Stack class("collapsible") {
|
|
6
|
+
Button -> $onToggle aria(expanded: $open) class("collapsible-trigger") {
|
|
7
|
+
Span "{$label}"
|
|
8
|
+
Icon "lucide:chevrons-up-down" class("size-4 text-muted-foreground")
|
|
9
|
+
}
|
|
10
|
+
Stack class("collapsible-panel", "collapsible-panel-open" when $open) {
|
|
11
|
+
Stack class("collapsible-content") { slot }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Combobox - a Select with a search box. The PAGE owns the value, the open bool, and the search text; it filters
|
|
2
|
+
# the items with `each … where`:
|
|
3
|
+
# state { value = "" : text open = false : bool q = "" : text items = [ … ] : list<Opt> }
|
|
4
|
+
# Combobox(label: value, open: open, onToggle: t) {
|
|
5
|
+
# SearchField bind(q) "Search…" class("combobox-search")
|
|
6
|
+
# Stack class("combobox-list") { each items as o where o.name contains q { ComboboxItem(value: o.name, current: value, label: o.name, onSelect: pick) } }
|
|
7
|
+
# }
|
|
8
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .combobox* + .select-* classes in globals.css.
|
|
9
|
+
part Combobox(label: text, open: bool, onToggle: action) {
|
|
10
|
+
Stack class("select-wrap") {
|
|
11
|
+
Button -> $onToggle aria(expanded: $open) class("combobox-trigger") {
|
|
12
|
+
Span "{$label}"
|
|
13
|
+
Icon "lucide:chevrons-up-down" class("size-4 opacity-50 shrink-0")
|
|
14
|
+
}
|
|
15
|
+
when $open { Button -> $onToggle class("select-backdrop") }
|
|
16
|
+
Stack class("combobox-content", "combobox-content-open" when $open) { slot }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
part ComboboxItem(value: text, current: text, label: text, onSelect: action) {
|
|
20
|
+
Button -> $onSelect($value) class("select-item") {
|
|
21
|
+
Span "{$label}"
|
|
22
|
+
when $value == $current { Icon "lucide:check" class("size-4") }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Command - a command palette (search + grouped item list). The PAGE provides the search input + the items:
|
|
2
|
+
# Command {
|
|
3
|
+
# SearchField bind(q) "Type a command…" class("command-input")
|
|
4
|
+
# CommandList { CommandGroup(label: "Suggestions") { CommandItem(onSelect: run) { Icon … Span "Calendar" } … } }
|
|
5
|
+
# }
|
|
6
|
+
# Put inside a Dialog for the modal palette. shadcn-for-muten · part · no deps · styles via the .command* classes.
|
|
7
|
+
part Command() { Stack class("command") { slot } }
|
|
8
|
+
part CommandList() { Stack class("command-list") { slot } }
|
|
9
|
+
part CommandGroup(label: text) {
|
|
10
|
+
Stack class("flex flex-col") {
|
|
11
|
+
Span "{$label}" class("command-group-label")
|
|
12
|
+
slot
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
part CommandItem(onSelect: action) { Button -> $onSelect class("command-item") { slot } }
|
|
16
|
+
part CommandShortcut(keys: text) { Span "{$keys}" class("command-shortcut") }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Context Menu host component (muten Custom). inputs: { label, items }. handlers: { select(label) }.
|
|
2
|
+
// Right-clicking the area opens a menu of items at the cursor; choosing one emits its label.
|
|
3
|
+
export function mount(el, inputs, handlers) {
|
|
4
|
+
const area = document.createElement('div'); area.className = 'context-area';
|
|
5
|
+
area.textContent = inputs.label == null ? 'Right-click here' : String(inputs.label);
|
|
6
|
+
el.appendChild(area);
|
|
7
|
+
const menu = document.createElement('div'); menu.className = 'context-menu';
|
|
8
|
+
el.appendChild(menu);
|
|
9
|
+
|
|
10
|
+
let open = false;
|
|
11
|
+
const hide = () => { menu.classList.remove('context-menu-open'); open = false; };
|
|
12
|
+
const build = (items) => {
|
|
13
|
+
menu.innerHTML = '';
|
|
14
|
+
(Array.isArray(items) ? items : []).forEach((it) => {
|
|
15
|
+
const label = (it && typeof it === 'object') ? (it.label == null ? '' : String(it.label)) : String(it);
|
|
16
|
+
const b = document.createElement('button'); b.type = 'button'; b.className = 'context-item'; b.textContent = label;
|
|
17
|
+
b.addEventListener('click', () => { hide(); if (handlers.select) handlers.select(label); });
|
|
18
|
+
menu.appendChild(b);
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
area.addEventListener('contextmenu', (e) => {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
const r = el.getBoundingClientRect();
|
|
25
|
+
menu.style.left = (e.clientX - r.left) + 'px';
|
|
26
|
+
menu.style.top = (e.clientY - r.top) + 'px';
|
|
27
|
+
menu.classList.add('context-menu-open');
|
|
28
|
+
open = true;
|
|
29
|
+
});
|
|
30
|
+
document.addEventListener('click', () => { if (open) hide(); });
|
|
31
|
+
|
|
32
|
+
build(inputs.items);
|
|
33
|
+
return (n) => { area.textContent = n.label == null ? 'Right-click here' : String(n.label); build(n.items); };
|
|
34
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Context Menu - right-click an area to open a menu of items. The PAGE owns the items + the action:
|
|
2
|
+
# state { items = ["Back", "Forward", "Reload"] : list<text> picked = "" : text }
|
|
3
|
+
# action onPick(v: text) mutates picked { picked.set(v) }
|
|
4
|
+
# ContextMenu(label: "Right-click here", items: items, onSelect: onPick)
|
|
5
|
+
# shadcn-for-muten · Custom · host JS is src/components/ContextMenu.js (copied by `muten add context-menu`).
|
|
6
|
+
part ContextMenu(label: text, items: list, onSelect: action) {
|
|
7
|
+
Custom ContextMenu inputs(label: $label, items: $items) on(select: $onSelect) class("context-menu-wrap")
|
|
8
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Dialog - a modal. The PAGE owns the bool + open/close actions:
|
|
2
|
+
# state { open = false : bool } action close mutates open { open.set(false) } action show mutates open { open.set(true) }
|
|
3
|
+
# Button "Open" -> show ... Dialog(open: open, onClose: close) { DialogTitle(label) DialogDescription(label) … DialogFooter { … } }
|
|
4
|
+
# shadcn-for-muten · part · no deps · the backdrop click closes; styles via the semantic .dialog* classes.
|
|
5
|
+
part Dialog(open: bool, onClose: action) {
|
|
6
|
+
Stack class("dialog-overlay", "dialog-overlay-open" when $open) {
|
|
7
|
+
Button -> $onClose class("dialog-backdrop")
|
|
8
|
+
Stack class("dialog-content") { slot }
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
part DialogTitle(label: text) { Title "{$label}" h2 class("dialog-title") }
|
|
12
|
+
part DialogDescription(label: text) { Span "{$label}" class("dialog-description") }
|
|
13
|
+
part DialogFooter() { Stack class("dialog-footer") { slot } }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Drawer - a panel that slides up from the bottom edge. The PAGE owns the bool + open/close actions:
|
|
2
|
+
# state { open = false : bool } action close mutates open { open.set(false) } Drawer(open: open, onClose: close) { … }
|
|
3
|
+
# shadcn-for-muten · part · no deps · backdrop click closes; styles via the semantic .drawer* classes.
|
|
4
|
+
part Drawer(open: bool, onClose: action) {
|
|
5
|
+
Stack class("drawer-overlay", "drawer-overlay-open" when $open) {
|
|
6
|
+
Button -> $onClose class("drawer-backdrop")
|
|
7
|
+
Stack class("drawer-content") {
|
|
8
|
+
Stack class("drawer-handle")
|
|
9
|
+
slot
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Dropdown Menu - a button that opens a menu below it. The PAGE owns the bool + the toggle action:
|
|
2
|
+
# state { open = false : bool } action t mutates open { open.toggle() }
|
|
3
|
+
# DropdownMenu(label: "Open", open: open, onToggle: t) { DropdownLabel(label) DropdownItem(label, onSelect) … }
|
|
4
|
+
# Each DropdownItem's action should also close the menu (set open false). The backdrop catches outside clicks.
|
|
5
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .dropdown* classes in globals.css.
|
|
6
|
+
part DropdownMenu(label: text, open: bool, onToggle: action) {
|
|
7
|
+
Stack class("dropdown") {
|
|
8
|
+
Button "{$label}" -> $onToggle aria(expanded: $open) class("btn btn-outline")
|
|
9
|
+
when $open { Button -> $onToggle class("dropdown-backdrop") }
|
|
10
|
+
Stack class("dropdown-content", "dropdown-content-open" when $open) { slot }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
part DropdownItem(label: text, onSelect: action) { Button "{$label}" -> $onSelect class("dropdown-item") }
|
|
14
|
+
part DropdownLabel(label: text) { Span "{$label}" class("dropdown-label") }
|
|
15
|
+
part DropdownSeparator() { Stack class("dropdown-separator") }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Empty - an empty-state placeholder. Centered title + description in a dashed border; drop an Icon/Button via the slot.
|
|
2
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .empty* classes in globals.css.
|
|
3
|
+
part Empty(title: text, description: text) {
|
|
4
|
+
Stack class("empty") {
|
|
5
|
+
slot
|
|
6
|
+
Span "{$title}" class("empty-title")
|
|
7
|
+
Span "{$description}" class("empty-description")
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Field + sub-components: a labelled form control with description / error.
|
|
2
|
+
# Compose: Field { FieldLabel(label) <a control, e.g. SearchField class("input")> FieldDescription(label) }
|
|
3
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .field* + .label classes in globals.css.
|
|
4
|
+
part Field() { Stack class("field") { slot } }
|
|
5
|
+
part FieldLabel(label: text) { Span "{$label}" class("label") }
|
|
6
|
+
part FieldDescription(label: text) { Span "{$label}" class("field-description") }
|
|
7
|
+
part FieldError(label: text) { Span "{$label}" class("field-error") }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Hover Card - richer content shown on hover (pure CSS, no state). label is the trigger; the slot is the card.
|
|
2
|
+
# HoverCard(label: "@muten") { Span "The AI-first frontend DSL." class("text-sm") }
|
|
3
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .hover-card* classes in globals.css.
|
|
4
|
+
part HoverCard(label: text) {
|
|
5
|
+
Stack class("hover-card") {
|
|
6
|
+
Span "{$label}" class("hover-card-trigger")
|
|
7
|
+
Stack class("hover-card-content") { slot }
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Input OTP host component (muten Custom). inputs: { value, length }. handlers: { input(value) }.
|
|
2
|
+
// Builds `length` single-character boxes; typing/paste/backspace move focus and emit the joined value.
|
|
3
|
+
export function mount(el, inputs, handlers) {
|
|
4
|
+
const len = Number(inputs.length ?? 6);
|
|
5
|
+
const boxes = [];
|
|
6
|
+
const emit = () => { if (handlers.input) handlers.input(boxes.map((b) => b.value).join('')); };
|
|
7
|
+
|
|
8
|
+
for (let i = 0; i < len; i++) {
|
|
9
|
+
const box = document.createElement('input');
|
|
10
|
+
box.className = 'otp-slot';
|
|
11
|
+
box.maxLength = 1;
|
|
12
|
+
box.inputMode = 'numeric';
|
|
13
|
+
box.autocomplete = 'one-time-code';
|
|
14
|
+
box.addEventListener('input', () => {
|
|
15
|
+
box.value = box.value.replace(/\D/g, '').slice(-1);
|
|
16
|
+
if (box.value && i < len - 1) boxes[i + 1].focus();
|
|
17
|
+
emit();
|
|
18
|
+
});
|
|
19
|
+
box.addEventListener('keydown', (e) => {
|
|
20
|
+
if (e.key === 'Backspace' && !box.value && i > 0) boxes[i - 1].focus();
|
|
21
|
+
});
|
|
22
|
+
box.addEventListener('paste', (e) => {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
const text = ((e.clipboardData && e.clipboardData.getData('text')) || '').replace(/\D/g, '').slice(0, len);
|
|
25
|
+
for (let j = 0; j < len; j++) boxes[j].value = text[j] || '';
|
|
26
|
+
(boxes[Math.min(text.length, len - 1)] || boxes[0]).focus();
|
|
27
|
+
emit();
|
|
28
|
+
});
|
|
29
|
+
boxes.push(box);
|
|
30
|
+
el.appendChild(box);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const set = (val) => { const s = String(val == null ? '' : val); boxes.forEach((b, i) => { b.value = s[i] || ''; }); };
|
|
34
|
+
set(inputs.value);
|
|
35
|
+
return (next) => set(next.value);
|
|
36
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Input OTP - N single-character boxes synced to one value. The PAGE owns the value (text):
|
|
2
|
+
# state { code = "" : text } action setCode(v: text) mutates code { code.set(v) }
|
|
3
|
+
# InputOtp(value: code, length: 6, onInput: setCode)
|
|
4
|
+
# shadcn-for-muten · Custom · host JS is src/components/InputOtp.js (copied by `muten add input-otp`).
|
|
5
|
+
part InputOtp(value: text, length: number, onInput: action) {
|
|
6
|
+
Custom InputOtp inputs(value: $value, length: $length) on(input: $onInput) class("otp")
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Item + sub-components: a bordered row of media + content + actions.
|
|
2
|
+
# Compose: Item { <Icon/Avatar> ItemContent { ItemTitle(label) ItemDescription(label) } <Button/Switch> }
|
|
3
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .item* classes in globals.css.
|
|
4
|
+
part Item() { Stack class("item") { slot } }
|
|
5
|
+
part ItemContent() { Stack class("item-content") { slot } }
|
|
6
|
+
part ItemTitle(label: text) { Span "{$label}" class("item-title") }
|
|
7
|
+
part ItemDescription(label: text) { Span "{$label}" class("item-description") }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Menubar - a bar of menus (File / Edit / View). The PAGE owns which is open via a text state:
|
|
2
|
+
# state { bar = "" : text } action open(v: text) mutates bar { if bar == v { bar.set("") } else { bar.set(v) } }
|
|
3
|
+
# action close mutates bar { bar.set("") }
|
|
4
|
+
# Menubar { MenubarMenu(label: "File", name: "file", current: bar, onOpen: open) { MenubarItem(label: "New", onSelect: close) … } }
|
|
5
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .menubar* classes (items reuse .dropdown-item).
|
|
6
|
+
part Menubar() { Stack class("menubar") { slot } }
|
|
7
|
+
part MenubarMenu(label: text, name: text, current: text, onOpen: action) {
|
|
8
|
+
Stack class("menubar-item") {
|
|
9
|
+
Button "{$label}" -> $onOpen($name) aria(expanded: $name == $current) class("menubar-trigger", "menubar-trigger-active" when $name == $current)
|
|
10
|
+
Stack class("menubar-content", "menubar-content-open" when $name == $current) { slot }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
part MenubarItem(label: text, onSelect: action) { Button "{$label}" -> $onSelect class("dropdown-item") }
|
|
14
|
+
part MenubarSeparator() { Stack class("dropdown-separator") }
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# Message Scroller - a scrollable column of chat messages (max-h-96 by default; eject to change).
|
|
2
|
+
# MessageScroller { Marker(label: "Today") Message(variant: "received") { … } Message(variant: "sent") { … } }
|
|
3
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .message-scroller class in globals.css.
|
|
4
|
+
part MessageScroller() { Stack class("message-scroller") { slot } }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Message + Bubble + Marker - chat primitives. variant: sent | received.
|
|
2
|
+
# Compose: Message(variant: "received") { Avatar(name) Bubble(variant: "received") { Span "Hi!" } }
|
|
3
|
+
# Marker(label: "Today")
|
|
4
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .message* / .bubble / .marker classes.
|
|
5
|
+
part Message(variant: text) {
|
|
6
|
+
Stack class("message", "message-sent" when $variant == "sent") { slot }
|
|
7
|
+
}
|
|
8
|
+
part Bubble(variant: text) {
|
|
9
|
+
Stack class("bubble", "bubble-sent" when $variant == "sent", "bubble-received" when $variant == "received") { slot }
|
|
10
|
+
}
|
|
11
|
+
part Marker(label: text) { Span "{$label}" class("marker") }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Navigation Menu - a nav bar whose items reveal a panel on hover (pure CSS, no state).
|
|
2
|
+
# NavigationMenu { NavigationMenuItem(label: "Getting started") { NavigationMenuLink(title: "Intro", desc: "…", href: "/intro") … } }
|
|
3
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .nav-menu* classes in globals.css.
|
|
4
|
+
part NavigationMenu() { Stack class("nav-menu") { slot } }
|
|
5
|
+
part NavigationMenuItem(label: text) {
|
|
6
|
+
Stack class("nav-menu-item") {
|
|
7
|
+
Span "{$label}" class("nav-menu-trigger")
|
|
8
|
+
Stack class("nav-menu-content") { slot }
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
part NavigationMenuLink(title: text, desc: text, href: text) {
|
|
12
|
+
Link "" -> "{$href}" class("nav-menu-link") {
|
|
13
|
+
Span "{$title}" class("nav-menu-link-title")
|
|
14
|
+
Span "{$desc}" class("nav-menu-link-text")
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Pagination + sub-components (links reuse the .btn classes).
|
|
2
|
+
# Compose: Pagination { PaginationPrevious(href) PaginationLink(label,href,page,current) PaginationEllipsis PaginationNext(href) }
|
|
3
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .pagination* + .btn classes in globals.css.
|
|
4
|
+
part Pagination() { Nav class("pagination") aria(label: "pagination") { slot } }
|
|
5
|
+
# `page` is this link's number, `current` the active-page state - the part highlights itself when they match.
|
|
6
|
+
part PaginationLink(label: text, href: text, page: number, current: number) { Link "{$label}" -> "{$href}" class("btn btn-icon", "btn-outline" when $page == $current, "btn-ghost" when $page != $current) }
|
|
7
|
+
part PaginationPrevious(href: text) { Link "Previous" -> "{$href}" class("btn btn-ghost btn-sm gap-1") }
|
|
8
|
+
part PaginationNext(href: text) { Link "Next" -> "{$href}" class("btn btn-ghost btn-sm gap-1") }
|
|
9
|
+
part PaginationEllipsis() { Span "…" class("pagination-ellipsis") }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Popover - a button that opens a floating panel below it. The PAGE owns the bool + the toggle action:
|
|
2
|
+
# state { open = false : bool } action t mutates open { open.toggle() }
|
|
3
|
+
# Popover(label: "Open", open: open, onToggle: t) { … any content … }
|
|
4
|
+
# shadcn-for-muten · part · no deps · the backdrop catches outside clicks; styles via the semantic .popover* classes.
|
|
5
|
+
part Popover(label: text, open: bool, onToggle: action) {
|
|
6
|
+
Stack class("popover") {
|
|
7
|
+
Button "{$label}" -> $onToggle aria(expanded: $open) class("btn btn-outline")
|
|
8
|
+
when $open { Button -> $onToggle class("popover-backdrop") }
|
|
9
|
+
Stack class("popover-content", "popover-content-open" when $open) { slot }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Progress - a determinate progress bar. value is 0-100; the fill width is the one runtime value, set via style().
|
|
2
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .progress* classes in globals.css.
|
|
3
|
+
part Progress(value: number) {
|
|
4
|
+
Stack class("progress") {
|
|
5
|
+
Stack class("progress-indicator") style(w: "{$value}%")
|
|
6
|
+
}
|
|
7
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Radio Group - single choice from a list. The PAGE owns a `text` state + an action that sets it:
|
|
2
|
+
# state { plan = "pro" : text } action setPlan(v: text) mutates plan { plan.set(v) }
|
|
3
|
+
# RadioGroup { RadioGroupItem(value: "pro", current: plan, label: "Pro", onSelect: setPlan) … }
|
|
4
|
+
# shadcn-for-muten · part · no deps · the dot always renders and scales in via .radio-dot-on.
|
|
5
|
+
part RadioGroup() { Stack class("radio-group") { slot } }
|
|
6
|
+
part RadioGroupItem(value: text, current: text, label: text, onSelect: action) {
|
|
7
|
+
Button -> $onSelect($value) aria(checked: $value == $current) class("radio-row") {
|
|
8
|
+
Stack class("radio") { Stack class("radio-dot", "radio-dot-on" when $value == $current) }
|
|
9
|
+
Span "{$label}" class("radio-label")
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Resizable host component (muten Custom). inputs: { left, right } (panel labels). A draggable splitter
|
|
2
|
+
// resizes the two panels. Edit this file to render real content per panel instead of the label text.
|
|
3
|
+
export function mount(el, inputs, handlers) {
|
|
4
|
+
const a = document.createElement('div'); a.className = 'resizable-panel';
|
|
5
|
+
const handle = document.createElement('div'); handle.className = 'resizable-handle';
|
|
6
|
+
const b = document.createElement('div'); b.className = 'resizable-panel';
|
|
7
|
+
el.appendChild(a); el.appendChild(handle); el.appendChild(b);
|
|
8
|
+
|
|
9
|
+
let pct = 50;
|
|
10
|
+
const paint = () => { a.style.flexBasis = pct + '%'; b.style.flexBasis = (100 - pct) + '%'; };
|
|
11
|
+
const setText = (left, right) => { a.textContent = left == null ? 'One' : String(left); b.textContent = right == null ? 'Two' : String(right); };
|
|
12
|
+
setText(inputs.left, inputs.right);
|
|
13
|
+
paint();
|
|
14
|
+
|
|
15
|
+
let drag = false;
|
|
16
|
+
handle.addEventListener('pointerdown', (e) => { drag = true; handle.setPointerCapture(e.pointerId); });
|
|
17
|
+
el.addEventListener('pointermove', (e) => {
|
|
18
|
+
if (!drag) return;
|
|
19
|
+
const r = el.getBoundingClientRect();
|
|
20
|
+
if (!r.width) return;
|
|
21
|
+
pct = Math.min(85, Math.max(15, ((e.clientX - r.left) / r.width) * 100));
|
|
22
|
+
paint();
|
|
23
|
+
});
|
|
24
|
+
const stop = () => { drag = false; };
|
|
25
|
+
handle.addEventListener('pointerup', stop);
|
|
26
|
+
handle.addEventListener('pointercancel', stop);
|
|
27
|
+
|
|
28
|
+
return (n) => setText(n.left, n.right);
|
|
29
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Resizable - two panels with a draggable splitter. Pass the panel labels; drag the handle to resize.
|
|
2
|
+
# Resizable(left: "Sidebar", right: "Content")
|
|
3
|
+
# shadcn-for-muten · Custom · host JS is src/components/Resizable.js (edit it to render real content per panel).
|
|
4
|
+
part Resizable(left: text, right: text) {
|
|
5
|
+
Custom Resizable inputs(left: $left, right: $right) class("resizable")
|
|
6
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# Scroll Area - a scrollable region with styled scrollbars (max-height 18rem by default; eject to change).
|
|
2
|
+
# ScrollArea { … long content … }
|
|
3
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .scroll-area class in globals.css.
|
|
4
|
+
part ScrollArea() { Stack class("scroll-area") { slot } }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Select - a dropdown that shows the chosen value. The PAGE owns the value (text) + the open bool + actions:
|
|
2
|
+
# state { fruit = "Apple" : text open = false : bool } action setFruit(v: text) mutates fruit { fruit.set(v) … }
|
|
3
|
+
# Select(value: fruit, open: open, onToggle: t) { SelectItem(value: "Apple", current: fruit, label: "Apple", onSelect: setFruit) … }
|
|
4
|
+
# Each SelectItem's action should set the value AND close. The backdrop catches outside clicks.
|
|
5
|
+
# shadcn-for-muten · part · no deps · styles via the semantic .select* classes in globals.css.
|
|
6
|
+
part Select(value: text, open: bool, onToggle: action) {
|
|
7
|
+
Stack class("select-wrap") {
|
|
8
|
+
Button -> $onToggle aria(expanded: $open) class("select-trigger") {
|
|
9
|
+
Span "{$value}"
|
|
10
|
+
Icon "lucide:chevrons-up-down" class("size-4 opacity-50")
|
|
11
|
+
}
|
|
12
|
+
when $open { Button -> $onToggle class("select-backdrop") }
|
|
13
|
+
Stack class("select-content", "select-content-open" when $open) { slot }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
part SelectItem(value: text, current: text, label: text, onSelect: action) {
|
|
17
|
+
Button -> $onSelect($value) class("select-item") {
|
|
18
|
+
Span "{$label}"
|
|
19
|
+
when $value == $current { Icon "lucide:check" class("size-4") }
|
|
20
|
+
}
|
|
21
|
+
}
|