@marianmeres/stuic 3.189.0 → 3.191.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/ # 80 component directories
26
+ ├── components/ # 82 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) — 80 component directories, Props pattern, snippets
149
+ - [Components](./docs/domains/components.md) — 82 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
package/API.md CHANGED
@@ -1273,6 +1273,43 @@ Horizontal schedule chart — project plans (task per row, progress, milestones)
1273
1273
 
1274
1274
  The geometry is exported separately for axis-aligned overlays: `buildGanttAxis`, `placeRange`, `placePoint`, `dayToFraction`, `boundsOf`.
1275
1275
 
1276
+ #### `ListGroup`
1277
+
1278
+ Bordered, rounded box of rows split by hairlines, with an optional header (title + aside) and footer. Each row is one wrapping flex line of parts; `data-grow` on the part that should absorb the slack. Generic over the item type.
1279
+
1280
+ | Prop | Type | Default | Description |
1281
+ | ------------ | -------------------------------------- | ------- | ---------------------------------------------------------------------- |
1282
+ | `items` | `T[]` | — | The rows (without `renderItem`, each renders as `THC`) |
1283
+ | `renderItem` | `Snippet<[{ item, index }]>` | — | Row content |
1284
+ | `children` | `Snippet` | — | Hand-written `<li>`s instead of `items` (styled identically) |
1285
+ | `getItemId` | `(item, index) => string \| number` | index | Keyed identity |
1286
+ | `itemProps` | `(item, index) => ListGroupItemProps` | — | Attributes (`data-*`, `class`) on each `<li>` |
1287
+ | `itemHref` | `(item, index) => string \| undefined` | — | Makes the whole row a link |
1288
+ | `title` | `THC` | — | Header start side; labels the list |
1289
+ | `titleLevel` | `1…6` | — | Render the title as `<hN>` (semantics only) |
1290
+ | `aside` | `THC` | — | Header end side |
1291
+ | `footer` | `THC` | — | A line under the rows |
1292
+ | `empty` | `THC` | — | Replaces the list when there are no rows; without it, nothing renders |
1293
+ | `listProps` | `ListGroupListProps` | — | Attributes for the `<ul>` (e.g. `aria-label` when there is no `title`) |
1294
+
1295
+ ```svelte
1296
+ <ListGroup
1297
+ class="text-sm"
1298
+ items={lines}
1299
+ getItemId={(l) => l.id}
1300
+ itemProps={(l) => ({ "data-line": l.id })}
1301
+ title="Loose items"
1302
+ aside="70 pc"
1303
+ empty="Nothing booked yet."
1304
+ >
1305
+ {#snippet renderItem({ item })}
1306
+ <span class="font-mono">{item.code}</span>
1307
+ <span data-grow>{item.name}</span>
1308
+ <span>×{item.qty}</span>
1309
+ {/snippet}
1310
+ </ListGroup>
1311
+ ```
1312
+
1276
1313
  #### `ImageCycler`
1277
1314
 
