@marianmeres/stuic 3.174.0 → 3.176.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.
package/AGENTS.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ```
25
25
  src/lib/
26
- ├── components/ # 78 component directories
26
+ ├── components/ # 79 component directories
27
27
  ├── actions/ # 16 Svelte actions (use: directives)
28
28
  ├── attachments/ # Svelte attachments ({@attach} — preferred for new DOM helpers)
29
29
  ├── utils/ # 55 utility modules (48 on the barrel)
@@ -146,7 +146,7 @@ so it is the only confusable pair — do not "fix" one into the other.
146
146
 
147
147
  ### Domain Docs
148
148
 
149
- - [Components](./docs/domains/components.md) — 78 component directories, Props pattern, snippets
149
+ - [Components](./docs/domains/components.md) — 79 component directories, Props pattern, snippets
150
150
  - [Theming](./docs/domains/theming.md) — CSS tokens, dark mode, themes
151
151
  - [CSS presets](./docs/domains/css-presets.md) — ratio-locked frame (letterbox), safe-area, scrollbar
152
152
  - [Actions](./docs/domains/actions.md) — 16 Svelte directives
@@ -237,7 +237,7 @@
237
237
  import Button from "../Button/Button.svelte";
238
238
  import EmptyState from "../EmptyState/EmptyState.svelte";
239
239
  import Pagination from "../Pagination/Pagination.svelte";
240
- import Thc, { isTHCNotEmpty, getTHCStringContent } from "../Thc/Thc.svelte";
240
+ import Thc, { isTHCNotEmpty } from "../Thc/Thc.svelte";
241
241
 
242
242
  let {
243
243
  columns,
@@ -746,7 +746,7 @@
746
746
  <div class={!unstyled ? "stuic-data-table-card-row" : undefined}>
747
747
  <span class={!unstyled ? "stuic-data-table-card-label" : undefined}>
748
748
  {#if isTHCNotEmpty(col.label)}
749
- {getTHCStringContent(col.label) || col.key}
749
+ <Thc thc={col.label!} />
750
750
  {:else}
751
751
  {col.key}
752
752
  {/if}
@@ -0,0 +1,262 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { Snippet } from "svelte";
4
+ import type { THC } from "../Thc/Thc.svelte";
5
+
6
+ /** Row shape: label above value, label beside value, or the former until there is room */
7
+ export type DescriptionListLayout = "auto" | "stacked" | "columns";
8
+
9
+ /**
10
+ * Where `layout="auto"` switches to columns, measured on the list's OWN inline size.
11
+ * The names and numbers of Tailwind's `@xs`…`@xl` container variants.
12
+ */
13
+ export type DescriptionListColumnsFrom = "xs" | "sm" | "md" | "lg" | "xl";
14
+
15
+ /** Hairlines: between rows only, around the whole list, or none */
16
+ export type DescriptionListDivide = "none" | "inside" | "outside";
17
+
18
+ /** How a long value behaves */
19
+ export type DescriptionListWrap = "anywhere" | "truncate" | "normal";
20
+
21
+ /** Text alignment of the value column */
22
+ export type DescriptionListValueAlign = "start" | "end";
23
+
24
+ export interface DescriptionListItem {
25
+ /** Keyed `{#each}` identity. Falls back to the index. */
26
+ key?: string | number;
27
+ /** The term (`<dt>`) */
28
+ label: THC;
29
+ /**
30
+ * The details (`<dd>`). A number is `String()`-ed. `undefined`, `null` or `""`
31
+ * → `emptyValue` (`null` is accepted because that is what an API row holds).
32
+ */
33
+ value?: THC | number | null;
34
+ /**
35
+ * A second `<dd>` under the value — a unit, a qualifier, a "vs. last month".
36
+ * In the columns state it sits under the value column, never under the label.
37
+ */
38
+ description?: THC | null;
39
+ /**
40
+ * Wraps the value in `<a href>`. Plain link only; anything more (`target`, `rel`,
41
+ * `onclick`) is the snippet / `children` form — same rule as `Stat` and `Timeline`.
42
+ */
43
+ href?: string | null;
44
+ /**
45
+ * `title` attribute on the value `<dd>`. When the effective wrap is `"truncate"`
46
+ * and `value` is a non-empty plain string, it defaults to that value — a clipped
47
+ * value must stay reachable.
48
+ */
49
+ title?: string;
50
+ /** `lang` on the `<dt>` */
51
+ labelLang?: string;
52
+ /** `lang` on the value `<dd>` */
53
+ valueLang?: string;
54
+ /** `data-emphasis` on the row: full-strength label color and semibold value — the *Total* row */
55
+ emphasis?: boolean;
56
+ /** Per-row override of the list's `wrap` */
57
+ wrap?: DescriptionListWrap;
58
+ /** Class for this row (`div`), merged after `classItem` */
59
+ class?: string;
60
+ /** Class for this row's `<dt>`, merged after `classLabel` */
61
+ classLabel?: string;
62
+ /** Class for this row's value `<dd>`, merged after `classValue` */
63
+ classValue?: string;
64
+ /** Class for this row's description `<dd>`, merged after `classDescription` */
65
+ classDescription?: string;
66
+ }
67
+
68
+ export interface DescriptionListSnippetArg {
69
+ item: DescriptionListItem;
70
+ index: number;
71
+ }
72
+
73
+ export interface Props extends Omit<HTMLAttributes<HTMLDListElement>, "children"> {
74
+ /** The rows, in order. Data-driven form. */
75
+ items?: DescriptionListItem[];
76
+ /**
77
+ * Compositional form: rendered inside the `<dl>` *instead of* `items`. Write
78
+ * `<div><dt>…</dt><dd>…</dd></div>` per row; the structural CSS styles it identically.
79
+ */
80
+ children?: Snippet;
81
+ /**
82
+ * `"stacked"`: label above value, always. `"columns"`: label beside value, always.
83
+ * `"auto"` (default): stacked until the list itself is `columnsFrom` wide, then columns.
84
+ */
85
+ layout?: DescriptionListLayout;
86
+ /**
87
+ * The **list's own** inline size at which `"auto"` switches to columns:
88
+ * 20 / 24 / 28 / 32 / 36rem. Ignored unless `layout="auto"`.
89
+ */
90
+ columnsFrom?: DescriptionListColumnsFrom;
91
+ /**
92
+ * `"inside"` (default): a hairline *between* rows only (a list inside a card, drawer
93
+ * or panel — the box supplies the outer edge). `"outside"`: plus one above the first
94
+ * and below the last (a list loose on a page). `"none"`: no rules.
95
+ */
96
+ divide?: DescriptionListDivide;
97
+ /**
98
+ * How a long value behaves. `"anywhere"` (default) never lets a value push the page
99
+ * sideways. `"truncate"` clips to one line with an ellipsis. `"normal"` leaves the
100
+ * browser default. Per-item override via `item.wrap`.
101
+ */
102
+ wrap?: DescriptionListWrap;
103
+ /**
104
+ * Text alignment of the value column. `"end"` is the totals shape — pair it with
105
+ * `--stuic-description-list-label-width: 1fr`.
106
+ */
107
+ valueAlign?: DescriptionListValueAlign;
108
+ /**
109
+ * Rendered in the `<dd>` when an item's `value` is `undefined`, `null` or `""`.
110
+ * Pass `""` to render an empty cell.
111
+ */
112
+ emptyValue?: THC;
113
+ /** Override the `<dt>` content for every row */
114
+ renderLabel?: Snippet<[DescriptionListSnippetArg]>;
115
+ /** Override the value `<dd>` content for every row */
116
+ renderValue?: Snippet<[DescriptionListSnippetArg]>;
117
+ /** Override the whole row *content* (the `<div>` stays — it is what the grid is on) */
118
+ renderItem?: Snippet<[DescriptionListSnippetArg]>;
119
+ /** Skip all default styling */
120
+ unstyled?: boolean;
121
+ /** Additional CSS classes */
122
+ class?: string;
123
+ /** Class for every row (`div`) */
124
+ classItem?: string;
125
+ /** Class for every `<dt>` */
126
+ classLabel?: string;
127
+ /** Class for every value `<dd>` */
128
+ classValue?: string;
129
+ /** Class for every description `<dd>` */
130
+ classDescription?: string;
131
+ /** Bindable element reference */
132
+ el?: HTMLDListElement;
133
+ }
134
+ </script>
135
+
136
+ <script lang="ts">
137
+ import { twMerge } from "../../utils/tw-merge.js";
138
+ import Thc, { isTHCNotEmpty } from "../Thc/Thc.svelte";
139
+
140
+ let {
141
+ items,
142
+ children,
143
+ layout = "auto",
144
+ columnsFrom = "sm",
145
+ divide = "inside",
146
+ wrap = "anywhere",
147
+ valueAlign = "start",
148
+ emptyValue = "—",
149
+ renderLabel,
150
+ renderValue,
151
+ renderItem,
152
+ unstyled = false,
153
+ class: classProp,
154
+ classItem: classItemProp,
155
+ classLabel: classLabelProp,
156
+ classValue: classValueProp,
157
+ classDescription: classDescriptionProp,
158
+ el = $bindable(),
159
+ ...rest
160
+ }: Props = $props();
161
+
162
+ /** Emptiness of a VALUE is deliberately not `isTHCNotEmpty`: `0` is a value, not empty. */
163
+ const _isEmptyValue = (v: THC | number | undefined | null): boolean =>
164
+ v === undefined || v === null || v === "";
165
+
166
+ const _value = (item: DescriptionListItem): THC => {
167
+ if (_isEmptyValue(item.value)) return emptyValue;
168
+ return typeof item.value === "number" ? String(item.value) : (item.value as THC);
169
+ };
170
+
171
+ const _wrap = (item: DescriptionListItem): DescriptionListWrap => item.wrap ?? wrap;
172
+
173
+ // A clipped value must stay reachable. Only a plain string can become a title —
174
+ // html/component/snippet values have no string to put there (pass `item.title`).
175
+ const _title = (item: DescriptionListItem): string | undefined => {
176
+ if (item.title !== undefined) return item.title;
177
+ if (_wrap(item) !== "truncate") return undefined;
178
+ return typeof item.value === "string" && item.value !== "" ? item.value : undefined;
179
+ };
180
+
181
+ let _class = $derived(
182
+ unstyled ? classProp : twMerge("stuic-description-list", classProp)
183
+ );
184
+
185
+ const _classItem = (item: DescriptionListItem) =>
186
+ unstyled
187
+ ? twMerge(classItemProp, item.class)
188
+ : twMerge("stuic-description-list-item", classItemProp, item.class);
189
+
190
+ const _classLabel = (item: DescriptionListItem) =>
191
+ unstyled
192
+ ? twMerge(classLabelProp, item.classLabel)
193
+ : twMerge("stuic-description-list-label", classLabelProp, item.classLabel);
194
+
195
+ const _classValue = (item: DescriptionListItem) =>
196
+ unstyled
197
+ ? twMerge(classValueProp, item.classValue)
198
+ : twMerge("stuic-description-list-value", classValueProp, item.classValue);
199
+
200
+ const _classDescription = (item: DescriptionListItem) =>
201
+ unstyled
202
+ ? twMerge(classDescriptionProp, item.classDescription)
203
+ : twMerge(
204
+ "stuic-description-list-description",
205
+ classDescriptionProp,
206
+ item.classDescription
207
+ );
208
+
209
+ // An empty list with `divide="outside"` would draw two rules around a void.
210
+ let _render = $derived(!!children || !!items?.length);
211
+ </script>
212
+
213
+ {#if _render}
214
+ <dl
215
+ bind:this={el}
216
+ class={_class}
217
+ data-layout={!unstyled ? layout : undefined}
218
+ data-columns-from={!unstyled && layout === "auto" ? columnsFrom : undefined}
219
+ data-divide={!unstyled ? divide : undefined}
220
+ data-wrap={!unstyled ? wrap : undefined}
221
+ data-value-align={!unstyled ? valueAlign : undefined}
222
+ {...rest}
223
+ >
224
+ {#if children}
225
+ {@render children()}
226
+ {:else}
227
+ {#each items ?? [] as item, index (item.key ?? index)}
228
+ <div
229
+ class={_classItem(item)}
230
+ data-emphasis={!unstyled && item.emphasis ? "" : undefined}
231
+ data-wrap={!unstyled ? item.wrap : undefined}
232
+ >
233
+ {#if renderItem}
234
+ {@render renderItem({ item, index })}
235
+ {:else}
236
+ <dt class={_classLabel(item)} lang={item.labelLang}>
237
+ {#if renderLabel}
238
+ {@render renderLabel({ item, index })}
239
+ {:else}
240
+ <Thc thc={item.label} />
241
+ {/if}
242
+ </dt>
243
+ <dd class={_classValue(item)} lang={item.valueLang} title={_title(item)}>
244
+ {#if renderValue}
245
+ {@render renderValue({ item, index })}
246
+ {:else if item.href}
247
+ <a href={item.href}><Thc thc={_value(item)} /></a>
248
+ {:else}
249
+ <Thc thc={_value(item)} />
250
+ {/if}
251
+ </dd>
252
+ {#if isTHCNotEmpty(item.description)}
253
+ <dd class={_classDescription(item)}>
254
+ <Thc thc={item.description!} />
255
+ </dd>
256
+ {/if}
257
+ {/if}
258
+ </div>
259
+ {/each}
260
+ {/if}
261
+ </dl>
262
+ {/if}
@@ -0,0 +1,127 @@
1
+ import type { HTMLAttributes } from "svelte/elements";
2
+ import type { Snippet } from "svelte";
3
+ import type { THC } from "../Thc/Thc.svelte";
4
+ /** Row shape: label above value, label beside value, or the former until there is room */
5
+ export type DescriptionListLayout = "auto" | "stacked" | "columns";
6
+ /**
7
+ * Where `layout="auto"` switches to columns, measured on the list's OWN inline size.
8
+ * The names and numbers of Tailwind's `@xs`…`@xl` container variants.
9
+ */
10
+ export type DescriptionListColumnsFrom = "xs" | "sm" | "md" | "lg" | "xl";
11
+ /** Hairlines: between rows only, around the whole list, or none */
12
+ export type DescriptionListDivide = "none" | "inside" | "outside";
13
+ /** How a long value behaves */
14
+ export type DescriptionListWrap = "anywhere" | "truncate" | "normal";
15
+ /** Text alignment of the value column */
16
+ export type DescriptionListValueAlign = "start" | "end";
17
+ export interface DescriptionListItem {
18
+ /** Keyed `{#each}` identity. Falls back to the index. */
19
+ key?: string | number;
20
+ /** The term (`<dt>`) */
21
+ label: THC;
22
+ /**
23
+ * The details (`<dd>`). A number is `String()`-ed. `undefined`, `null` or `""`
24
+ * → `emptyValue` (`null` is accepted because that is what an API row holds).
25
+ */
26
+ value?: THC | number | null;
27
+ /**
28
+ * A second `<dd>` under the value — a unit, a qualifier, a "vs. last month".
29
+ * In the columns state it sits under the value column, never under the label.
30
+ */
31
+ description?: THC | null;
32
+ /**
33
+ * Wraps the value in `<a href>`. Plain link only; anything more (`target`, `rel`,
34
+ * `onclick`) is the snippet / `children` form — same rule as `Stat` and `Timeline`.
35
+ */
36
+ href?: string | null;
37
+ /**
38
+ * `title` attribute on the value `<dd>`. When the effective wrap is `"truncate"`
39
+ * and `value` is a non-empty plain string, it defaults to that value — a clipped
40
+ * value must stay reachable.
41
+ */
42
+ title?: string;
43
+ /** `lang` on the `<dt>` */
44
+ labelLang?: string;
45
+ /** `lang` on the value `<dd>` */
46
+ valueLang?: string;
47
+ /** `data-emphasis` on the row: full-strength label color and semibold value — the *Total* row */
48
+ emphasis?: boolean;
49
+ /** Per-row override of the list's `wrap` */
50
+ wrap?: DescriptionListWrap;
51
+ /** Class for this row (`div`), merged after `classItem` */
52
+ class?: string;
53
+ /** Class for this row's `<dt>`, merged after `classLabel` */
54
+ classLabel?: string;
55
+ /** Class for this row's value `<dd>`, merged after `classValue` */
56
+ classValue?: string;
57
+ /** Class for this row's description `<dd>`, merged after `classDescription` */
58
+ classDescription?: string;
59
+ }
60
+ export interface DescriptionListSnippetArg {
61
+ item: DescriptionListItem;
62
+ index: number;
63
+ }
64
+ export interface Props extends Omit<HTMLAttributes<HTMLDListElement>, "children"> {
65
+ /** The rows, in order. Data-driven form. */
66
+ items?: DescriptionListItem[];
67
+ /**
68
+ * Compositional form: rendered inside the `<dl>` *instead of* `items`. Write
69
+ * `<div><dt>…</dt><dd>…</dd></div>` per row; the structural CSS styles it identically.
70
+ */
71
+ children?: Snippet;
72
+ /**
73
+ * `"stacked"`: label above value, always. `"columns"`: label beside value, always.
74
+ * `"auto"` (default): stacked until the list itself is `columnsFrom` wide, then columns.
75
+ */
76
+ layout?: DescriptionListLayout;
77
+ /**
78
+ * The **list's own** inline size at which `"auto"` switches to columns:
79
+ * 20 / 24 / 28 / 32 / 36rem. Ignored unless `layout="auto"`.
80
+ */
81
+ columnsFrom?: DescriptionListColumnsFrom;
82
+ /**
83
+ * `"inside"` (default): a hairline *between* rows only (a list inside a card, drawer
84
+ * or panel — the box supplies the outer edge). `"outside"`: plus one above the first
85
+ * and below the last (a list loose on a page). `"none"`: no rules.
86
+ */
87
+ divide?: DescriptionListDivide;
88
+ /**
89
+ * How a long value behaves. `"anywhere"` (default) never lets a value push the page
90
+ * sideways. `"truncate"` clips to one line with an ellipsis. `"normal"` leaves the
91
+ * browser default. Per-item override via `item.wrap`.
92
+ */
93
+ wrap?: DescriptionListWrap;
94
+ /**
95
+ * Text alignment of the value column. `"end"` is the totals shape — pair it with
96
+ * `--stuic-description-list-label-width: 1fr`.
97
+ */
98
+ valueAlign?: DescriptionListValueAlign;
99
+ /**
100
+ * Rendered in the `<dd>` when an item's `value` is `undefined`, `null` or `""`.
101
+ * Pass `""` to render an empty cell.
102
+ */
103
+ emptyValue?: THC;
104
+ /** Override the `<dt>` content for every row */
105
+ renderLabel?: Snippet<[DescriptionListSnippetArg]>;
106
+ /** Override the value `<dd>` content for every row */
107
+ renderValue?: Snippet<[DescriptionListSnippetArg]>;
108
+ /** Override the whole row *content* (the `<div>` stays — it is what the grid is on) */
109
+ renderItem?: Snippet<[DescriptionListSnippetArg]>;
110
+ /** Skip all default styling */
111
+ unstyled?: boolean;
112
+ /** Additional CSS classes */
113
+ class?: string;
114
+ /** Class for every row (`div`) */
115
+ classItem?: string;
116
+ /** Class for every `<dt>` */
117
+ classLabel?: string;
118
+ /** Class for every value `<dd>` */
119
+ classValue?: string;
120
+ /** Class for every description `<dd>` */
121
+ classDescription?: string;
122
+ /** Bindable element reference */
123
+ el?: HTMLDListElement;
124
+ }
125
+ declare const DescriptionList: import("svelte").Component<Props, {}, "el">;
126
+ type DescriptionList = ReturnType<typeof DescriptionList>;
127
+ export default DescriptionList;
@@ -0,0 +1,254 @@
1
+ # DescriptionList
2
+
3
+ The term-and-value list — the read-only block on every detail page, drawer and back-office
4
+ panel. Renders the HTML the spec intends: `<dl>` › `<div>` per pair › `<dt>` + `<dd>`, with
5
+ an optional second `<dd>` qualifying the value. No ARIA is added or needed — a `<dl>`
6
+ already has list semantics, which is the reason to render one instead of nested `<div>`s.
7
+
8
+ Two ways to feed it, both first-class: `items` for flat lists, `children` for markup-heavy
9
+ ones. The CSS is written on the _structure_ (`dl > div > dt`), so a hand-written
10
+ `<div><dt/><dd/></div>` renders identically to a generated row.
11
+
12
+ The default layout (`"auto"`) puts the label **over** the value while the list is narrow
13
+ and **beside** it once the list itself is wide enough — a container query, not a media
14
+ query, so a narrow list inside a wide page stays stacked.
15
+
16
+ Not an editor (that is `FieldKeyValues`), not a table. A run of numbers that should be a
17
+ table is a table.
18
+
19
+ ## Props
20
+
21
+ | Prop | Type | Default | Description |
22
+ | ------------------ | -------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------- |
23
+ | `items` | `DescriptionListItem[]` | - | The rows, in order. Data-driven form |
24
+ | `children` | `Snippet` | - | Compositional form: rendered inside the `<dl>` _instead of_ `items` |
25
+ | `layout` | `"auto" \| "stacked" \| "columns"` | `"auto"` | Label over value, label beside value, or the former until the list is `columnsFrom` wide |
26
+ | `columnsFrom` | `"xs" \| "sm" \| "md" \| "lg" \| "xl"` | `"sm"` | The **list's own** width at which `"auto"` flips: 20 / 24 / 28 / 32 / 36rem. Only for `"auto"` |
27
+ | `divide` | `"none" \| "inside" \| "outside"` | `"inside"` | Hairlines between rows only, around the whole list as well, or none |
28
+ | `wrap` | `"anywhere" \| "truncate" \| "normal"` | `"anywhere"` | How a long value behaves. Per-row override via `item.wrap` |
29
+ | `valueAlign` | `"start" \| "end"` | `"start"` | Text alignment of the value column (`"end"` is the totals shape) |
30
+ | `emptyValue` | `THC` | `"—"` | Rendered when an item's `value` is `undefined`, `null` or `""`. Pass `""` for an empty cell |
31
+ | `renderLabel` | `Snippet<[DescriptionListSnippetArg]>` | - | Override the `<dt>` content for every row |
32
+ | `renderValue` | `Snippet<[DescriptionListSnippetArg]>` | - | Override the value `<dd>` content for every row |
33
+ | `renderItem` | `Snippet<[DescriptionListSnippetArg]>` | - | Override the whole row _content_ (the `<div>` stays — it is what the grid is on) |
34
+ | `unstyled` | `boolean` | `false` | Skip all default styling (no classes, no data attributes) |
35
+ | `class` | `string` | - | Additional CSS classes on the `<dl>` (merged via twMerge) |
36
+ | `classItem` | `string` | - | Class for every row `<div>` |
37
+ | `classLabel` | `string` | - | Class for every `<dt>` |
38
+ | `classValue` | `string` | - | Class for every value `<dd>` |
39
+ | `classDescription` | `string` | - | Class for every description `<dd>` |
40
+ | `el` | `HTMLDListElement` | - | Element reference (bindable) |
41
+
42
+ Any other attribute (`aria-label`, `data-*`, `style`, `lang`, …) is passed to the `<dl>`.
43
+
44
+ Empty `items` with no `children` renders **nothing** — not even the `<dl>`: an empty list
45
+ with `divide="outside"` would otherwise draw two rules around a void.
46
+
47
+ ### `DescriptionListItem`
48
+
49
+ | Field | Type | Description |
50
+ | ------------------ | --------------------- | ------------------------------------------------------------------------------------------------------- |
51
+ | `key` | `string \| number` | Keyed `{#each}` identity. Falls back to the index |
52
+ | `label` | `THC` | The term (`<dt>`). Required |
53
+ | `value` | `THC \| number` | The details (`<dd>`). A number is `String()`-ed; missing → `emptyValue` |
54
+ | `description` | `THC` | A second `<dd>` under the value — a unit, a qualifier. In the columns state it sits under the value |
55
+ | `href` | `string` | Wraps the value in `<a href>`. Plain link only; anything more is the snippet / `children` form |
56
+ | `title` | `string` | `title` on the value `<dd>`. Auto-filled from a plain-string value under `wrap="truncate"` unless given |
57
+ | `labelLang` | `string` | `lang` on the `<dt>` |
58
+ | `valueLang` | `string` | `lang` on the value `<dd>` |
59
+ | `emphasis` | `boolean` | `data-emphasis` on the row: full-strength label color and semibold value — the _Total_ row |
60
+ | `wrap` | `DescriptionListWrap` | Per-row override of the list's `wrap` |
61
+ | `class` | `string` | Class for this row, merged after `classItem` |
62
+ | `classLabel` | `string` | Class for this `<dt>`, merged after `classLabel` |
63
+ | `classValue` | `string` | Class for this value `<dd>`, merged after `classValue` |
64
+ | `classDescription` | `string` | Class for this description `<dd>`, merged after `classDescription` |
65
+
66
+ ### Snippet Props
67
+
68
+ All three receive `DescriptionListSnippetArg` = `{ item, index }`, as in `Timeline`.
69
+
70
+ ## Usage
71
+
72
+ ### Basic
73
+
74
+ ```svelte
75
+ <script lang="ts">
76
+ import { DescriptionList } from "@marianmeres/stuic";
77
+ </script>
78
+
79
+ <DescriptionList
80
+ items={[
81
+ { label: "Reference", value: "REF-2026-0042" },
82
+ { label: "Created", value: "Sep 1, 2026" },
83
+ { label: "Owner", value: null }, // renders the em dash
84
+ ]}
85
+ />
86
+ ```
87
+
88
+ ### A detail block (the reference shape)
89
+
90
+ Stacked while narrow, two columns once the list itself has room, interior hairlines, a
91
+ truncated URL that stays reachable through its `title`, and a qualifier line under a count:
92
+
93
+ ```svelte
94
+ <DescriptionList
95
+ class="mt-3 text-sm"
96
+ items={[
97
+ { label: "Reference", value: doc.reference, classValue: "font-mono font-semibold" },
98
+ {
99
+ label: "Share link",
100
+ value: shareUrl(doc),
101
+ href: shareUrl(doc),
102
+ wrap: "truncate",
103
+ },
104
+ {
105
+ label: "Downloads",
106
+ value: downloadCount,
107
+ description: "in the last 30 days",
108
+ classValue: "tabular-nums",
109
+ },
110
+ ]}
111
+ />
112
+ ```
113
+
114
+ ### Totals
115
+
116
+ ```svelte
117
+ <DescriptionList
118
+ layout="columns"
119
+ valueAlign="end"
120
+ divide="none"
121
+ style="--stuic-description-list-label-width: 1fr;"
122
+ items={[
123
+ { label: "Subtotal", value: "$120.00" },
124
+ { label: "Tax", value: "$25.20" },
125
+ { label: "Total", value: "$145.20", emphasis: true, class: "border-t mt-1 pt-2" },
126
+ ]}
127
+ />
128
+ ```
129
+
130
+ ### The `children` form
131
+
132
+ For rows the data form cannot express — a link with `target`/`rel`, a conditional row, a
133
+ button inside a value. The structural CSS styles it identically, so **no layout utility is
134
+ needed on the consumer side**; `data-wrap` and `data-emphasis` on a hand-written row are
135
+ honoured by the same selectors the data form uses.
136
+
137
+ ```svelte
138
+ <DescriptionList class="mt-3 text-sm">
139
+ <div>
140
+ <dt>Reference</dt>
141
+ <dd class="font-mono text-base font-semibold">{doc.reference}</dd>
142
+ </div>
143
+ <div data-wrap="truncate">
144
+ <dt>Share link</dt>
145
+ <dd title={shareUrl(doc)}>
146
+ <a href={shareUrl(doc)} target="_blank" rel="noopener noreferrer">
147
+ {shareUrl(doc)}
148
+ </a>
149
+ </dd>
150
+ </div>
151
+ {#if downloadCount !== null}
152
+ <div>
153
+ <dt>Downloads</dt>
154
+ <dd class="tabular-nums">{downloadCount}</dd>
155
+ <dd>in the last 30 days</dd>
156
+ </div>
157
+ {/if}
158
+ </DescriptionList>
159
+ ```
160
+
161
+ One `<div>` per pair is the contract: it is what the spec provides for grouping a term with
162
+ its details, and it is what lets each row be a grid without subgrid. Bare `<dt>`/`<dd>`
163
+ children of the `<dl>` are valid HTML but get no row styling.
164
+
165
+ ### Snippet overrides
166
+
167
+ ```svelte
168
+ <DescriptionList {items}>
169
+ {#snippet renderValue({ item })}
170
+ <Pill label={item.value} intent="success" />
171
+ {/snippet}
172
+ </DescriptionList>
173
+ ```
174
+
175
+ ## Layout
176
+
177
+ `layout="auto"` makes the `<dl>` an inline-size container (named
178
+ `stuic-description-list`) and switches the **rows** to a two-column grid at `columnsFrom` —
179
+ an element cannot query itself, so the query is on the list and the styling is on its rows.
180
+
181
+ Two consequences worth knowing:
182
+
183
+ - **The list measures itself, not the viewport.** A 200px-wide list on a 1400px page stays
184
+ stacked. That is the whole point: `sm:` would have flipped it while it was still 200px.
185
+ - **`container-type: inline-size` gives the `<dl>` inline-axis size containment**, so its
186
+ min-content contribution to a flex parent is zero — the list can sit beside a fixed-width
187
+ element and shrink to whatever is left. `min-w-0 flex-1` on the consumer side is already
188
+ there (the base rule sets `min-width: 0` in the static layouts too).
189
+
190
+ The breakpoint is a prop on a fixed scale rather than a token because
191
+ `@container (min-width: var(--x))` is not valid CSS — size queries take literals only. The
192
+ scale is Tailwind's own `@xs`…`@xl`, so a consumer who would have typed `@min-[24rem]`
193
+ writes `columnsFrom="sm"`.
194
+
195
+ ## Dividers
196
+
197
+ `divide="inside"` (default) draws `n − 1` rules — a list inside a card, drawer or panel,
198
+ where the box supplies the outer edge; the first and last rows sit flush with it.
199
+ `divide="outside"` draws `n + 1` and keeps the outer padding — a list loose on a page,
200
+ where the top and bottom lines are what close it. `divide="none"` draws none.
201
+
202
+ ## Wrapping
203
+
204
+ `wrap="anywhere"` (default) never lets a value push the page sideways. It is
205
+ `overflow-wrap: anywhere` and not `break-word` on purpose: only `anywhere` is counted in
206
+ min-content sizing, so an inline-block inside a value cannot overflow the column it was
207
+ meant to respect.
208
+
209
+ `wrap="truncate"` clips to one line with an ellipsis — for a value that is chrome (an ID, a
210
+ URL that is also a link). The `title` is auto-filled from a plain-string value so the
211
+ clipped text stays reachable; for an html/component/snippet value there is no string to put
212
+ there, so pass `item.title`. A `href` value must stay `display: inline` for the `<dd>`'s
213
+ `text-overflow` to clip it — do not make the link `inline-block`.
214
+
215
+ `wrap="normal"` leaves the browser default. Any of the three can be set per row via
216
+ `item.wrap` (or `data-wrap` on a hand-written row), which wins over the list-level value.
217
+
218
+ ## CSS Variables
219
+
220
+ | Variable | Default | Description |
221
+ | ----------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
222
+ | `--stuic-description-list-label-width` | `minmax(7rem, 10rem)` | Label track (any `grid-template-columns` value; not declared — fallback at the usage site) |
223
+ | `--stuic-description-list-gap-x` | `1.5rem` | Label ↔ value, columns state |
224
+ | `--stuic-description-list-gap-y` | `0.125rem` | Label ↕ value, stacked state |
225
+ | `--stuic-description-list-item-padding-y` | `0.5rem` | Row padding, both states |
226
+ | `--stuic-description-list-rule-color` | `var(--stuic-color-border)` | Hairline color |
227
+ | `--stuic-description-list-rule-width` | `1px` | Hairline thickness |
228
+ | `--stuic-description-list-label-font-size` | `var(--text-sm)` | Label size |
229
+ | `--stuic-description-list-label-font-weight` | `var(--font-weight-medium)` | Label weight |
230
+ | `--stuic-description-list-label-text` | `var(--stuic-color-muted-foreground)` | Label color |
231
+ | `--stuic-description-list-value-text` | `var(--stuic-color-foreground)` | Value color (size is inherited) |
232
+ | `--stuic-description-list-description-font-size` | `var(--text-sm)` | Description size |
233
+ | `--stuic-description-list-description-text` | `var(--stuic-color-muted-foreground)` | Description color |
234
+ | `--stuic-description-list-label-text-emphasis` | `var(--stuic-color-foreground)` | Label color on an `emphasis` row |
235
+ | `--stuic-description-list-value-font-weight-emphasis` | `var(--font-weight-semibold)` | Value weight on an `emphasis` row |
236
+
237
+ The value deliberately has no font-size token: it inherits, so `class="text-sm"` on the
238
+ `<dl>` scales the list. The rule tokens are the component's own (like `Separator`) rather
239
+ than the `--stuic-border-width` tier, so a theme that zeroes box borders keeps its
240
+ hairlines.
241
+
242
+ ## Data Attributes
243
+
244
+ On the `<dl>`: `data-layout`, `data-columns-from` (only when `layout="auto"`),
245
+ `data-divide`, `data-wrap`, `data-value-align`. On each row `<div>`: `data-wrap` (when
246
+ `item.wrap` is set) and `data-emphasis` (empty attribute). `unstyled` removes all of them
247
+ along with the classes.
248
+
249
+ ## Accessibility
250
+
251
+ The rendered `<dl>` carries list semantics natively — no `role` is added. One term per row:
252
+ a second `<dt>` in the same group is valid HTML but lands in the value column; use two rows
253
+ instead. `lang` per row (`labelLang` / `valueLang`) is for a localized label or a
254
+ fallback-language value inside a page that declares another language.
@@ -0,0 +1,264 @@
1
+ /* ============================================================================
2
+ DESCRIPTION LIST COMPONENT TOKENS
3
+ Override globally: :root { --stuic-description-list-label-width: auto; }
4
+ Override locally: <DescriptionList style="--stuic-description-list-gap-x: 1rem;">
5
+ ============================================================================ */
6
+
7
+ :root {
8
+ /* Layout */
9
+ --stuic-description-list-gap-x: 1.5rem; /* label <-> value, columns state */
10
+ --stuic-description-list-gap-y: 0.125rem; /* label <-> value, stacked state */
11
+ --stuic-description-list-item-padding-y: 0.5rem; /* row padding, both states */
12
+
13
+ /* `--stuic-description-list-label-width` is deliberately NOT declared: it is read
14
+ as a fallback argument at the usage site (`minmax(7rem, 10rem)`) so a scoped
15
+ override on any ancestor works — see "Fallback Pattern" in docs/conventions.md. */
16
+
17
+ /* Rules */
18
+ --stuic-description-list-rule-color: var(--stuic-color-border);
19
+ --stuic-description-list-rule-width: 1px;
20
+
21
+ /* Label */
22
+ --stuic-description-list-label-font-size: var(--text-sm);
23
+ --stuic-description-list-label-font-weight: var(--font-weight-medium);
24
+ --stuic-description-list-label-text: var(--stuic-color-muted-foreground);
25
+
26
+ /* Value — inherits font-size, so `class="text-sm"` on the <dl> scales the whole list */
27
+ --stuic-description-list-value-text: var(--stuic-color-foreground);
28
+
29
+ /* Description (the second <dd>) */
30
+ --stuic-description-list-description-font-size: var(--text-sm);
31
+ --stuic-description-list-description-text: var(--stuic-color-muted-foreground);
32
+
33
+ /* Emphasis row */
34
+ --stuic-description-list-label-text-emphasis: var(--stuic-color-foreground);
35
+ --stuic-description-list-value-font-weight-emphasis: var(--font-weight-semibold);
36
+ }
37
+
38
+ @layer components {
39
+ /* ============================================================================
40
+ BASE — selected STRUCTURALLY (`dl > div > dt`), not by class, so a hand-written
41
+ `<div><dt/><dd/></div>` child renders identically to a generated `items` row.
42
+ The per-element classes exist only as merge targets for `classLabel` & co.
43
+ ============================================================================ */
44
+
45
+ .stuic-description-list {
46
+ /* UA gives <dl> a 1em block margin and <dd> a 40px inline start margin */
47
+ margin: 0;
48
+ /* A list in a flex/grid parent shrinks to its share instead of pushing it wider.
49
+ In `layout="auto"` the container-type below already implies this; declaring it
50
+ here means the static layouts behave the same way. */
51
+ min-width: 0;
52
+ }
53
+
54
+ /* Stacked is the base state: label over value. */
55
+ .stuic-description-list > div {
56
+ display: flex;
57
+ flex-direction: column;
58
+ row-gap: var(--stuic-description-list-gap-y);
59
+ padding-block: var(--stuic-description-list-item-padding-y);
60
+ min-width: 0;
61
+ }
62
+
63
+ .stuic-description-list > div > dt {
64
+ min-width: 0;
65
+ font-size: var(--stuic-description-list-label-font-size);
66
+ font-weight: var(--stuic-description-list-label-font-weight);
67
+ color: var(--stuic-description-list-label-text);
68
+ }
69
+
70
+ .stuic-description-list > div > dd {
71
+ margin: 0;
72
+ min-width: 0;
73
+ color: var(--stuic-description-list-value-text);
74
+ }
75
+
76
+ /* The second <dd> on the same term is the qualifier line. */
77
+ .stuic-description-list > div > dd + dd {
78
+ font-size: var(--stuic-description-list-description-font-size);
79
+ color: var(--stuic-description-list-description-text);
80
+ }
81
+
82
+ /* ============================================================================
83
+ LAYOUT — "columns" is static; "auto" is the same row grid behind a container
84
+ query on the LIST's own inline size (an element cannot query itself, so the
85
+ container is the <dl> and the query styles its rows).
86
+ ============================================================================ */
87
+
88
+ .stuic-description-list[data-layout="columns"] > div {
89
+ display: grid;
90
+ grid-template-columns:
91
+ var(--stuic-description-list-label-width, minmax(7rem, 10rem))
92
+ /* minmax(0, 1fr) and not 1fr: a grid item's automatic minimum size refuses
93
+ to shrink below its content, so `wrap="truncate"` would overflow instead
94
+ of clipping. */
95
+ minmax(0, 1fr);
96
+ column-gap: var(--stuic-description-list-gap-x);
97
+ align-items: baseline;
98
+ }
99
+
100
+ .stuic-description-list[data-layout="columns"] > div > dd + dd {
101
+ grid-column: 2;
102
+ }
103
+
104
+ .stuic-description-list[data-layout="auto"] {
105
+ /* Named so the query below can only ever resolve against this <dl>, never
106
+ against a consumer's own @container ancestor. */
107
+ container-name: stuic-description-list;
108
+ container-type: inline-size;
109
+ }
110
+
111
+ /* The breakpoint cannot be a token: `@container (min-width: var(--x))` is not valid
112
+ CSS — size queries take literals only. Hence a prop on a fixed scale mapped to a
113
+ data attribute, and one block per step. Values are Tailwind's @xs…@xl. */
114
+
115
+ @container stuic-description-list (min-width: 20rem) {
116
+ .stuic-description-list[data-layout="auto"][data-columns-from="xs"] > div {
117
+ display: grid;
118
+ grid-template-columns:
119
+ var(--stuic-description-list-label-width, minmax(7rem, 10rem))
120
+ minmax(0, 1fr);
121
+ column-gap: var(--stuic-description-list-gap-x);
122
+ align-items: baseline;
123
+ }
124
+ .stuic-description-list[data-layout="auto"][data-columns-from="xs"] > div > dd + dd {
125
+ grid-column: 2;
126
+ }
127
+ }
128
+
129
+ @container stuic-description-list (min-width: 24rem) {
130
+ .stuic-description-list[data-layout="auto"][data-columns-from="sm"] > div {
131
+ display: grid;
132
+ grid-template-columns:
133
+ var(--stuic-description-list-label-width, minmax(7rem, 10rem))
134
+ minmax(0, 1fr);
135
+ column-gap: var(--stuic-description-list-gap-x);
136
+ align-items: baseline;
137
+ }
138
+ .stuic-description-list[data-layout="auto"][data-columns-from="sm"] > div > dd + dd {
139
+ grid-column: 2;
140
+ }
141
+ }
142
+
143
+ @container stuic-description-list (min-width: 28rem) {
144
+ .stuic-description-list[data-layout="auto"][data-columns-from="md"] > div {
145
+ display: grid;
146
+ grid-template-columns:
147
+ var(--stuic-description-list-label-width, minmax(7rem, 10rem))
148
+ minmax(0, 1fr);
149
+ column-gap: var(--stuic-description-list-gap-x);
150
+ align-items: baseline;
151
+ }
152
+ .stuic-description-list[data-layout="auto"][data-columns-from="md"] > div > dd + dd {
153
+ grid-column: 2;
154
+ }
155
+ }
156
+
157
+ @container stuic-description-list (min-width: 32rem) {
158
+ .stuic-description-list[data-layout="auto"][data-columns-from="lg"] > div {
159
+ display: grid;
160
+ grid-template-columns:
161
+ var(--stuic-description-list-label-width, minmax(7rem, 10rem))
162
+ minmax(0, 1fr);
163
+ column-gap: var(--stuic-description-list-gap-x);
164
+ align-items: baseline;
165
+ }
166
+ .stuic-description-list[data-layout="auto"][data-columns-from="lg"] > div > dd + dd {
167
+ grid-column: 2;
168
+ }
169
+ }
170
+
171
+ @container stuic-description-list (min-width: 36rem) {
172
+ .stuic-description-list[data-layout="auto"][data-columns-from="xl"] > div {
173
+ display: grid;
174
+ grid-template-columns:
175
+ var(--stuic-description-list-label-width, minmax(7rem, 10rem))
176
+ minmax(0, 1fr);
177
+ column-gap: var(--stuic-description-list-gap-x);
178
+ align-items: baseline;
179
+ }
180
+ .stuic-description-list[data-layout="auto"][data-columns-from="xl"] > div > dd + dd {
181
+ grid-column: 2;
182
+ }
183
+ }
184
+
185
+ /* ============================================================================
186
+ DIVIDERS
187
+ ============================================================================ */
188
+
189
+ .stuic-description-list[data-divide="inside"] > div + div,
190
+ .stuic-description-list[data-divide="outside"] > div {
191
+ border-top: var(--stuic-description-list-rule-width) solid
192
+ var(--stuic-description-list-rule-color);
193
+ }
194
+
195
+ .stuic-description-list[data-divide="outside"] > div:last-child {
196
+ border-bottom: var(--stuic-description-list-rule-width) solid
197
+ var(--stuic-description-list-rule-color);
198
+ }
199
+
200
+ /* Inside a box, the first and last rows sit flush with the box's own edges. With
201
+ OUTSIDE rules the padding is what keeps the text off the rule, so it stays. */
202
+ .stuic-description-list:not([data-divide="outside"]) > div:first-child {
203
+ padding-top: 0;
204
+ }
205
+
206
+ .stuic-description-list:not([data-divide="outside"]) > div:last-child {
207
+ padding-bottom: 0;
208
+ }
209
+
210
+ /* ============================================================================
211
+ WRAPPING — `anywhere` and not `break-word` on purpose: only `anywhere` counts in
212
+ min-content sizing, so an inline-block inside the value cannot overflow the
213
+ column it was meant to respect.
214
+ ============================================================================ */
215
+
216
+ .stuic-description-list[data-wrap="anywhere"] > div > dd {
217
+ overflow-wrap: anywhere;
218
+ }
219
+
220
+ .stuic-description-list[data-wrap="truncate"] > div > dd {
221
+ overflow: hidden;
222
+ text-overflow: ellipsis;
223
+ white-space: nowrap;
224
+ }
225
+
226
+ /* Row-level override (`item.wrap`, or `data-wrap` on a hand-written row). Same
227
+ specificity as the list-level rules above — one class, one attribute, two
228
+ elements — so it wins by source order. This block MUST stay after that one. */
229
+ .stuic-description-list > div[data-wrap="normal"] > dd {
230
+ overflow-wrap: normal;
231
+ white-space: normal;
232
+ overflow: visible;
233
+ text-overflow: clip;
234
+ }
235
+
236
+ .stuic-description-list > div[data-wrap="anywhere"] > dd {
237
+ overflow-wrap: anywhere;
238
+ white-space: normal;
239
+ overflow: visible;
240
+ text-overflow: clip;
241
+ }
242
+
243
+ .stuic-description-list > div[data-wrap="truncate"] > dd {
244
+ overflow: hidden;
245
+ text-overflow: ellipsis;
246
+ white-space: nowrap;
247
+ }
248
+
249
+ /* ============================================================================
250
+ VALUE ALIGNMENT + EMPHASIS (the totals shape)
251
+ ============================================================================ */
252
+
253
+ .stuic-description-list[data-value-align="end"] > div > dd {
254
+ text-align: end;
255
+ }
256
+
257
+ .stuic-description-list > div[data-emphasis] > dt {
258
+ color: var(--stuic-description-list-label-text-emphasis);
259
+ }
260
+
261
+ .stuic-description-list > div[data-emphasis] > dd {
262
+ font-weight: var(--stuic-description-list-value-font-weight-emphasis);
263
+ }
264
+ }
@@ -0,0 +1 @@
1
+ export { default as DescriptionList, type Props as DescriptionListProps, type DescriptionListItem, type DescriptionListSnippetArg, type DescriptionListLayout, type DescriptionListColumnsFrom, type DescriptionListDivide, type DescriptionListWrap, type DescriptionListValueAlign, } from "./DescriptionList.svelte";
@@ -0,0 +1 @@
1
+ export { default as DescriptionList, } from "./DescriptionList.svelte";
@@ -387,7 +387,7 @@
387
387
  // aria-live announcement text for reorder actions
388
388
  let liveAnnouncement = $state("");
389
389
  let parentHiddenInputEl: HTMLInputElement | undefined = $state();
390
- let hasLabel = $derived(isTHCNotEmpty(label) || typeof label === "function");
390
+ let hasLabel = $derived(isTHCNotEmpty(label));
391
391
  let inputEl = $state<HTMLInputElement>()!;
392
392
  // Outer wrapper for scrollIntoView and focus targeting.
393
393
  let wrapEl: HTMLDivElement | undefined = $state();
@@ -151,10 +151,11 @@
151
151
  classLabel
152
152
  )}
153
153
  >
154
- {#if isTHCNotEmpty(label)}
155
- <Thc thc={label as THC} forceAsHtml />
156
- {:else}
154
+ <!-- a snippet label first: it takes `{ id }`, which `Thc` cannot pass -->
155
+ {#if typeof label === "function"}
157
156
  {@render (label as SnippetWithId)({ id })}
157
+ {:else if isTHCNotEmpty(label)}
158
+ <Thc thc={label as THC} forceAsHtml />
158
159
  {/if}
159
160
  </div>
160
161
  {/if}
@@ -147,10 +147,11 @@
147
147
  classLabel
148
148
  )}
149
149
  >
150
- {#if isTHCNotEmpty(label)}
151
- <Thc thc={label as THC} forceAsHtml />
152
- {:else}
150
+ <!-- a snippet label first: it takes `{ id }`, which `Thc` cannot pass -->
151
+ {#if typeof label === "function"}
153
152
  {@render (label as SnippetWithId)({ id })}
153
+ {:else if isTHCNotEmpty(label)}
154
+ <Thc thc={label as THC} forceAsHtml />
154
155
  {/if}
155
156
  </div>
156
157
  {/if}
@@ -82,7 +82,7 @@
82
82
  }
83
83
  });
84
84
 
85
- let hasLabel = $derived(isTHCNotEmpty(label) || typeof label === "function");
85
+ let hasLabel = $derived(isTHCNotEmpty(label));
86
86
  </script>
87
87
 
88
88
  {#snippet snippetOrThc({ id, value }: { id: string; value?: SnippetWithId | THC })}
@@ -26,15 +26,21 @@ type THC =
26
26
 
27
27
  ### `isTHCNotEmpty(value)`
28
28
 
29
- Checks if a THC value has renderable content.
29
+ Checks if a THC value has renderable content — every form `Thc` itself can render.
30
30
 
31
31
  ```ts
32
32
  isTHCNotEmpty("Hello"); // true
33
33
  isTHCNotEmpty({ text: "Hi" }); // true
34
+ isTHCNotEmpty(mySnippet); // true
35
+ isTHCNotEmpty({ snippet }); // true
34
36
  isTHCNotEmpty(""); // false
35
37
  isTHCNotEmpty(null); // false
36
38
  ```
37
39
 
40
+ > **Note:** a snippet label that needs an argument (e.g. the `{ id }` the `Field*`
41
+ > components pass) must be rendered directly, not through `Thc` — `Thc` renders a bare
42
+ > snippet with no arguments. Test `typeof value === "function"` first in that case.
43
+
38
44
  ### `getTHCStringContent(value)`
39
45
 
40
46
  Extracts string content from a THC value.
@@ -70,13 +70,18 @@
70
70
  /**
71
71
  * Checks if a THC value has renderable content.
72
72
  *
73
+ * Covers every form `Thc` itself can render: non-empty text/html, a component,
74
+ * a `{ snippet }`, and a bare snippet function.
75
+ *
73
76
  * @param m - The THC value to check
74
- * @returns `true` if the value contains non-empty text, html, or a component
77
+ * @returns `true` if the value contains renderable content
75
78
  *
76
79
  * @example
77
80
  * ```ts
78
81
  * isTHCNotEmpty("Hello"); // true
79
82
  * isTHCNotEmpty({ text: "Hi" }); // true
83
+ * isTHCNotEmpty(mySnippet); // true
84
+ * isTHCNotEmpty({ snippet }); // true
80
85
  * isTHCNotEmpty(""); // false
81
86
  * isTHCNotEmpty(null); // false
82
87
  * ```
@@ -87,7 +92,10 @@
87
92
  _is(m) ||
88
93
  _is((m as WithText)?.text) ||
89
94
  _is((m as WithHtml)?.html) ||
90
- !!(m as WithComponent)?.component
95
+ !!(m as WithComponent)?.component ||
96
+ // a bare snippet is a function; `{ snippet }` is the object form
97
+ typeof m === "function" ||
98
+ !!(m as WithSnippet)?.snippet
91
99
  );
92
100
  }
93
101
 
@@ -58,13 +58,18 @@ export interface Props extends Record<string, any> {
58
58
  /**
59
59
  * Checks if a THC value has renderable content.
60
60
  *
61
+ * Covers every form `Thc` itself can render: non-empty text/html, a component,
62
+ * a `{ snippet }`, and a bare snippet function.
63
+ *
61
64
  * @param m - The THC value to check
62
- * @returns `true` if the value contains non-empty text, html, or a component
65
+ * @returns `true` if the value contains renderable content
63
66
  *
64
67
  * @example
65
68
  * ```ts
66
69
  * isTHCNotEmpty("Hello"); // true
67
70
  * isTHCNotEmpty({ text: "Hi" }); // true
71
+ * isTHCNotEmpty(mySnippet); // true
72
+ * isTHCNotEmpty({ snippet }); // true
68
73
  * isTHCNotEmpty(""); // false
69
74
  * isTHCNotEmpty(null); // false
70
75
  * ```
package/dist/index.css CHANGED
@@ -95,6 +95,7 @@ In practice:
95
95
  @import "./components/CopyButton/index.css";
96
96
  @import "./components/CronInput/index.css";
97
97
  @import "./components/DataTable/index.css";
98
+ @import "./components/DescriptionList/index.css";
98
99
  @import "./components/DismissibleMessage/index.css";
99
100
  @import "./components/DropdownMenu/index.css";
100
101
  @import "./components/EmailVerifyForm/index.css";
package/dist/index.d.ts CHANGED
@@ -46,6 +46,7 @@ export * from "./components/ContextMenu/index.js";
46
46
  export * from "./components/CopyButton/index.js";
47
47
  export * from "./components/CronInput/index.js";
48
48
  export * from "./components/DataTable/index.js";
49
+ export * from "./components/DescriptionList/index.js";
49
50
  export * from "./components/DismissibleMessage/index.js";
50
51
  export * from "./components/Drawer/index.js";
51
52
  export * from "./components/DropdownMenu/index.js";
package/dist/index.js CHANGED
@@ -52,6 +52,7 @@ export * from "./components/ContextMenu/index.js";
52
52
  export * from "./components/CopyButton/index.js";
53
53
  export * from "./components/CronInput/index.js";
54
54
  export * from "./components/DataTable/index.js";
55
+ export * from "./components/DescriptionList/index.js";
55
56
  export * from "./components/DismissibleMessage/index.js";
56
57
  export * from "./components/Drawer/index.js";
57
58
  export * from "./components/DropdownMenu/index.js";
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- 78 Svelte 5 component directories with consistent API patterns. All use runes-based reactivity.
5
+ 79 Svelte 5 component directories with consistent API patterns. All use runes-based reactivity.
6
6
 
7
7
  ## Component Categories
8
8
 
@@ -105,6 +105,7 @@
105
105
  | Thc | Flexible renderer for text, HTML, components, or snippets |
106
106
  | Card | Flexible card with image, title, footer; vertical/horizontal layout |
107
107
  | Stat | KPI/stat card: label + value + delta with trend arrow and semantic coloring |
108
+ | DescriptionList | Term/value list (`<dl>`): stacked or two-column by container query, hairlines, truncation, totals |
108
109
  | Timeline | Vertical event list on a rail: dot/icon/custom markers, inline or opposite time, alternate layout |
109
110
  | TrendChart | Svelte wrapper for `@marianmeres/trend-chart` (subpath-only: `@marianmeres/stuic/trend-chart`) |
110
111
  | Tree | Hierarchical tree view with keyboard nav and drag-and-drop |
@@ -970,6 +971,55 @@ Prefix: `--stuic-copy-button-*` (the surface themes via `--stuic-button-*`)
970
971
 
971
972
  ---
972
973
 
974
+ ## DescriptionList
975
+
976
+ The term-and-value list — the read-only block on every detail page, drawer and back-office panel. Renders `<dl>` › `<div>` per pair › `<dt>` + `<dd>` (plus an optional second `<dd>` qualifying the value), which is what the spec provides for grouping a term with its details and what lets each row be a grid without subgrid. No ARIA is added: a `<dl>` already has list semantics. Two equal ways to feed it — `items` for flat lists, `children` for markup-heavy ones — because the CSS selects **structurally** (`dl > div > dt`, `> div > dd + dd`), so a hand-written row renders identically to a generated one. Not an editor (that is `FieldKeyValues`) and not a table.
977
+
978
+ ### Exports
979
+
980
+ | Export | Kind | Description |
981
+ | ---------------------------- | --------- | -------------------------------------------------------------------------------------------------------- |
982
+ | `DescriptionList` | component | Main component |
983
+ | `DescriptionListProps` | type | Props type |
984
+ | `DescriptionListItem` | type | `{ key?, label, value?, description?, href?, title?, labelLang?, valueLang?, emphasis?, wrap?, class* }` |
985
+ | `DescriptionListLayout` | type | `"auto" \| "stacked" \| "columns"` |
986
+ | `DescriptionListColumnsFrom` | type | `"xs" \| "sm" \| "md" \| "lg" \| "xl"` (20 / 24 / 28 / 32 / 36rem) |
987
+ | `DescriptionListDivide` | type | `"none" \| "inside" \| "outside"` |
988
+ | `DescriptionListWrap` | type | `"anywhere" \| "truncate" \| "normal"` |
989
+ | `DescriptionListValueAlign` | type | `"start" \| "end"` |
990
+ | `DescriptionListSnippetArg` | type | `{ item, index }` — argument of all three snippets |
991
+
992
+ ### Key Props
993
+
994
+ | Prop | Type | Default | Description |
995
+ | ------------- | -------------------------------------- | ------------ | ----------------------------------------------------------------------------------- |
996
+ | `items` | `DescriptionListItem[]` | — | The rows, in order (label/value/description are THC) |
997
+ | `children` | `Snippet` | — | Rendered inside the `<dl>` _instead of_ `items` |
998
+ | `layout` | `"auto" \| "stacked" \| "columns"` | `"auto"` | Label over value, beside it, or the former until the list is `columnsFrom` wide |
999
+ | `columnsFrom` | `"xs" \| "sm" \| "md" \| "lg" \| "xl"` | `"sm"` | The **list's own** width at which `"auto"` flips. Dropped unless `layout="auto"` |
1000
+ | `divide` | `"none" \| "inside" \| "outside"` | `"inside"` | n−1 rules (list in a box), n+1 (list loose on a page), or none |
1001
+ | `wrap` | `"anywhere" \| "truncate" \| "normal"` | `"anywhere"` | Long-value behavior; per-row override via `item.wrap` |
1002
+ | `valueAlign` | `"start" \| "end"` | `"start"` | `"end"` is the totals shape (pair with `--stuic-description-list-label-width: 1fr`) |
1003
+ | `emptyValue` | `THC` | `"—"` | Used when `value` is `undefined`/`null`/`""` — `0` is a value, not empty |
1004
+
1005
+ Snippets (all receive `{ item, index }`): `renderLabel`, `renderValue`, `renderItem` (the row `<div>` stays — it is what the grid is on). Class slots: `class`, `classItem`, `classLabel`, `classValue`, `classDescription`, each also available per item. Empty `items` with no `children` renders **nothing**, not an empty `<dl>`.
1006
+
1007
+ ### Why a container query
1008
+
1009
+ `layout="auto"` makes the `<dl>` a named inline-size container (`stuic-description-list`) and flips its **rows** to a two-column grid at `columnsFrom` — an element cannot query itself. The viewport answers a different question: a 200px-wide list inside a 1400px page must stay stacked, and `sm:` would have flipped it. The breakpoint is a prop on a fixed scale (Tailwind's `@xs`…`@xl`) and not a token because `@container (min-width: var(--x))` is not valid CSS — size queries take literals. Side effect worth knowing: `container-type: inline-size` gives the list inline-axis containment, so its min-content contribution to a flex parent is zero (the list can sit beside a fixed-width element and shrink into what is left; `min-w-0` is already there in the static layouts too).
1010
+
1011
+ ### Row-level contract (works in both forms)
1012
+
1013
+ `data-wrap="anywhere|truncate|normal"` and `data-emphasis` on a row `<div>` are honoured by the same selectors the data form uses, so `children` rows opt into per-row wrapping and the _Total_ look without a prop. Under `wrap="truncate"` a plain-string value auto-fills the `<dd>`'s `title` (a clipped value must stay reachable); pass `item.title` for html/component/snippet values.
1014
+
1015
+ ### CSS Tokens
1016
+
1017
+ Prefix: `--stuic-description-list-*`
1018
+
1019
+ `label-width` (not declared — fallback `minmax(7rem, 10rem)` at the usage site), `gap-x`, `gap-y`, `item-padding-y`, `rule-color`, `rule-width`, `label-font-size`, `label-font-weight`, `label-text`, `value-text`, `description-font-size`, `description-text`, `label-text-emphasis`, `value-font-weight-emphasis`
1020
+
1021
+ ---
1022
+
973
1023
  ## EmptyState
974
1024
 
975
1025
  Icon + title + description + CTA placeholder for empty lists, tables, and search results. Centered, non-interactive; the CTA area is a snippet filled with consumer `Button`s/links. Pairs naturally with `DataTable` ("no rows") and search UIs ("no results").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.174.0",
3
+ "version": "3.176.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",