@muten/shadcn 0.2.11 → 0.2.13

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/kit.css CHANGED
@@ -606,8 +606,8 @@ input[type="search"]::-webkit-search-cancel-button, input[type="search"]::-webki
606
606
  .cx-otp[data-state="bad"] .cn-input-otp-slot { border-color: var(--destructive); }
607
607
  .cx-otp[data-state="bad"] { animation: cx-shake calc(var(--t-move) * 1.35) var(--ease-out); }
608
608
  .cx-otp[data-state="ok"] .cn-input-otp-slot { border-color: var(--success); color: var(--success); }
609
- #otp-msg[data-tone="bad"] { color: var(--destructive); }
610
- #otp-msg[data-tone="ok"] { color: var(--success); }
609
+ [data-otp-msg][data-tone="bad"] { color: var(--destructive); }
610
+ [data-otp-msg][data-tone="ok"] { color: var(--success); }
611
611
 
612
612
  /* ── Color picker ── */
613
613
  .cx-swatch-row { display: flex; flex-wrap: wrap; gap: 10px; }
@@ -713,7 +713,7 @@ input[type="search"]::-webkit-search-cancel-button, input[type="search"]::-webki
713
713
  .cx-pagination .cx-pg-num .cn-button, .cx-pg-gap { width: 32px; }
714
714
  .cx-pg-gap { display: flex; justify-content: center; }
715
715
  .cx-pg-list { transition: opacity 160ms ease; }
716
- .cx-pg-list[data-loading] { opacity: .45; }
716
+ .cx-pg-list[data-loading="true"] { opacity: .45; } /* muten writes "false" too: only true dims */
717
717
  .cx-pg-empty { border-top-color: transparent; }
718
718
  /* virtual list */
719
719
  .cx-vlist { height: 364px; overflow-y: auto; border-radius: calc(var(--radius) * 1.2); box-shadow: 0 0 0 1px var(--border); overscroll-behavior: contain; }
@@ -5560,3 +5560,50 @@ a.cx-bc-item:hover { color: var(--foreground); }
5560
5560
  .style-nova .cn-alert > .cn-alert-action { grid-column: 1 / -1; grid-row: auto; justify-self: start; margin: 8px 0 0; }
5561
5561
  .style-nova .cn-alert:has(> :is(svg, .mu-icon)) > .cn-alert-action { grid-column: 2; }
5562
5562
  }
