@muten/shadcn 0.2.9 → 0.2.11

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
@@ -771,12 +771,16 @@ input[type="search"]::-webkit-search-cancel-button, input[type="search"]::-webki
771
771
  .cx-dt td.cx-dt-check { grid-area: c; align-self: center; width: auto; }
772
772
  .cx-dt td.cx-dt-who { grid-area: who; }
773
773
  .cx-dt td.cx-dt-svc { grid-area: meta; color: var(--muted-foreground); font-size: 13px; }
774
+ /* the narrow row holds one text column in its place; any other text column gets a line of its own under it */
775
+ .cx-dt td.cx-dt-svc ~ td.cx-dt-svc { grid-area: auto; grid-column: 2 / -1; }
776
+ .cx-dt tbody tr { grid-auto-rows: min-content; }
774
777
  .cx-dt td.cx-dt-when { display: none; }
775
778
  .cx-dt td.cx-dt-st { grid-area: st; justify-self: end; }
776
779
  .cx-dt td.cx-dt-num { grid-area: amt; }
777
780
  .cx-dt td.cx-dt-act { display: none; }
778
781
  .cx-dt-who small { display: none; }
779
- .cx-dt td.cx-dt-svc::after { content: " · " attr(data-when); }
782
+ .cx-dt td.cx-dt-svc[data-when]:not([data-when=""])::after { content: " · " attr(data-when); }
783
+ .cx-dt td.cx-dt-svc ~ td.cx-dt-svc[data-when]::after { content: none; }
780
784
  }
781
785
 
782
786
  /* ── Board ── */
@@ -5541,3 +5545,18 @@ a.cx-bc-item:hover { color: var(--foreground); }
5541
5545
  /* LangScope: a plain block; its host draws nothing */
5542
5546
  .cx-lang-scope { display: contents; }
5543
5547
  .cx-lang-host { display: none; }