1278
1315
  Auto-cycling image carousel with fade transitions. Preloads next image before displaying. Supports custom title/description snippets.
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ // Layout fixture: rows whose parts are DIRECT children of the <li> (a raw snippet can
3
+ // only render one root element), plus a hand-written `children` form to compare.
4
+ import ListGroup from "./ListGroup.svelte";
5
+
6
+ interface Line {
7
+ id: number;
8
+ code: string;
9
+ name: string;
10
+ qty: number;
11
+ location: string;
12
+ }
13
+
14
+ let {
15
+ width = 1000,
16
+ mode = "items",
17
+ linked = false,
18
+ showRow = false,
19
+ style,
20
+ title = "Loose items",
21
+ }: {
22
+ width?: number;
23
+ mode?: "items" | "children" | "children-empty";
24
+ linked?: boolean;
25
+ showRow?: boolean;
26
+ style?: string;
27
+ title?: string;
28
+ } = $props();
29
+
30
+ const LINES: Line[] = [
31
+ {
32
+ id: 1,
33
+ code: "7QMET2GD",
34
+ name: "Folding chair",
35
+ qty: 40,
36
+ location: "hall-a.floor-a",
37
+ },
38
+ {
39
+ id: 2,
40
+ code: "89A6KMHY",
41
+ name: "Carpet tape, double-sided",
42
+ qty: 3,
43
+ location: "hall-a-raca.rack-a2.shelf-a2-2.consumables-bin",
44
+ },
45
+ ];
46
+ </script>
47
+
48
+ <!-- the theme is not loaded in browser tests: `--stuic-color-border` / `-ring` are undefined, which
49
+ would make the rule and ring declarations invalid at computed-value time -->
50
+ <div
51
+ data-testid="frame"
52
+ style="width: {width}px; --stuic-list-group-rule-color: currentColor; --stuic-list-group-item-ring-color: currentColor;"
53
+ >
54
+ {#if mode === "items"}
55
+ <ListGroup
56
+ items={LINES}
57
+ getItemId={(l) => l.id}
58
+ itemHref={linked ? (l) => `#/line/${l.id}` : undefined}
59
+ {title}
60
+ {style}
61
+ >
62
+ {#snippet renderItem({ item })}
63
+ <span data-part="code">{item.code}</span>
64
+ <span data-part="name" data-grow>{item.name}</span>
65
+ <span data-part="qty">×{item.qty}</span>
66
+ <span data-part="picked">picked {item.qty}/{item.qty}</span>
67
+ <span data-part="location">{item.location}</span>
68
+ {/snippet}
69
+ </ListGroup>
70
+ {:else if mode === "children"}
71
+ <ListGroup {title} {style}>
72
+ <li data-row="plain">
73
+ <span data-part="code">7QMET2GD</span>
74
+ <span data-part="name" data-grow>Folding chair</span>
75
+ </li>
76
+ <li data-row="link">
77
+ <a href="#/search"><span data-part="name" data-grow>Search</span><kbd>/</kbd></a>
78
+ </li>
79
+ <li data-row="button">
80
+ <button type="button"><span>A button row</span></button>
81
+ </li>
82
+ <li data-row="stuic">
83
+ <button type="button" class="stuic-button">Kept</button>
84
+ </li>
85
+ </ListGroup>
86
+ {:else}
87
+ <ListGroup {title} footer="The footer">
88
+ {#if showRow}<li>A row</li>{/if}
89
+ </ListGroup>
90
+ {/if}
91
+ </div>
@@ -0,0 +1,12 @@
1
+ import ListGroup from "./ListGroup.svelte";
2
+ type $$ComponentProps = {
3
+ width?: number;
4
+ mode?: "items" | "children" | "children-empty";
5
+ linked?: boolean;
6
+ showRow?: boolean;
7
+ style?: string;
8
+ title?: string;
9
+ };
10
+ declare const ListGroup: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type ListGroup = ReturnType<typeof ListGroup>;
12
+ export default ListGroup;
@@ -0,0 +1,232 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes, HTMLLiAttributes } from "svelte/elements";
3
+ import type { Snippet } from "svelte";
4
+ import type { THC } from "../Thc/Thc.svelte";
5
+
6
+ /** Renders the title as `<h1>`…`<h6>`. Semantics only — the look never changes. */
7
+ export type ListGroupTitleLevel = 1 | 2 | 3 | 4 | 5 | 6;
8
+
9
+ export interface ListGroupSnippetArg<T = unknown> {
10
+ item: T;
11
+ index: number;
12
+ }
13
+
14
+ /**
15
+ * What `itemProps` may put on a row `<li>` — typically the `data-*` hooks a consumer's
16
+ * tests select on. `class` is a plain string so it can be merged after `classItem`.
17
+ */
18
+ export type ListGroupItemProps = Omit<HTMLLiAttributes, "children" | "class"> & {
19
+ class?: string;
20
+ };
21
+
22
+ /** Attributes for the `<ul>` itself (`class` is `classList`, `role` is fixed) */
23
+ export type ListGroupListProps = Omit<
24
+ HTMLAttributes<HTMLUListElement>,
25
+ "children" | "class" | "role"
26
+ >;
27
+
28
+ export interface Props<T = unknown> extends Omit<
29
+ HTMLAttributes<HTMLDivElement>,
30
+ "children" | "title"
31
+ > {
32
+ /** The rows, in order. Data-driven form. */
33
+ items?: T[];
34
+ /**
35
+ * A row's content, rendered inside the `<li>` (or inside the `<a>` when `itemHref`
36
+ * returns a href). Without it an item is rendered as `THC`, so `items={["a", "b"]}`
37
+ * works as is.
38
+ */
39
+ renderItem?: Snippet<[ListGroupSnippetArg<T>]>;
40
+ /**
41
+ * Compositional form: rendered inside the `<ul>` *instead of* `items`. Write `<li>`s;
42
+ * the structural CSS styles them exactly like generated rows.
43
+ */
44
+ children?: Snippet;
45
+ /** Keyed `{#each}` identity. Defaults to the index. */
46
+ getItemId?: (item: T, index: number) => string | number;
47
+ /**
48
+ * Attributes spread onto each `<li>` — the `data-*` hooks consumers test against.
49
+ * A returned `class` is merged after `classItem`. `null`/`undefined` values omit the
50
+ * attribute. Identity comes from `getItemId`, never from here.
51
+ */
52
+ itemProps?: (item: T, index: number) => ListGroupItemProps | undefined;
53
+ /**
54
+ * When it returns a href, the row's content is wrapped in
55
+ * `<a class="stuic-list-group-item-link">` and the anchor becomes the row box
56
+ * (padding, hover, focus ring). Falsy → a plain row. A linked row must not contain
57
+ * other interactive content.
58
+ */
59
+ itemHref?: (item: T, index: number) => string | undefined | null;
60
+ /** The header's start side. Labels the list (`aria-labelledby`). */
61
+ title?: THC;
62
+ /** Render the title as `<hN>` instead of a `<div>`. Semantics only. */
63
+ titleLevel?: ListGroupTitleLevel;
64
+ /**
65
+ * The header's end side: a sum, a count, a shortfall, an action. Never an automatic
66
+ * row count — say what the figure is.
67
+ */
68
+ aside?: THC;
69
+ /** A line inside the box, under the rows, above a rule. */
70
+ footer?: THC;
71
+ /**
72
+ * Rendered in place of the `<ul>` when `items` is empty (or absent) and there are no
73
+ * `children`. Without it, a group with no rows renders nothing at all.
74
+ */
75
+ empty?: THC;
76
+ /**
77
+ * Attributes for the `<ul>`. The escape hatch for labelling a list that has no
78
+ * `title` (`aria-label`, or `aria-labelledby` pointing at a caption outside the box)
79
+ * — an `aria-label` on the root `<div>` would name nothing.
80
+ */
81
+ listProps?: ListGroupListProps;
82
+ /** Skip all default styling */
83
+ unstyled?: boolean;
84
+ /** Additional CSS classes for the root */
85
+ class?: string;
86
+ /** Class for the header row */
87
+ classHeader?: string;
88
+ /** Class for the title */
89
+ classTitle?: string;
90
+ /** Class for the aside */
91
+ classAside?: string;
92
+ /** Class for the `<ul>` */
93
+ classList?: string;
94
+ /** Class for every generated `<li>` */
95
+ classItem?: string;
96
+ /** Class for every generated `<a>` (`itemHref`) */
97
+ classItemLink?: string;
98
+ /** Class for the empty state */
99
+ classEmpty?: string;
100
+ /** Class for the footer */
101
+ classFooter?: string;
102
+ /** Bindable root element reference */
103
+ el?: HTMLDivElement;
104
+ }
105
+ </script>
106
+
107
+ <script lang="ts" generics="T = unknown">
108
+ import { twMerge } from "../../utils/tw-merge.js";
109
+ import { getId } from "../../utils/get-id.js";
110
+ import Thc, { isTHCNotEmpty } from "../Thc/Thc.svelte";
111
+
112
+ let {
113
+ items,
114
+ renderItem,
115
+ children,
116
+ getItemId = (_item: T, index: number) => index,
117
+ itemProps,
118
+ itemHref,
119
+ title,
120
+ titleLevel,
121
+ aside,
122
+ footer,
123
+ empty,
124
+ listProps,
125
+ unstyled = false,
126
+ class: classProp,
127
+ classHeader: classHeaderProp,
128
+ classTitle: classTitleProp,
129
+ classAside: classAsideProp,
130
+ classList: classListProp,
131
+ classItem: classItemProp,
132
+ classItemLink: classItemLinkProp,
133
+ classEmpty: classEmptyProp,
134
+ classFooter: classFooterProp,
135
+ el = $bindable(),
136
+ ...rest
137
+ }: Props<T> = $props();
138
+
139
+ const titleId = getId("stuic-list-group-title-");
140
+
141
+ let rows = $derived(items ?? []);
142
+ let hasRows = $derived(!!children || rows.length > 0);
143
+ let hasTitle = $derived(isTHCNotEmpty(title));
144
+ let hasAside = $derived(isTHCNotEmpty(aside));
145
+ let hasFooter = $derived(isTHCNotEmpty(footer));
146
+ let hasEmpty = $derived(isTHCNotEmpty(empty));
147
+
148
+ let _titleTag = $derived(
149
+ titleLevel && titleLevel >= 1 && titleLevel <= 6 ? `h${titleLevel}` : "div"
150
+ );
151
+
152
+ // Under `unstyled` a part keeps only the consumer's classes — `undefined` rather than
153
+ // `class=""` when there are none.
154
+ const _cls = (base: string, ...extra: (string | undefined)[]) =>
155
+ (unstyled ? twMerge(...extra) : twMerge(base, ...extra)) || undefined;
156
+
157
+ let _class = $derived(unstyled ? classProp : twMerge("stuic-list-group", classProp));
158
+ let _classHeader = $derived(_cls("stuic-list-group-header", classHeaderProp));
159
+ let _classTitle = $derived(_cls("stuic-list-group-title", classTitleProp));
160
+ let _classAside = $derived(_cls("stuic-list-group-aside", classAsideProp));
161
+ let _classList = $derived(_cls("stuic-list-group-list", classListProp));
162
+ let _classItemLink = $derived(_cls("stuic-list-group-item-link", classItemLinkProp));
163
+ let _classEmpty = $derived(_cls("stuic-list-group-empty", classEmptyProp));
164
+ let _classFooter = $derived(_cls("stuic-list-group-footer", classFooterProp));
165
+
166
+ /** `itemProps` split into the attributes to spread and the class to merge */
167
+ const _itemAttrs = (item: T, index: number) => {
168
+ const { class: itemClass, ...attrs }: ListGroupItemProps =
169
+ itemProps?.(item, index) ?? {};
170
+ return { attrs, class: _cls("stuic-list-group-item", classItemProp, itemClass) };
171
+ };
172
+ </script>
173
+
174
+ {#snippet content(item: T, index: number)}
175
+ {#if renderItem}
176
+ {@render renderItem({ item, index })}
177
+ {:else}
178
+ <Thc thc={item as THC} />
179
+ {/if}
180
+ {/snippet}
181
+
182
+ {#if hasRows || hasEmpty}
183
+ <div bind:this={el} class={_class} {...rest}>
184
+ <!-- A <div>, not a <header>: outside <main>/sectioning content (a drawer, a
185
+ dialog) a <header> is a `banner` landmark, one per group. -->
186
+ {#if hasTitle || hasAside}
187
+ <div class={_classHeader}>
188
+ {#if hasTitle}
189
+ <svelte:element this={_titleTag} id={titleId} class={_classTitle}>
190
+ <Thc thc={title!} />
191
+ </svelte:element>
192
+ {/if}
193
+ {#if hasAside}
194
+ <div class={_classAside}><Thc thc={aside!} /></div>
195
+ {/if}
196
+ </div>
197
+ {/if}
198
+
199
+ {#if hasRows}
200
+ <!-- WebKit drops list semantics from a `list-style: none` <ul> without it -->
201
+ <ul
202
+ role="list"
203
+ aria-labelledby={hasTitle ? titleId : undefined}
204
+ {...listProps}
205
+ class={_classList}
206
+ >
207
+ {#if children}
208
+ {@render children()}
209
+ {:else}
210
+ {#each rows as item, index (getItemId(item, index))}
211
+ {@const li = _itemAttrs(item, index)}
212
+ {@const href = itemHref?.(item, index)}
213
+ <li {...li.attrs} class={li.class}>
214
+ {#if href}
215
+ <a {href} class={_classItemLink}>{@render content(item, index)}</a>
216
+ {:else}
217
+ {@render content(item, index)}
218
+ {/if}
219
+ </li>
220
+ {/each}
221
+ {/if}
222
+ </ul>
223
+ {:else}
224
+ <div class={_classEmpty}><Thc thc={empty!} /></div>
225
+ {/if}
226
+
227
+ <!-- A <div>, not a <footer>: same landmark reason as the header -->
228
+ {#if hasFooter}
229
+ <div class={_classFooter}><Thc thc={footer!} /></div>
230
+ {/if}
231
+ </div>
232
+ {/if}
@@ -0,0 +1,116 @@
1
+ import type { HTMLAttributes, HTMLLiAttributes } from "svelte/elements";
2
+ import type { Snippet } from "svelte";
3
+ import type { THC } from "../Thc/Thc.svelte";
4
+ /** Renders the title as `<h1>`…`<h6>`. Semantics only — the look never changes. */
5
+ export type ListGroupTitleLevel = 1 | 2 | 3 | 4 | 5 | 6;
6
+ export interface ListGroupSnippetArg<T = unknown> {
7
+ item: T;
8
+ index: number;
9
+ }
10
+ /**
11
+ * What `itemProps` may put on a row `<li>` — typically the `data-*` hooks a consumer's
12
+ * tests select on. `class` is a plain string so it can be merged after `classItem`.
13
+ */
14
+ export type ListGroupItemProps = Omit<HTMLLiAttributes, "children" | "class"> & {
15
+ class?: string;
16
+ };
17
+ /** Attributes for the `<ul>` itself (`class` is `classList`, `role` is fixed) */
18
+ export type ListGroupListProps = Omit<HTMLAttributes<HTMLUListElement>, "children" | "class" | "role">;
19
+ export interface Props<T = unknown> extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "title"> {
20
+ /** The rows, in order. Data-driven form. */
21
+ items?: T[];
22
+ /**
23
+ * A row's content, rendered inside the `<li>` (or inside the `<a>` when `itemHref`
24
+ * returns a href). Without it an item is rendered as `THC`, so `items={["a", "b"]}`
25
+ * works as is.
26
+ */
27
+ renderItem?: Snippet<[ListGroupSnippetArg<T>]>;
28
+ /**
29
+ * Compositional form: rendered inside the `<ul>` *instead of* `items`. Write `<li>`s;
30
+ * the structural CSS styles them exactly like generated rows.
31
+ */
32
+ children?: Snippet;
33
+ /** Keyed `{#each}` identity. Defaults to the index. */
34
+ getItemId?: (item: T, index: number) => string | number;
35
+ /**
36
+ * Attributes spread onto each `<li>` — the `data-*` hooks consumers test against.
37
+ * A returned `class` is merged after `classItem`. `null`/`undefined` values omit the
38
+ * attribute. Identity comes from `getItemId`, never from here.
39
+ */
40
+ itemProps?: (item: T, index: number) => ListGroupItemProps | undefined;
41
+ /**
42
+ * When it returns a href, the row's content is wrapped in
43
+ * `<a class="stuic-list-group-item-link">` and the anchor becomes the row box
44
+ * (padding, hover, focus ring). Falsy → a plain row. A linked row must not contain
45
+ * other interactive content.
46
+ */
47
+ itemHref?: (item: T, index: number) => string | undefined | null;
48
+ /** The header's start side. Labels the list (`aria-labelledby`). */
49
+ title?: THC;
50
+ /** Render the title as `<hN>` instead of a `<div>`. Semantics only. */
51
+ titleLevel?: ListGroupTitleLevel;
52
+ /**
53
+ * The header's end side: a sum, a count, a shortfall, an action. Never an automatic
54
+ * row count — say what the figure is.
55
+ */
56
+ aside?: THC;
57
+ /** A line inside the box, under the rows, above a rule. */
58
+ footer?: THC;
59
+ /**
60
+ * Rendered in place of the `<ul>` when `items` is empty (or absent) and there are no
61
+ * `children`. Without it, a group with no rows renders nothing at all.
62
+ */
63
+ empty?: THC;
64
+ /**
65
+ * Attributes for the `<ul>`. The escape hatch for labelling a list that has no
66
+ * `title` (`aria-label`, or `aria-labelledby` pointing at a caption outside the box)
67
+ * — an `aria-label` on the root `<div>` would name nothing.
68
+ */
69
+ listProps?: ListGroupListProps;
70
+ /** Skip all default styling */
71
+ unstyled?: boolean;
72
+ /** Additional CSS classes for the root */
73
+ class?: string;
74
+ /** Class for the header row */
75
+ classHeader?: string;
76
+ /** Class for the title */
77
+ classTitle?: string;
78
+ /** Class for the aside */
79
+ classAside?: string;
80
+ /** Class for the `<ul>` */
81
+ classList?: string;
82
+ /** Class for every generated `<li>` */
83
+ classItem?: string;
84
+ /** Class for every generated `<a>` (`itemHref`) */
85
+ classItemLink?: string;
86
+ /** Class for the empty state */
87
+ classEmpty?: string;
88
+ /** Class for the footer */
89
+ classFooter?: string;
90
+ /** Bindable root element reference */
91
+ el?: HTMLDivElement;
92
+ }
93
+ declare function $$render<T = unknown>(): {
94
+ props: Props<T>;
95
+ exports: {};
96
+ bindings: "el";
97
+ slots: {};
98
+ events: {};
99
+ };
100
+ declare class __sveltets_Render<T = unknown> {
101
+ props(): ReturnType<typeof $$render<T>>['props'];
102
+ events(): ReturnType<typeof $$render<T>>['events'];
103
+ slots(): ReturnType<typeof $$render<T>>['slots'];
104
+ bindings(): "el";
105
+ exports(): {};
106
+ }
107
+ interface $$IsomorphicComponent {
108
+ new <T = unknown>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
109
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
110
+ } & ReturnType<__sveltets_Render<T>['exports']>;
111
+ <T = unknown>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
112
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
113
+ }
114
+ declare const ListGroup: $$IsomorphicComponent;
115
+ type ListGroup<T = unknown> = InstanceType<typeof ListGroup<T>>;
116
+ export default ListGroup;