5563
+
5564
+ /* ═════ SIDEBAR SUB · the level below an item (SidebarSub, SidebarSubLink) ═════
5565
+ Indented under its parent with a hairline on the left; an item holds an optional status dot or a spinning ring, the
5566
+ name, a hint in words that opens on hover (always shown where there is no hover), and a count. */
5567
+ .cx-sb-sub { display: flex; flex-direction: column; gap: 1px; margin: 2px 0 4px 15px; padding-left: 10px; border-left: 1px solid var(--border); min-width: 0; }
5568
+ .cx-sidebar[data-collapsed="true"] .cx-sb-sub { display: none; }
5569
+ .cx-sb-subitem { position: relative; display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 0 8px; border-radius: calc(var(--radius) * .7); color: var(--muted-foreground); font-size: 13.5px; text-decoration: none; white-space: nowrap; min-width: 0; transition: background-color .15s ease, color .15s ease; }
5570
+ .cx-sb-subitem:hover { background: var(--accent); color: var(--foreground); }
5571
+ .cx-sb-subitem[data-active="true"] { background: var(--selected-soft); color: var(--selected); font-weight: 500; }
5572
+ .cx-sb-subitem:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
5573
+ .cx-sb-subitem > .mu-icon { width: 15px; height: 15px; flex: none; }
5574
+ .cx-sb-subitem > .cx-sb-count { margin-left: auto; }
5575
+ .cx-sb-sub > .cx-sb-subitem[data-tone] { color: var(--foreground); font-size: 14px; }
5576
+ .cx-sb-subitem:has(+ .cx-sb-sub) { font-weight: 600; }
5577
+ .cx-sb-dot { width: 8px; height: 8px; border-radius: 99px; flex: none; background: var(--muted-foreground); }
5578
+ .cx-sb-subitem[data-tone="ok"] > .cx-sb-dot { background: var(--success); }
5579
+ .cx-sb-subitem[data-tone="wait"] > .cx-sb-dot { background: var(--warning); }
5580
+ .cx-sb-subitem[data-tone="bad"] > .cx-sb-dot { background: var(--destructive); }
5581
+ .cx-sb-ring { width: 12px; height: 12px; flex: none; box-sizing: border-box; border-radius: 99px; border: 2px solid var(--warning); border-right-color: transparent; animation: cx-sb-spin .7s linear infinite; }
5582
+ .cx-sb-hint { margin-left: auto; font-size: 12px; color: var(--warning); opacity: 0; transform: translateX(4px); transition: opacity .15s ease, transform .2s var(--ease-out); flex: none; }
5583
+ .cx-sb-subitem:hover > .cx-sb-hint, .cx-sb-subitem:focus-visible > .cx-sb-hint { opacity: 1; transform: none; }
5584
+ /* with a mouse the words take the name's place on hover (the name fades, they fade in where it was): the row never
5585
+ changes size and nothing shows cut. Without hover they sit after the name. */
5586
+ @media (hover: hover) {
5587
+ .cx-sb-subitem { --hint-x: 28px; }
5588
+ .cx-sb-subitem:has(> .cx-sb-dot) { --hint-x: 24px; }
5589
+ .cx-sb-hint { position: absolute; left: var(--hint-x); right: 8px; top: 50%; translate: 0 -50%; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; pointer-events: none; }
5590
+ .cx-sb-label { transition: opacity .15s ease; }
5591
+ .cx-sb-subitem:hover > .cx-sb-label:has(+ .cx-sb-hint), .cx-sb-subitem:focus-visible > .cx-sb-label:has(+ .cx-sb-hint) { opacity: 0; }
5592
+ }
5593
+ @media (hover: none) { .cx-sb-hint { opacity: 1; transform: none; } }
5594
+
5595
+ /* ═════ TONE BADGE · busy, compact and lg (ToneBadge) ═════
5596
+ busy swaps the dot for a ring that spins; compact keeps only the dot or the ring and opens the words beside it on
5597
+ hover or focus (their column grows from 0fr, so nothing jumps); without hover they always show. lg sits beside a
5598
+ page's title. */
5599
+ .cn-badge[class*="cx-tone-"] .cx-tone-words { display: grid; grid-template-columns: 1fr; overflow: hidden; }
5600
+ .cn-badge[class*="cx-tone-"] .cx-tone-words > span { min-width: 0; white-space: nowrap; }
5601
+ .cn-badge[data-size="lg"] { height: 28px; padding: 0 12px; gap: 8px; font-size: 14px; font-weight: 600; border: 1px solid color-mix(in oklch, var(--tone, var(--muted-foreground)) 30%, transparent); }
5602
+ .cn-badge[data-size="lg"] .cx-dot { width: 8px; height: 8px; }
5603
+ .cx-tone-ring { width: 12px; height: 12px; flex: none; box-sizing: border-box; border-radius: 99px; border: 2px solid var(--tone, currentColor); border-right-color: transparent; animation: cx-sb-spin .7s linear infinite; }
5604
+ .cn-badge[data-compact="true"] { gap: 0; padding-inline: 8px; transition: gap .2s var(--ease-out), padding .2s var(--ease-out); }
5605
+ .cn-badge[data-compact="true"] .cx-tone-words { grid-template-columns: 0fr; opacity: 0; transition: grid-template-columns .2s var(--ease-out), opacity .15s ease; }
5606
+ .cn-badge[data-compact="true"]:hover, .cn-badge[data-compact="true"]:focus-within { gap: 8px; padding-inline: 12px; }
5607
+ .cn-badge[data-compact="true"]:hover .cx-tone-words, .cn-badge[data-compact="true"]:focus-within .cx-tone-words { grid-template-columns: 1fr; opacity: 1; }
5608
+ @media (hover: none) { .cn-badge[data-compact="true"] { gap: 8px; padding-inline: 12px; } .cn-badge[data-compact="true"] .cx-tone-words { grid-template-columns: 1fr; opacity: 1; } }
5609
+ @media (prefers-reduced-motion: reduce) { .cx-sb-ring, .cx-tone-ring { animation-duration: 1.6s; } .cn-badge[data-compact="true"], .cn-badge[data-compact="true"] .cx-tone-words, .cx-sb-hint { transition: none; } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "shadcn-style component registry for muten - own-the-source parts you add with `muten add`.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,10 +19,15 @@ part StatusBadge(label: text, tone: text = "") { Span "{$label}" class("cn-badge
19
19
  # ToneBadge - a state with its tone, tinted, with or without a dot: new | ok | wait | bad | pro (PRO uses --selected).
20
20
  # The word is the tone mixed toward the ink, so it reads on its tint in light and dark.
21
21
  # ToneBadge(label: "Confirmed", tone: "ok") ToneBadge(label: "Pending", tone: "wait", dot: "dot")
22
- part ToneBadge(label: text, tone: text, dot: text = "") {
23
- Stack class("cn-badge group/badge cx-tone-{$tone}") data(slot: "badge") {
24
- when $dot != "" { Span "" class("cx-dot") }
25
- Span "{$label}"
22
+ # busy: a ring that spins instead of the dot (something on its way). compact: only the dot or the ring, the words open
23
+ # beside it on hover or focus (where there is no hover they always show; a screen reader always reads them).
24
+ # size "lg": the state beside a page's title.
25
+ # ToneBadge(label: "Building v24", tone: "wait", busy: true, compact: true, size: "lg")
26
+ part ToneBadge(label: text, tone: text, dot: text = "", busy: bool = false, compact: bool = false, size: text = "") {
27
+ Stack class("cn-badge group/badge cx-tone-{$tone}") data(slot: "badge", busy: $busy, compact: $compact, size: $size) {
28
+ when $busy { Span "" class("cx-tone-ring") aria(hidden: "true") }
29
+ when not $busy and $dot != "" { Span "" class("cx-dot") }
30
+ Stack class("cx-tone-words") { Span "{$label}" }
26
31
  }
27
32
  }
28
33
  # Chip - a value the person can take away with its ×; it fades out (no jump). The page owns the list.
@@ -1,7 +1,9 @@
1
1
  # InputOtp - the verification code: slots in two groups, a caret, “Verificando…”, ok / bad, a resend countdown. The
2
2
  # PAGE checks the code: onInput gets it when complete; answer with state ("ok" | "bad") and message.
3
3
  # InputOtp(value: code, length: 6, hint: "We sent it to +57 300 123 4567", state: otpState, message: otpMsg, onInput: check)
4
+ # InputOtp(length: 8, letters: true, resendIn: 0, …) a code copied from another screen (KQ4M-7T2X): letters and
5
+ # digits, in capitals, a pasted dash dropped, and no resend button (resendIn 0 never offers one).
4
6
  # shadcn-for-muten · Custom (kit values) · host JS input-otp.js.
5
- part InputOtp(value: text = "", length: number = 6, hint: text = "", state: text = "", message: text = "", resendIn: number = 30, onInput: action) {
6
- Custom InputOtp inputs(value: $value, length: $length, hint: $hint, state: $state, message: $message, resendIn: $resendIn) on(input: $onInput) class("cx-otp-host")
7
+ part InputOtp(value: text = "", length: number = 6, letters: bool = false, hint: text = "", state: text = "", message: text = "", resendIn: number = 30, onInput: action) {
8
+ Custom InputOtp inputs(value: $value, length: $length, letters: $letters, hint: $hint, state: $state, message: $message, resendIn: $resendIn) on(input: $onInput) class("cx-otp-host")
7
9
  }
@@ -90,7 +90,7 @@ Object.assign(ES, {
90
90
  'maximum': 'máximo', 'minimum': 'mínimo', 'Currency': 'Moneda', 'Country': 'País', '{0} (it has no minor unit)': '{0} (no tiene unidad menor)', 'cents of {0}': 'centavos de {0}',
91
91
  'Saved as: {0} {1}': 'Se guarda: {0} {1}', 'Type a price': 'Escribe un precio', 'Search currency': 'Buscar moneda', 'Search country': 'Buscar país',
92
92
  'Saved as +{0}': 'Se guarda como +{0}', '{0} digits short for a number from {1}': 'Faltan {0} dígitos para un número de {1}', '{0} digits, without the {1}': '{0} dígitos, sin el {1}',
93
- '{0}-digit code': 'Código de {0} dígitos', 'Checking…': 'Verificando…', 'Resend in 0:{0}': 'Reenviar en 0:{0}', 'Resend code': 'Reenviar código',
93
+ '{0}-digit code': 'Código de {0} dígitos', '{0}-character code': 'Código de {0} caracteres', 'Checking…': 'Verificando…', 'Resend in 0:{0}': 'Reenviar en 0:{0}', 'Resend code': 'Reenviar código',
94
94
  'We sent you a new code': 'Te enviamos un código nuevo', 'Done, it is verified': 'Listo, quedó verificado', 'That is not the code': 'Ese código no es',
95
95
  });
96
96
 
@@ -157,7 +157,14 @@ export function phoneField(root, i, core, on) {
157
157
  export function otpField(root, i, core, on) {
158
158
  const { $ } = core, T = (k, ...a) => core.t(core.langOf(root), k, ...a);
159
159
  const len = Number(i.length) || 6, half = Math.ceil(len / 2);
160
- root.innerHTML = `<div class="cx-vline cx-otp-stack"><div class="cx-otp"><input class="cx-otp-input" inputmode="numeric" autocomplete="one-time-code" maxlength="${len}" aria-label="${T('{0}-digit code', len)}"><div data-slot="input-otp" class="cn-input-otp" aria-hidden="true"><div data-slot="input-otp-group" class="cn-input-otp-group"></div><div data-slot="input-otp-separator" class="cn-input-otp-separator cx-otp-sep"><span></span></div><div data-slot="input-otp-group" class="cn-input-otp-group"></div></div></div><p class="cx-note" data-otp-msg></p><button data-slot="button" class="cn-button cn-button-variant-link cn-button-size-sm group/button" data-otp-resend disabled></button></div>`;
160
+ // letters: a code people copy from another screen (KQ4M-7T2X), so letters and digits, shown in capitals, and room in the
161
+ // field for a pasted dash or spaces, which are dropped (maxlength would otherwise cut the paste before the cleanup)
162
+ const letters = i.letters === true || i.letters === 'true';
163
+ const keep = letters ? (v) => v.toUpperCase().replace(/[^A-Z0-9]/g, '') : (v) => v.replace(/\D/g, '');
164
+ const field = letters
165
+ ? `inputmode="text" autocapitalize="characters" autocomplete="off" spellcheck="false" maxlength="${len * 2}" aria-label="${T('{0}-character code', len)}"`
166
+ : `inputmode="numeric" autocomplete="one-time-code" maxlength="${len}" aria-label="${T('{0}-digit code', len)}"`;
167
+ root.innerHTML = `<div class="cx-vline cx-otp-stack"><div class="cx-otp"><input class="cx-otp-input" ${field}><div data-slot="input-otp" class="cn-input-otp" aria-hidden="true"><div data-slot="input-otp-group" class="cn-input-otp-group"></div><div data-slot="input-otp-separator" class="cn-input-otp-separator cx-otp-sep"><span></span></div><div data-slot="input-otp-group" class="cn-input-otp-group"></div></div></div><p class="cx-note" data-otp-msg></p><button data-slot="button" class="cn-button cn-button-variant-link cn-button-size-sm group/button" data-otp-resend disabled></button></div>`;
161
168
  const otpIn = $('.cx-otp-input', root), otpBox = $('.cx-otp', root), otpMsg = $('[data-otp-msg]', root), resend = $('[data-otp-resend]', root);
162
169
  const slots = [];
163
170
  root.querySelectorAll('.cn-input-otp-group').forEach((g, gi) => { const n = gi ? len - half : half; for (let k = 0; k < n; k++) { const s = document.createElement('div'); s.className = 'cn-input-otp-slot'; s.dataset.slot = 'input-otp-slot'; g.appendChild(s); slots.push(s); } });
@@ -166,16 +173,20 @@ export function otpField(root, i, core, on) {
166
173
  const paint = () => { const v = otpIn.value, focused = document.activeElement === otpIn, at = Math.min(v.length, len - 1); slots.forEach((s, k) => { s.textContent = v[k] || ''; const act = focused && k === at && !busy; s.dataset.active = act; if (act && !v[k]) s.innerHTML = '<span class="cx-caret"></span>'; }); };
167
174
  const toEnd = () => { const n = otpIn.value.length; otpIn.setSelectionRange(n, n); };
168
175
  otpIn.addEventListener('input', () => {
169
- otpIn.value = otpIn.value.replace(/\D/g, '').slice(0, len); delete otpBox.dataset.state; otpMsg.removeAttribute('data-tone'); otpMsg.textContent = hint; paint();
176
+ otpIn.value = keep(otpIn.value).slice(0, len); delete otpBox.dataset.state; otpMsg.removeAttribute('data-tone'); otpMsg.textContent = hint; paint();
170
177
  if (otpIn.value.length === len) { busy = true; otpIn.readOnly = true; otpMsg.textContent = T('Checking…'); paint(); on?.complete?.(otpIn.value); }
171
178
  });
172
179
  ['focus', 'blur', 'click', 'keyup'].forEach((t) => otpIn.addEventListener(t, () => { toEnd(); paint(); }));
173
180
  otpIn.addEventListener('select', toEnd);
181
+ // resendIn 0: a code that is not sent again (one a shop gave you, one a terminal shows), so no resend button at all
174
182
  let left = Number(i.resendIn ?? 30), rt = 0;
183
+ const resends = left > 0;
184
+ resend.hidden = !resends;
175
185
  const tick = () => { left--; resend.disabled = left > 0; resend.textContent = left > 0 ? T('Resend in 0:{0}', String(left).padStart(2, '0')) : T('Resend code'); if (left > 0) rt = setTimeout(tick, 1000); };
176
186
  resend.textContent = T('Resend in 0:{0}', String(left).padStart(2, '0'));
177
187
  resend.onclick = () => { left = Number(i.resendIn ?? 30) + 1; clearTimeout(rt); tick(); otpMsg.removeAttribute('data-tone'); otpMsg.textContent = T('We sent you a new code'); otpIn.value = ''; delete otpBox.dataset.state; otpIn.focus(); on?.resend?.(); };
178
- rt = setTimeout(tick, 1000); paint();
188
+ if (resends) rt = setTimeout(tick, 1000);
189
+ paint();
179
190
  const answer = (st, msg) => {
180
191
  if (!st || st === 'checking') return;
181
192
  busy = false; otpIn.readOnly = false;
@@ -2,6 +2,20 @@
2
2
  // the Modal part's (muten state + kit.css). This only does what markup cannot: history (Back closes), Esc, the focus
3
3
  // trap, the scroll lock, the auto mode (dialog on a wide screen, drawer on a phone) and the drawer's drag to close.
4
4
  // Same rules as the reference artifact's modal.
5
+ // MODALS STACK (a receipt opened over a cita): Back, Esc and the focus trap belong to the TOP one only, and the page's
6
+ // scroll is unlocked when the last one closes. Each modal used to listen to every Back: closing the receipt went back
7
+ // in history and that Back also closed the cita under it, leaving the agenda without its buttons (21-10-2026).
8
+ const stack = (globalThis.__cxModals ||= { list: [], skip: 0, wired: false });
9
+ if (!stack.wired) {
10
+ stack.wired = true;
11
+ addEventListener('popstate', () => {
12
+ // the history.back() a modal makes when it closes itself is its own, not a Back for the one under it
13
+ if (stack.skip > 0) { stack.skip -= 1; return; }
14
+ stack.list.at(-1)?.onPop();
15
+ });
16
+ }
17
+ const isTop = (me) => stack.list.at(-1) === me;
18
+
5
19
  export function mount(el, inputs, on) {
6
20
  const mdl = el.closest('.cx-modal'), root = mdl.parentElement, ov = root.querySelector(':scope > .cx-overlay');
7
21
  const body = mdl.querySelector(':scope > .cx-modal-body');
@@ -11,6 +25,7 @@ export function mount(el, inputs, on) {
11
25
  // of opening: a dialog that only learns it is a dialog as it opens animates its centring from the corner
12
26
  const resolve = () => { if (auto && !isOpen) mdl.dataset.mode = phone.matches ? 'drawer' : 'dialog'; };
13
27
  let isOpen = false, pushed = false, lastFocus = null;
28
+ const me = { onPop: () => { if (isOpen) { pushed = false; close(); } } };
14
29
  queueMicrotask(() => resolve()); phone.addEventListener('change', resolve);
15
30
  // the footer sticks under the scrolling body, not inside it
16
31
  const placeFooter = () => { const f = body.querySelector('.cx-modal-foot'); if (f) mdl.appendChild(f); };
@@ -40,6 +55,7 @@ export function mount(el, inputs, on) {
40
55
  if (auto) mdl.dataset.mode = phone.matches ? 'drawer' : 'dialog';
41
56
  if (wantsAnchor()) place(); else unplace();
42
57
  lastFocus = document.activeElement;
58
+ stack.list.push(me);
43
59
  document.documentElement.classList.add('cx-locked');
44
60
  // on the phone, focusing a field would throw the keyboard over the sheet: focus the sheet itself
45
61
  requestAnimationFrame(() => { ((mdl.dataset.mode === 'dialog' && focusables().find((x) => x.matches('input, textarea'))) || mdl).focus({ preventScroll: true }); });
@@ -48,9 +64,10 @@ export function mount(el, inputs, on) {
48
64
  const shut = () => {
49
65
  if (!isOpen) return; isOpen = false;
50
66
  mdl.style.transform = ''; if (ov) ov.style.opacity = '';
51
- document.documentElement.classList.remove('cx-locked');
67
+ stack.list = stack.list.filter((one) => one !== me);
68
+ if (stack.list.length === 0) document.documentElement.classList.remove('cx-locked');
52
69
  lastFocus?.focus?.({ preventScroll: true });
53
- if (pushed) { pushed = false; history.back(); }
70
+ if (pushed) { pushed = false; stack.skip += 1; history.back(); }
54
71
  };
55
72
  mdl.tabIndex = -1;
56
73
  // A link inside (a menu in a drawer): close first, then go. Closing undoes the history entry the modal pushed, and
@@ -64,9 +81,8 @@ export function mount(el, inputs, on) {
64
81
  pushed = false; addEventListener('popstate', go);
65
82
  close(); history.back();
66
83
  }, true);
67
- addEventListener('popstate', () => { if (isOpen) { pushed = false; close(); } });
68
84
  document.addEventListener('keydown', (e) => {
69
- if (!isOpen) return;
85
+ if (!isOpen || !isTop(me)) return;
70
86
  if (e.key === 'Escape') { e.preventDefault(); close(); }
71
87
  if (e.key === 'Tab') { const f = focusables(); if (!f.length) return; const first = f[0], last = f[f.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }
72
88
  });
@@ -9,6 +9,17 @@
9
9
  # SidebarLink is the same item as a link to a route: the app marks where you are by itself (no `current` to keep), also
10
10
  # below it (on /schedule/42 the “Appointments” link stays marked). `match` lists other pages it stands for ("/a /b").
11
11
  # SidebarLink(label: "Appointments", to: "/schedule", count: "6") { Icon "lucide:calendar-days" }
12
+ # SidebarSub is the level below an item (shadcn's SidebarMenuSub): indented, with a line on its left. Nest one inside
13
+ # another for one more level. SidebarSubLink is an item in it; the app says which one is `active` (a tab, a query: a
14
+ # route alone cannot tell), `tone` puts a status dot before it (ok · wait · bad · idle), `busy` a ring that spins
15
+ # instead, and `hint` says that state in words: on hover beside the name, always for a screen reader. `active` and
16
+ # `busy` are "true" or anything else (text, so a row's own call can fill them: "{isOpen(s.name)}"). An item with its
17
+ # own level open below it is set in bold.
18
+ # SidebarLink(label: "Services", to: "/services") { Icon "lucide:layers" }
19
+ # SidebarSub(label: "Your services") {
20
+ # SidebarSubLink(label: "api", to: "/services/api", tone: "ok", busy: "{building}", hint: "Building v24")
21
+ # SidebarSub(label: "api") { SidebarSubLink(label: "Logs", to: "/services/api?tab=logs", active: "{tab == "logs"}") { Icon "lucide:terminal" } }
22
+ # }
12
23
  # The name AppSidebar avoids muten's own Sidebar primitive. shadcn-for-muten · parts · no deps · Nova (.cn-sidebar-*) + kit.css.
13
24
  part AppSidebar(collapsed: bool, label: text = "Menu") {
14
25
  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 } }
@@ -40,3 +51,16 @@ part SidebarLink(label: text, to: text, count: text = "", match: text = "") {
40
51
  Span "{$count}" class("cn-sidebar-menu-badge cx-sb-count") data(slot: "sidebar-menu-badge")
41
52
  }
42
53
  }
54
+ part SidebarSub(label: text = "") {
55
+ Stack class("cn-sidebar-menu-sub cx-sb-sub") data(slot: "sidebar-menu-sub") aria(role: "group", label: $label) { slot }
56
+ }
57
+ part SidebarSubLink(label: text, to: text, active: text = "", count: text = "", tone: text = "", busy: text = "", hint: text = "") {
58
+ Link -> "{$to}" class("cn-sidebar-menu-sub-button cx-sb-subitem") data(slot: "sidebar-menu-sub-button", active: "{$active == "true"}", tone: $tone, busy: "{$busy == "true"}", name: $label) aria(current: "{$active == "true" ? "page" : "false"}", label: "{$label}{$hint == "" ? "" : ", {$hint}"}") {
59
+ when $busy == "true" { Span "" class("cx-sb-ring") aria(hidden: "true") }
60
+ when $busy != "true" and $tone != "" { Span "" class("cx-sb-dot") aria(hidden: "true") }
61
+ slot
62
+ Span "{$label}" class("cx-sb-label")
63
+ when $hint != "" { Span "{$hint}" class("cx-sb-hint") aria(hidden: "true") }
64
+ when $count != "" { Span "{$count}" class("cn-sidebar-menu-badge cx-sb-count") data(slot: "sidebar-menu-badge") }
65
+ }
66
+ }
package/registry.json CHANGED
@@ -457,7 +457,7 @@
457
457
  "part": "InputOtp",
458
458
  "file": "registry/input-otp.muten",
459
459
  "component": "InputOtp",
460
- "description": "InputOtp(value, length, hint, state, message, resendIn, onInput): slots, caret, ok/bad, resend countdown."
460
+ "description": "InputOtp(value, length, letters, hint, state, message, resendIn, onInput): slots, caret, ok/bad, resend countdown (none with resendIn 0); letters for a code like KQ4M-7T2X."
461
461
  },
462
462
  {
463
463
  "name": "input",
@@ -936,7 +936,7 @@
936
936
  "deps": [],
937
937
  "part": "AppSidebar",
938
938
  "file": "registry/sidebar.muten",
939
- "description": "AppSidebar - a collapsible nav panel (SidebarHeader / SidebarGroup / SidebarItem). Named AppSidebar so it never shadows muten's core Sidebar <aside> primitive."
939
+ "description": "AppSidebar - a collapsible nav panel (SidebarHeader / SidebarGroup / SidebarItem / SidebarLink), with SidebarSub / SidebarSubLink for the level below an item (active, a status dot or spinning ring, a hint on hover). Named AppSidebar so it never shadows muten's core Sidebar <aside> primitive."
940
940
  },
941
941
  {
942
942
  "name": "site-footer",