5548
+
5549
+ /* ── Field: a field that must be filled says so at its label ── */
5550
+ .cn-label.cx-required::after { content: " *"; color: var(--destructive); }
5551
+
5552
+ /* ── Alert with an action: the action takes its own column (Nova's absolute corner reserves 72 px, and a wider button
5553
+ covered the title); on a phone it goes under the text ── */
5554
+ .style-nova .cn-alert:has(> .cn-alert-action) { padding-right: 10px; grid-template-columns: 1fr auto; }
5555
+ .style-nova .cn-alert:has(> :is(svg, .mu-icon)):has(> .cn-alert-action) { grid-template-columns: auto 1fr auto; }
5556
+ .style-nova .cn-alert > .cn-alert-action { position: static; grid-column: -2; grid-row: 1 / span 9; align-self: center; margin-left: 8px; }
5557
+ @media (max-width: 560px) {
5558
+ .style-nova .cn-alert:has(> .cn-alert-action) { grid-template-columns: 1fr; }
5559
+ .style-nova .cn-alert:has(> :is(svg, .mu-icon)):has(> .cn-alert-action) { grid-template-columns: auto 1fr; }
5560
+ .style-nova .cn-alert > .cn-alert-action { grid-column: 1 / -1; grid-row: auto; justify-self: start; margin: 8px 0 0; }
5561
+ .style-nova .cn-alert:has(> :is(svg, .mu-icon)) > .cn-alert-action { grid-column: 2; }
5562
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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",
@@ -3,15 +3,15 @@
3
3
  # Field(label: "Description", description: "Shows in your shop.") { TextArea(value: desc) }
4
4
  # Two short fields side by side: FieldRow { Field(…) { … } Field(…) { … } } (one column on a phone).
5
5
  # error: a message that replaces the description in the same place (nothing jumps) and marks the field invalid.
6
- # orientation: vertical | horizontal.
6
+ # orientation: vertical | horizontal. required: true marks the label (a red asterisk) for a field that must be filled.
7
7
  # FieldSet - sets the size and the look of every field inside it, the kit's own too (PasswordField, PhoneField…):
8
8
  # FieldSet(size: "2xl", variant: "filled") { Field(label: "Email") { … } PasswordField(label: "Password") }
9
9
  # FieldSeparator - a line with a word in the middle, between two ways in: FieldSeparator(label: "or with your email")
10
10
  # FieldHead - a label with an action beside it: FieldHead(label: "Password") { LinkBtn(to: "/recover", variant: "link", size: "sm") { Span "Forgot it?" } }
11
11
  # shadcn-for-muten · part · no deps · Nova (.cn-field) + kit.css.
12
- part Field(label: text = "", description: text = "", error: text = "", orientation: text = "vertical") {
12
+ part Field(label: text = "", description: text = "", error: text = "", orientation: text = "vertical", required: bool = false) {
13
13
  Stack class("cn-field cn-field-orientation-{$orientation} group/field") data(slot: "field", orientation: $orientation, invalid: $error != "") {
14
- Span "{$label}" class("cn-label") data(slot: "label")
14
+ Span "{$label}" class("cn-label", "cx-required" when $required) data(slot: "label")
15
15
  slot
16
16
  when $error == "" { Text "{$description}" class("cn-field-description") data(slot: "field-description") }
17
17
  when $error != "" { Text "{$error}" class("cn-field-error") data(slot: "field-error") aria(role: "alert") }
@@ -2,7 +2,7 @@
2
2
  // and bulk actions are data. Everything else is the artifact's: filters from their type (search · facet · date ·
3
3
  // range) with counts, chips that leave with a fade, rows that fade out / slide / enter, the height easing, sort by
4
4
  // header, selection with its own bulk bar, the phone's “search + Filters” sheet, and a narrow box becoming Items.
5
- // columns [{key, label, type: person|text|date|status|money, sub (person: the key under the name), sortable}]
5
+ // columns [{key, label, type: person|text|date|status|money, sub (person: the key under the name), sortable, sort: "desc" (opens newest first)}]
6
6
  // rows [{id, …keys}] (a date is "yyyy-mm-ddTHH:MM"; a row's date may be "+N" days from today too)
7
7
  // filters [{id, type: search|facet|date|range, label, key | keys (search)}]
8
8
  // statuses [{id, label, tone: ok|wait|info|bad}] · noun ["appointment", "appointments"] · bulk [{id, label, variant}]
@@ -37,10 +37,16 @@ export function dataTable(root, cfg, core, on) {
37
37
  };
38
38
  const SORTERS = Object.fromEntries(COLS.map((c) => [c.key, c.type === 'status' ? (r) => ST_ORDER.indexOf(r[c.key]) : c.type === 'date' ? (r) => +asDate(r[c.key]) : (r) => r[c.key]]));
39
39
  const cbHtml = (label, on) => `<button data-slot="checkbox" role="checkbox" data-state="${on ? 'checked' : 'unchecked'}" aria-checked="${on}" class="cn-checkbox" aria-label="${label}"><span data-slot="checkbox-indicator" class="cn-checkbox-indicator"><svg class="cb-mark" viewBox="0 0 24 24">${CB_CHECK}</svg></span></button>`;
40
- const firstSort = dateCol ? { key: dateCol.key, dir: 1 } : { key: COLS[0]?.key, dir: 1 };
40
+ // the table opens sorted by its date column (else the first); a column with sort: "desc" opens newest first (a log)
41
+ const opening = dateCol ?? COLS[0];
42
+ const firstSort = { key: opening?.key, dir: opening?.sort === 'desc' ? -1 : 1 };
41
43
  const bulk = list(cfg.bulk);
44
+ // rows are selectable only when there is something to do with them: without bulk actions, no checkbox column
45
+ const picks = bulk.length > 0;
46
+ // the phone's «Filters» sheet only when there is a filter besides the search
47
+ const sheetFilters = () => FILTERS.some((f) => f.type !== 'search');
42
48
  const CELL_CLASS = { person: 'cx-dt-who', text: 'cx-dt-svc', date: 'cx-dt-when', status: 'cx-dt-st', money: 'cx-dt-num' };
43
- root.innerHTML = `<div class="cx-dt-host"><div class="cx-dt-toolbar"><div class="cx-hstack cx-dt-tools" role="toolbar" aria-label="${T('Filters')}"></div><span class="cx-text" data-variant="caption" data-dt-shown aria-live="polite"></span></div><div class="cx-hstack cx-dt-chips" hidden></div><div class="cx-dt-bulk" aria-live="polite"><div><span data-dt-count></span><div class="cx-hstack" style="gap:6px">${bulk.map((b) => ui.button({ label: b.label, variant: b.variant || 'outline', size: 'xs', attrs: `data-dt-bulk="${b.id}"` })).join('')}${ui.button({ label: T('Clear selection'), variant: 'ghost', size: 'xs', attrs: 'data-dt-unselect' })}</div></div></div><div class="cx-dt-scroll cx-scroll"><table class="cx-dt"><thead><tr><th class="cx-dt-check">${cbHtml(T('Select all'), false).replace('class="cn-checkbox"', 'class="cn-checkbox" data-dt-all')}</th>${COLS.map((c) => `<th aria-sort="${c.key === firstSort.key ? 'ascending' : 'none'}" ${c.type === 'money' ? 'class="cx-dt-num"' : ''}>${c.sortable === false ? ui.text(c.label, 'label') : `<button class="cn-button cn-button-variant-ghost cn-button-size-xs cx-th" data-sort="${c.key}">${c.label}<svg><use href="#${c.key === firstSort.key ? 'arrowup' : 'sort'}"/></svg></button>`}</th>`).join('')}</tr></thead><tbody></tbody></table></div></div>`;
49
+ root.innerHTML = `<div class="cx-dt-host"><div class="cx-dt-toolbar"><div class="cx-hstack cx-dt-tools" role="toolbar" aria-label="${T('Filters')}"></div><span class="cx-text" data-variant="caption" data-dt-shown aria-live="polite"></span></div><div class="cx-hstack cx-dt-chips" hidden></div><div class="cx-dt-bulk" aria-live="polite"><div><span data-dt-count></span><div class="cx-hstack" style="gap:6px">${bulk.map((b) => ui.button({ label: b.label, variant: b.variant || 'outline', size: 'xs', attrs: `data-dt-bulk="${b.id}"` })).join('')}${ui.button({ label: T('Clear selection'), variant: 'ghost', size: 'xs', attrs: 'data-dt-unselect' })}</div></div></div><div class="cx-dt-scroll cx-scroll"><table class="cx-dt"><thead><tr>${picks ? `<th class="cx-dt-check">${cbHtml(T('Select all'), false).replace('class="cn-checkbox"', 'class="cn-checkbox" data-dt-all')}</th>` : ''}${COLS.map((c) => `<th aria-sort="${c.key === firstSort.key ? (firstSort.dir === 1 ? 'ascending' : 'descending') : 'none'}" ${c.type === 'money' ? 'class="cx-dt-num"' : ''}>${c.sortable === false ? ui.text(c.label, 'label') : `<button class="cn-button cn-button-variant-ghost cn-button-size-xs cx-th" data-sort="${c.key}">${c.label}<svg><use href="#${c.key === firstSort.key ? (firstSort.dir === 1 ? 'arrowup' : 'arrowdown') : 'sort'}"/></svg></button>`}</th>`).join('')}</tr></thead><tbody></tbody></table></div></div>`;
44
50
  const host = $('.cx-dt-host', root), dtTools = $('.cx-dt-tools', root), dtChips = $('.cx-dt-chips', root), dtCountEl = $('[data-dt-shown]', root), dtBody = $('tbody', root), dtAll = $('[data-dt-all]', root), dtBulk = $('.cx-dt-bulk', root);
45
51
  const dtSel = new Set(); let dtSort = firstSort;
46
52
  const passes = (r, except) => FILTERS.every((f) => f.id === except || PASS[f.type](f, fState[f.id], r));
@@ -53,7 +59,7 @@ export function dataTable(root, cfg, core, on) {
53
59
  const paintTools = () => {
54
60
  if (dtMobile) {
55
61
  const n = activeCount(), focused = document.activeElement?.dataset?.filter === searchF?.id;
56
- dtTools.innerHTML = searchHtml() + ui.button({ label: `${ui.icon('filter')}${T('Filters')}${n ? ui.badge(String(n), { variant: 'default' }) : ''}`, size: 'sm', cls: 'cx-fbtn-m', attrs: 'data-fmobile aria-haspopup="dialog"' });
62
+ dtTools.innerHTML = searchHtml() + (sheetFilters() ? ui.button({ label: `${ui.icon('filter')}${T('Filters')}${n ? ui.badge(String(n), { variant: 'default' }) : ''}`, size: 'sm', cls: 'cx-fbtn-m', attrs: 'data-fmobile aria-haspopup="dialog"' }) : '');
57
63
  if (focused) { const i = dtTools.querySelector('[data-filter]'); i.focus(); i.setSelectionRange(i.value.length, i.value.length); }
58
64
  return;
59
65
  }
@@ -146,7 +152,7 @@ export function dataTable(root, cfg, core, on) {
146
152
  return `<td class="cx-dt-svc" ${dateCol ? `data-when="${whenText(r[dateCol.key])}"` : ''}>${v ?? ''}</td>`;
147
153
  };
148
154
  const who = (r) => r[colOf('person')?.key ?? COLS[0]?.key] ?? '';
149
- const rowCells = (r) => `<td class="cx-dt-check">${cbHtml(T('Select {0}', who(r)), dtSel.has(r.id))}</td>${COLS.map((c) => cell(c, r)).join('')}`;
155
+ const rowCells = (r) => `${picks ? `<td class="cx-dt-check">${cbHtml(T('Select {0}', who(r)), dtSel.has(r.id))}</td>` : ''}${COLS.map((c) => cell(c, r)).join('')}`;
150
156
  const EASE = 'cubic-bezier(.23, 1, .32, 1)';
151
157
  let dtAnim = 0;
152
158
  const dtPaint = () => {
@@ -170,7 +176,7 @@ export function dataTable(root, cfg, core, on) {
170
176
  };
171
177
  if (leaving.length && dtBody.children.length) { leaving.forEach((tr) => tr.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 130, easing: 'ease-out', fill: 'forwards' })); setTimeout(apply, 130); } else apply();
172
178
  dtCountEl.textContent = rows.length === DT.length ? `${DT.length} ${DT.length === 1 ? ONE : MANY}` : `${rows.length} de ${DT.length} ${MANY}`; paintChips();
173
- setCheck(dtAll, dtSel.size === 0 ? false : dtSel.size === DT.length ? true : 'mixed');
179
+ if (dtAll) setCheck(dtAll, dtSel.size === 0 ? false : dtSel.size === DT.length ? true : 'mixed');
174
180
  dtBulk.toggleAttribute('data-open', dtSel.size > 0);
175
181
  if (dtSel.size) $('[data-dt-count]', root).textContent = `${dtSel.size} ${dtSel.size === 1 ? 'seleccionada' : 'seleccionadas'}`;
176
182
  syncMobileCount(rows.length);
@@ -181,10 +187,10 @@ export function dataTable(root, cfg, core, on) {
181
187
  $$('thead [data-sort]', root).forEach((b) => { const on = b.dataset.sort === key; b.parentElement.setAttribute('aria-sort', on ? (dtSort.dir === 1 ? 'ascending' : 'descending') : 'none'); $('use', b).setAttribute('href', on ? (dtSort.dir === 1 ? '#arrowup' : '#arrowdown') : '#sort'); });
182
188
  dtPaint();
183
189
  });
184
- dtAll.addEventListener('click', () => { if (dtSel.size === DT.length) dtSel.clear(); else DT.forEach((r) => dtSel.add(r.id)); dtPaint(); });
190
+ dtAll?.addEventListener('click', () => { if (dtSel.size === DT.length) dtSel.clear(); else DT.forEach((r) => dtSel.add(r.id)); dtPaint(); });
185
191
  dtBody.addEventListener('click', (e) => {
186
192
  if (e.target.closest('[data-fclear-empty]')) { dtTools.querySelector('[data-fclear]')?.click(); return; }
187
- const tr = e.target.closest('tr'); if (!tr || tr.dataset.id === 'empty') return;
193
+ const tr = e.target.closest('tr'); if (!picks || !tr || tr.dataset.id === 'empty') return;
188
194
  if (!e.target.closest('.cn-checkbox') && !host.hasAttribute('data-narrow') && !matchMedia('(max-width: 640px)').matches) return;
189
195
  const raw = tr.dataset.id, id = DT.find((r) => String(r.id) === raw)?.id; dtSel.has(id) ? dtSel.delete(id) : dtSel.add(id); dtPaint();
190
196
  });
@@ -196,5 +202,12 @@ export function dataTable(root, cfg, core, on) {
196
202
  dtSel.clear(); dtPaint(); on?.action?.(JSON.stringify({ action: b.dataset.dtBulk, ids }));
197
203
  });
198
204
  paintTools(); dtPaint();
199
- return { refresh: () => { dtPaint(); paintTools(); } };
205
+ // new rows from the page (a live list): the same table takes them, keeping its sort, filters and selection
206
+ const setRows = (rows) => {
207
+ DT.splice(0, DT.length, ...list(rows).map((r, i) => ({ id: r.id ?? i, ...r })));
208
+ const ids = new Set(DT.map((r) => r.id));
209
+ [...dtSel].forEach((id) => { if (!ids.has(id)) dtSel.delete(id); });
210
+ dtPaint(); paintTools();
211
+ };
212
+ return { refresh: () => { dtPaint(); paintTools(); }, setRows };
200
213
  }
@@ -48,12 +48,14 @@ export function mount(el, inputs, on) {
48
48
  el.addEventListener('input', () => on.action?.(JSON.stringify(wh.value())));
49
49
  },
50
50
  };
51
- RENDER.table = (core, i) => import('@muten/shadcn/registry/kit/table.js').then(({ dataTable }) => dataTable(el, { columns: i.columns, rows: i.rows, filters: i.filters, statuses: i.statuses, noun: [i.one || 'fila', i.many || 'filas'], bulk: i.bulk }, core, on));
51
+ RENDER.table = (core, i) => import('@muten/shadcn/registry/kit/table.js').then(({ dataTable }) => (shown = dataTable(el, { columns: i.columns, rows: i.rows, filters: i.filters, statuses: i.statuses, noun: [i.one || 'fila', i.many || 'filas'], bulk: i.bulk }, core, on)));
52
52
  // until the kit arrives the host keeps its final size with a shimmer (kit.css, :empty), then the widget fades in
53
53
  el.classList.add('cx-kw-wait');
54
54
  // its language: inputs.lang, else the nearest lang (the page's); a lang that changes repaints it in the new one
55
55
  if (inputs.lang) el.setAttribute('lang', inputs.lang);
56
56
  let core0 = null;
57
+ // what a widget hands back to be fed later (the table's setRows)
58
+ let shown = null;
57
59
  import('@muten/shadcn/registry/kit/core.js').then((core) => {
58
60
  core0 = core;
59
61
  const render = RENDER[inputs.name]; if (!render) return;
@@ -80,5 +82,6 @@ export function mount(el, inputs, on) {
80
82
  el.addEventListener('pointerdown', () => { stopped = true; clearTimeout(timer); }, { once: true, capture: true });
81
83
  timer = setTimeout(tick, d.every);
82
84
  }
83
- return () => {};
85
+ // muten calls this when a bound state changes: a table takes its new rows in place, the rest keep their snapshot
86
+ return (next) => { if (inputs.name === 'table' && next && shown?.setRows) shown.setRows(next.rows); };
84
87
  }