@sukramj/fabric-ui 0.0.0-stage → 0.1.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.
Files changed (43) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +91 -2
  3. package/package.json +92 -4
  4. package/src/lib/api/client.ts +154 -0
  5. package/src/lib/api/index.ts +3 -0
  6. package/src/lib/api/schema.gen.ts +1159 -0
  7. package/src/lib/api/stream.ts +54 -0
  8. package/src/lib/context.ts +96 -0
  9. package/src/lib/i18n/de.json +157 -0
  10. package/src/lib/i18n/en.json +157 -0
  11. package/src/lib/i18n/index.ts +50 -0
  12. package/src/lib/index.ts +10 -0
  13. package/src/lib/kit/Badge.svelte +26 -0
  14. package/src/lib/kit/Button.svelte +49 -0
  15. package/src/lib/kit/Card.svelte +16 -0
  16. package/src/lib/kit/DataTable.svelte +239 -0
  17. package/src/lib/kit/EmptyState.svelte +24 -0
  18. package/src/lib/kit/ErrorState.svelte +22 -0
  19. package/src/lib/kit/Icon.svelte +12 -0
  20. package/src/lib/kit/Input.svelte +22 -0
  21. package/src/lib/kit/LoadingState.svelte +15 -0
  22. package/src/lib/kit/PageHeader.svelte +32 -0
  23. package/src/lib/kit/PageShell.svelte +16 -0
  24. package/src/lib/kit/Select.svelte +49 -0
  25. package/src/lib/kit/Spinner.svelte +59 -0
  26. package/src/lib/kit/Tabs.svelte +63 -0
  27. package/src/lib/kit/data-table.ts +24 -0
  28. package/src/lib/kit/icons.ts +41 -0
  29. package/src/lib/kit/index.ts +21 -0
  30. package/src/lib/kit/tabs.ts +13 -0
  31. package/src/lib/kit/utils.ts +49 -0
  32. package/src/lib/pages/index.ts +8 -0
  33. package/src/lib/pages/node/Diagnostics.svelte +205 -0
  34. package/src/lib/pages/node/Endpoints.svelte +82 -0
  35. package/src/lib/pages/node/Fabrics.svelte +169 -0
  36. package/src/lib/pages/node/Pair.svelte +186 -0
  37. package/src/lib/pages/node/Selection.svelte +388 -0
  38. package/src/lib/pages/node/Status.svelte +63 -0
  39. package/src/lib/qr.ts +58 -0
  40. package/src/lib/store.svelte.ts +293 -0
  41. package/src/lib/theme/ha-bridge.ts +156 -0
  42. package/src/lib/theme/index.ts +1 -0
  43. package/src/lib/theme/tokens.css +292 -0
@@ -0,0 +1,239 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui/DataTable.svelte:1-469
2
+ at b40b289a: sortable headers, an optional search box, sort and search
3
+ persisted under persistKey, the phone reflow (table-reflow in
4
+ tokens.css), groupBy sections and activatable rows. The column filter
5
+ row, column groups and row expansion are left out; the node pages use
6
+ none of them. -->
7
+ <script lang="ts" generics="Row">
8
+ import type { Snippet } from "svelte";
9
+ import type { IconName } from "./icons";
10
+ import type { DataColumn, DataTableSort } from "./data-table";
11
+ import { makeTextMatcher } from "./utils";
12
+ import { useT } from "../context";
13
+ import Input from "./Input.svelte";
14
+ import EmptyState from "./EmptyState.svelte";
15
+
16
+ let {
17
+ rows,
18
+ columns,
19
+ rowKey,
20
+ search = false,
21
+ searchPlaceholder = "",
22
+ initialSort = null,
23
+ persistKey = "",
24
+ emptyMessage,
25
+ emptyDescription = "",
26
+ emptyIcon = "mdi:format-list-bulleted",
27
+ cell,
28
+ rowClass,
29
+ onRowClick,
30
+ groupBy,
31
+ groupHeader,
32
+ }: {
33
+ rows: Row[];
34
+ columns: DataColumn<Row>[];
35
+ rowKey: (row: Row) => string;
36
+ search?: boolean;
37
+ searchPlaceholder?: string;
38
+ initialSort?: DataTableSort | null;
39
+ /** When set, sort and search persist under this localStorage key. */
40
+ persistKey?: string;
41
+ emptyMessage: string;
42
+ emptyDescription?: string;
43
+ emptyIcon?: IconName;
44
+ cell?: Snippet<[Row, DataColumn<Row>]>;
45
+ rowClass?: (row: Row) => string;
46
+ /** Makes every row activatable; a click inside a control stays the control's. */
47
+ onRowClick?: (row: Row) => void;
48
+ /** Splits the rows into sections, after search and sort, in first-row order. */
49
+ groupBy?: (row: Row) => string;
50
+ groupHeader?: Snippet<[string, Row[]]>;
51
+ } = $props();
52
+
53
+ const t = useT();
54
+
55
+ type Persisted = { sortKey: string; sortAsc: boolean; query: string };
56
+
57
+ // Read initialSort once; a persisted state wins.
58
+ function initialState(): Persisted {
59
+ let base: Persisted = { sortKey: initialSort?.key ?? "", sortAsc: initialSort?.asc ?? true, query: "" };
60
+ if (persistKey) {
61
+ try {
62
+ const raw = localStorage.getItem("fabric-ui.table:" + persistKey);
63
+ if (raw) base = { ...base, ...(JSON.parse(raw) as Partial<Persisted>) };
64
+ } catch {
65
+ // storage unavailable — the initial sort stands.
66
+ }
67
+ }
68
+ return base;
69
+ }
70
+
71
+ const init0 = initialState();
72
+ let sortKey = $state(init0.sortKey);
73
+ let sortAsc = $state(init0.sortAsc);
74
+ let query = $state(init0.query);
75
+
76
+ $effect(() => {
77
+ if (!persistKey) return;
78
+ const snapshot: Persisted = { sortKey, sortAsc, query };
79
+ try {
80
+ localStorage.setItem("fabric-ui.table:" + persistKey, JSON.stringify(snapshot));
81
+ } catch {
82
+ // storage unavailable — sort and search do not persist.
83
+ }
84
+ });
85
+
86
+ function toggleSort(col: DataColumn<Row>) {
87
+ if (!col.sortable) return;
88
+ if (sortKey === col.key) sortAsc = !sortAsc;
89
+ else {
90
+ sortKey = col.key;
91
+ sortAsc = true;
92
+ }
93
+ }
94
+
95
+ function alignClass(col: DataColumn<Row>): string {
96
+ const align = col.align ?? (col.numeric ? "right" : "left");
97
+ return align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
98
+ }
99
+
100
+ function rowClickHandler(row: Row) {
101
+ return (event: MouseEvent) => {
102
+ const target = event.target as HTMLElement | null;
103
+ if (target?.closest("button, a, input, select, textarea, label")) return;
104
+ onRowClick?.(row);
105
+ };
106
+ }
107
+
108
+ function rowKeyHandler(row: Row) {
109
+ return (event: KeyboardEvent) => {
110
+ if (event.key !== "Enter" && event.key !== " ") return;
111
+ const target = event.target as HTMLElement | null;
112
+ if (target?.closest("button, a, input, select, textarea")) return;
113
+ event.preventDefault();
114
+ onRowClick?.(row);
115
+ };
116
+ }
117
+
118
+ function compare(a: unknown, b: unknown): number {
119
+ const an = a === null || a === undefined;
120
+ const bn = b === null || b === undefined;
121
+ if (an && bn) return 0;
122
+ if (an) return 1; // nulls last
123
+ if (bn) return -1;
124
+ if (typeof a === "number" && typeof b === "number") return a - b;
125
+ return String(a).localeCompare(String(b), undefined, { sensitivity: "base", numeric: true });
126
+ }
127
+
128
+ const processed = $derived.by(() => {
129
+ let list = rows;
130
+ if (search && query.trim()) {
131
+ const match = makeTextMatcher(query);
132
+ list = list.filter((r) =>
133
+ columns.some((c) => {
134
+ const v = c.get?.(r);
135
+ return v != null && match(String(v));
136
+ }),
137
+ );
138
+ }
139
+ const col = sortKey ? columns.find((c) => c.key === sortKey) : undefined;
140
+ if (col?.get) {
141
+ const get = col.get;
142
+ list = [...list].sort((a, b) => {
143
+ const cmp = compare(get(a), get(b));
144
+ return sortAsc ? cmp : -cmp;
145
+ });
146
+ }
147
+ return list;
148
+ });
149
+
150
+ const groups = $derived.by(() => {
151
+ if (!groupBy) return null;
152
+ const out = new Map<string, Row[]>();
153
+ for (const row of processed) {
154
+ const key = groupBy(row);
155
+ const bucket = out.get(key);
156
+ if (bucket) bucket.push(row);
157
+ else out.set(key, [row]);
158
+ }
159
+ return Array.from(out, ([key, rows]) => ({ key, rows }));
160
+ });
161
+
162
+ function ariaSort(col: DataColumn<Row>): "ascending" | "descending" | "none" {
163
+ if (!col.sortable || sortKey !== col.key) return "none";
164
+ return sortAsc ? "ascending" : "descending";
165
+ }
166
+ </script>
167
+
168
+ {#if search}
169
+ <div class="mb-3 max-w-md">
170
+ <Input type="search" placeholder={searchPlaceholder || t("common.search")} bind:value={query} />
171
+ </div>
172
+ {/if}
173
+
174
+ {#if processed.length === 0}
175
+ <EmptyState message={emptyMessage} description={emptyDescription} icon={emptyIcon} />
176
+ {:else}
177
+ <div class="overflow-x-auto">
178
+ <table class="table-reflow w-full text-sm">
179
+ <thead class="border-b border-[var(--ha-divider-color)] text-left text-xs font-semibold uppercase tracking-wide text-[var(--ha-secondary-text-color)]">
180
+ <tr>
181
+ {#each columns as col (col.key)}
182
+ <th class="px-3 py-2 {alignClass(col)} {col.headClass ?? ''}" aria-sort={ariaSort(col)} scope="col">
183
+ {#if col.sortable}
184
+ <button
185
+ type="button"
186
+ class="inline-flex items-center gap-1 font-semibold uppercase tracking-wide hover:text-[var(--ha-primary-text-color)]"
187
+ onclick={() => toggleSort(col)}
188
+ >
189
+ <span>{col.label}</span>
190
+ <span class="w-3 text-[10px]" aria-hidden="true">{sortKey === col.key ? (sortAsc ? "▲" : "▼") : ""}</span>
191
+ </button>
192
+ {:else}
193
+ {col.label}
194
+ {/if}
195
+ </th>
196
+ {/each}
197
+ </tr>
198
+ </thead>
199
+ {#snippet bodyRow(row: Row)}
200
+ <tr
201
+ class="border-b border-[var(--ha-divider-color)] last:border-0 hover:bg-[var(--ha-secondary-background-color)] {onRowClick ? 'cursor-pointer' : ''} {rowClass?.(row) ?? ''}"
202
+ tabindex={onRowClick ? 0 : undefined}
203
+ onclick={onRowClick ? rowClickHandler(row) : undefined}
204
+ onkeydown={onRowClick ? rowKeyHandler(row) : undefined}
205
+ >
206
+ {#each columns as col (col.key)}
207
+ <td
208
+ class="px-3 py-2 {alignClass(col)} {col.numeric ? 'tabular-nums' : ''} {col.title ? 'reflow-title' : ''} {col.cellClass ?? ''}"
209
+ data-label={col.label}
210
+ >
211
+ {#if cell}{@render cell(row, col)}{:else}{col.get?.(row) ?? "—"}{/if}
212
+ </td>
213
+ {/each}
214
+ </tr>
215
+ {/snippet}
216
+
217
+ {#if groups}
218
+ {#each groups as group (group.key)}
219
+ <tbody>
220
+ <tr class="border-b border-[var(--ha-divider-color)] bg-[var(--ha-secondary-background-color)]">
221
+ <th colspan={columns.length} scope="colgroup" class="px-3 py-2 text-left font-normal">
222
+ {#if groupHeader}{@render groupHeader(group.key, group.rows)}{:else}{group.key}{/if}
223
+ </th>
224
+ </tr>
225
+ {#each group.rows as row (rowKey(row))}
226
+ {@render bodyRow(row)}
227
+ {/each}
228
+ </tbody>
229
+ {/each}
230
+ {:else}
231
+ <tbody>
232
+ {#each processed as row (rowKey(row))}
233
+ {@render bodyRow(row)}
234
+ {/each}
235
+ </tbody>
236
+ {/if}
237
+ </table>
238
+ </div>
239
+ {/if}
@@ -0,0 +1,24 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//EmptyState.svelte:1-46 at b40b289a. -->
2
+ <script lang="ts">
3
+ import type { Snippet } from "svelte";
4
+ import { cn } from "./utils";
5
+ import Icon from "./Icon.svelte";
6
+ import type { IconName } from "./icons";
7
+
8
+ type Props = { message: string; description?: string; icon?: IconName; class?: string; action?: Snippet };
9
+
10
+ let { message, description, icon = "mdi:text-box-search-outline", class: className, action }: Props = $props();
11
+ </script>
12
+
13
+ <div class={cn("flex flex-col items-center justify-center gap-3 py-12 text-center", className)}>
14
+ <Icon name={icon} size={40} class="text-[var(--ha-disabled-text-color)]" aria-label="" />
15
+ <div class="flex flex-col items-center gap-1">
16
+ <p class="text-sm text-[var(--ha-secondary-text-color)]">{message}</p>
17
+ {#if description}
18
+ <p class="max-w-sm text-balance text-xs text-[var(--ha-disabled-text-color)]">{description}</p>
19
+ {/if}
20
+ </div>
21
+ {#if action}
22
+ {@render action()}
23
+ {/if}
24
+ </div>
@@ -0,0 +1,22 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//ErrorState.svelte:1-36 at b40b289a. -->
2
+ <script lang="ts">
3
+ import { useT } from "../context";
4
+ import { cn } from "./utils";
5
+ import Card from "./Card.svelte";
6
+ import Button from "./Button.svelte";
7
+ import Icon from "./Icon.svelte";
8
+
9
+ type Props = { message: string; onRetry?: () => void; class?: string };
10
+ let { message, onRetry, class: className }: Props = $props();
11
+ const t = useT();
12
+ </script>
13
+
14
+ <Card class={cn("flex items-start gap-3 p-3", className)}>
15
+ <Icon name="mdi:alert-circle" size={18} class="mt-0.5 shrink-0 text-[var(--ha-error-color)]" aria-label="" />
16
+ <div class="min-w-0 flex-1">
17
+ <p class="text-sm text-[var(--ha-error-color)]">{t("common.error")} {message}</p>
18
+ {#if onRetry}
19
+ <Button variant="outline" size="sm" class="mt-2" onclick={onRetry}>{t("common.reload")}</Button>
20
+ {/if}
21
+ </div>
22
+ </Card>
@@ -0,0 +1,12 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//Icon.svelte:1-35 at b40b289a. -->
2
+ <script lang="ts">
3
+ import { resolveIcon, type IconName } from "./icons";
4
+
5
+ type Props = { name: IconName; size?: number; class?: string; "aria-label"?: string; title?: string };
6
+
7
+ let { name, size = 18, class: className = "", "aria-label": ariaLabel, title }: Props = $props();
8
+
9
+ const Cmp = $derived(resolveIcon(name));
10
+ </script>
11
+
12
+ <Cmp {size} class={className} aria-label={ariaLabel} aria-hidden={ariaLabel ? undefined : "true"} {title} />
@@ -0,0 +1,22 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//Input.svelte:1-24 at b40b289a. -->
2
+ <script lang="ts">
3
+ import type { HTMLInputAttributes } from "svelte/elements";
4
+ import { cn } from "./utils";
5
+
6
+ type Props = HTMLInputAttributes & { value?: string | number | null };
7
+
8
+ let { class: className, value = $bindable(), ...rest }: Props = $props();
9
+ </script>
10
+
11
+ <input
12
+ bind:value
13
+ class={cn(
14
+ // 16px on phones suppresses iOS Safari's focus zoom; h-10 is a 40px touch row.
15
+ "h-10 w-full rounded-md border border-[var(--ha-divider-color)] bg-[var(--ha-card-background-color)] px-3 py-1 text-base text-[var(--ha-primary-text-color)] shadow-sm transition sm:text-sm",
16
+ "placeholder:text-[var(--ha-secondary-text-color)]",
17
+ "focus-visible:border-[var(--ha-primary-color)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ha-primary-color)]",
18
+ "disabled:cursor-not-allowed disabled:opacity-50",
19
+ className,
20
+ )}
21
+ {...rest}
22
+ />
@@ -0,0 +1,15 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//LoadingState.svelte:1-30 at b40b289a. -->
2
+ <script lang="ts">
3
+ import { useT } from "../context";
4
+ import { cn } from "./utils";
5
+ import Spinner from "./Spinner.svelte";
6
+
7
+ type Props = { label?: string; message?: string; class?: string };
8
+ let { label, message, class: className }: Props = $props();
9
+ const t = useT();
10
+ </script>
11
+
12
+ <div class={cn("flex items-center justify-center gap-2 py-8 text-sm text-[var(--ha-secondary-text-color)]", className)} role="status" aria-live="polite">
13
+ <Spinner size={18} />
14
+ <span>{label ?? message ?? t("common.loading")}</span>
15
+ </div>
@@ -0,0 +1,32 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//PageHeader.svelte:1-72 at b40b289a: title and subtitle on the
2
+ left, actions on the right, wrapping by the rendered width (works inside
3
+ a narrow frame on a wide viewport). -->
4
+ <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+ import { cn } from "./utils";
7
+
8
+ interface Props {
9
+ title: string;
10
+ subtitle?: string;
11
+ actions?: Snippet;
12
+ children?: Snippet;
13
+ class?: string;
14
+ }
15
+
16
+ let { title, subtitle, actions, children, class: cls = "" }: Props = $props();
17
+ </script>
18
+
19
+ <header class={cn("mb-4 flex flex-wrap items-start justify-between gap-3", cls)}>
20
+ <div>
21
+ <h1 class="text-balance text-2xl font-bold tracking-tight text-[var(--ha-primary-text-color)]">{title}</h1>
22
+ {#if subtitle}
23
+ <p class="text-sm text-[var(--ha-secondary-text-color)]">{subtitle}</p>
24
+ {/if}
25
+ {#if children}
26
+ {@render children()}
27
+ {/if}
28
+ </div>
29
+ {#if actions}
30
+ <div class="flex flex-wrap items-center gap-2">{@render actions()}</div>
31
+ {/if}
32
+ </header>
@@ -0,0 +1,16 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//PageShell.svelte:1-44 at b40b289a. -->
2
+ <script lang="ts">
3
+ import type { Snippet } from "svelte";
4
+ import { cn } from "./utils";
5
+
6
+ type Width = "default" | "wide" | "narrow";
7
+ interface Props { width?: Width; class?: string; children: Snippet }
8
+
9
+ let { width = "default", class: cls = "", children }: Props = $props();
10
+
11
+ const widths: Record<Width, string> = { default: "max-w-6xl", wide: "", narrow: "max-w-4xl" };
12
+ </script>
13
+
14
+ <section class={cn("mx-auto w-full px-4 py-6 sm:px-6", widths[width], cls)}>
15
+ {@render children()}
16
+ </section>
@@ -0,0 +1,49 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//Select.svelte:1-80 at b40b289a, on a native <select>
2
+ instead of bits-ui: the same props and look, the platform's own popup
3
+ and keyboard handling, one dependency less. -->
4
+ <script lang="ts">
5
+ import { useT } from "../context";
6
+ import { cn } from "./utils";
7
+
8
+ type Option = { value: string; label: string };
9
+ type Props = {
10
+ options: Option[];
11
+ value?: string;
12
+ placeholder?: string;
13
+ disabled?: boolean;
14
+ onValueChange?: (value: string) => void;
15
+ ariaLabel?: string;
16
+ class?: string;
17
+ };
18
+
19
+ const t = useT();
20
+ let {
21
+ options,
22
+ value = $bindable(""),
23
+ placeholder = t("select.placeholder"),
24
+ disabled = false,
25
+ onValueChange,
26
+ ariaLabel,
27
+ class: className,
28
+ }: Props = $props();
29
+ </script>
30
+
31
+ <select
32
+ bind:value
33
+ {disabled}
34
+ aria-label={ariaLabel}
35
+ onchange={(e) => onValueChange?.((e.currentTarget as HTMLSelectElement).value)}
36
+ class={cn(
37
+ "h-10 w-full rounded-md border border-[var(--ha-divider-color)] bg-[var(--ha-card-background-color)] px-3 py-1 text-base text-[var(--ha-primary-text-color)] shadow-sm sm:text-sm",
38
+ "focus-visible:border-[var(--ha-primary-color)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ha-primary-color)]",
39
+ "disabled:cursor-not-allowed disabled:opacity-50",
40
+ className,
41
+ )}
42
+ >
43
+ {#if !options.some((o) => o.value === value)}
44
+ <option value="" disabled>{placeholder}</option>
45
+ {/if}
46
+ {#each options as option (option.value)}
47
+ <option value={option.value}>{option.label}</option>
48
+ {/each}
49
+ </select>
@@ -0,0 +1,59 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//Spinner.svelte:1-100 at b40b289a: three threads with a
2
+ sweeping segment; decorative, collapses to a static mark under reduced
3
+ motion. -->
4
+ <script lang="ts">
5
+ import { cn } from "./utils";
6
+
7
+ type Props = { size?: number; class?: string };
8
+ let { size = 18, class: className }: Props = $props();
9
+ </script>
10
+
11
+ <span class={cn("fui-loader text-[var(--ha-secondary-text-color)]", className)} style="--fui-size:{size}px" aria-hidden="true">
12
+ <span class="fui-thread"></span>
13
+ <span class="fui-thread"></span>
14
+ <span class="fui-thread"></span>
15
+ </span>
16
+
17
+ <style>
18
+ .fui-loader {
19
+ display: inline-block;
20
+ position: relative;
21
+ width: calc(var(--fui-size) * 1.5);
22
+ height: var(--fui-size);
23
+ flex: none;
24
+ }
25
+ .fui-thread {
26
+ position: absolute;
27
+ left: 0;
28
+ right: 0;
29
+ height: max(1.5px, calc(var(--fui-size) / 9));
30
+ border-radius: 9999px;
31
+ background-color: currentColor;
32
+ opacity: 0.25;
33
+ overflow: hidden;
34
+ }
35
+ .fui-thread:nth-child(1) { top: 12%; }
36
+ .fui-thread:nth-child(2) { top: 44%; }
37
+ .fui-thread:nth-child(3) { top: 76%; }
38
+ .fui-thread::after {
39
+ content: "";
40
+ position: absolute;
41
+ top: 0;
42
+ bottom: 0;
43
+ width: 40%;
44
+ border-radius: inherit;
45
+ background-color: currentColor;
46
+ opacity: 0.9;
47
+ animation: fui-sweep 1.1s ease-in-out infinite;
48
+ }
49
+ .fui-thread:nth-child(2)::after { animation-delay: 0.16s; }
50
+ .fui-thread:nth-child(3)::after { animation-delay: 0.32s; }
51
+ @keyframes fui-sweep {
52
+ 0% { transform: translateX(-110%); }
53
+ 55%, 100% { transform: translateX(360%); }
54
+ }
55
+ @media (prefers-reduced-motion: reduce) {
56
+ .fui-thread { opacity: 0.5; }
57
+ .fui-thread::after { animation: none; opacity: 0; }
58
+ }
59
+ </style>
@@ -0,0 +1,63 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//Tabs.svelte:1-107 at b40b289a: the underline and segmented
2
+ weights; the active marker uses the primary token so the skin applies. -->
3
+ <script lang="ts">
4
+ import type { TabItem } from "./tabs";
5
+ import { cn } from "./utils";
6
+ import Badge from "./Badge.svelte";
7
+ import Icon from "./Icon.svelte";
8
+
9
+ interface Props {
10
+ items: TabItem[];
11
+ active: string;
12
+ variant?: "underline" | "segmented";
13
+ fill?: boolean;
14
+ ariaLabel?: string;
15
+ onSelect?: (key: string) => void;
16
+ class?: string;
17
+ }
18
+
19
+ let { items, active, variant = "underline", fill = false, ariaLabel, onSelect, class: cls = "" }: Props = $props();
20
+
21
+ const listClass = $derived(
22
+ variant === "underline"
23
+ ? cn("flex gap-1 overflow-x-auto border-b border-[var(--ha-divider-color)]", cls)
24
+ : cn("inline-flex flex-wrap gap-0.5 rounded-lg bg-[var(--ha-secondary-background-color)] p-0.5", cls),
25
+ );
26
+
27
+ function itemClass(isActive: boolean): string {
28
+ if (variant === "underline") {
29
+ return cn(
30
+ "-mb-px inline-flex items-center justify-center gap-2 whitespace-nowrap border-b-2 px-4 py-3 text-center text-sm font-medium transition",
31
+ fill && "flex-1",
32
+ isActive
33
+ ? "border-[var(--ha-primary-color)] text-[var(--ha-primary-color)]"
34
+ : "border-transparent text-[var(--ha-secondary-text-color)] hover:text-[var(--ha-primary-text-color)]",
35
+ );
36
+ }
37
+ return cn(
38
+ "rounded-md px-3 py-1.5 text-sm font-medium transition",
39
+ isActive
40
+ ? "bg-[var(--ha-card-background-color)] text-[var(--ha-primary-text-color)] shadow-sm"
41
+ : "text-[var(--ha-secondary-text-color)] hover:text-[var(--ha-primary-text-color)]",
42
+ );
43
+ }
44
+ </script>
45
+
46
+ <div class={listClass} role="tablist" aria-label={ariaLabel}>
47
+ {#each items as item (item.key)}
48
+ {@const isActive = item.key === active}
49
+ {#if item.href}
50
+ <a href={item.href} role="tab" aria-selected={isActive} class={itemClass(isActive)} onclick={() => onSelect?.(item.key)}>
51
+ {#if item.icon}<Icon name={item.icon} size={16} />{/if}
52
+ {item.label}
53
+ {#if item.badge !== undefined}<Badge variant="muted">{item.badge}</Badge>{/if}
54
+ </a>
55
+ {:else}
56
+ <button type="button" role="tab" aria-selected={isActive} class={itemClass(isActive)} onclick={() => onSelect?.(item.key)}>
57
+ {#if item.icon}<Icon name={item.icon} size={16} />{/if}
58
+ {item.label}
59
+ {#if item.badge !== undefined}<Badge variant="muted">{item.badge}</Badge>{/if}
60
+ </button>
61
+ {/if}
62
+ {/each}
63
+ </div>
@@ -0,0 +1,24 @@
1
+ // The column descriptor of DataTable.svelte. Ported from openccu-loom
2
+ // assets/ui/src/lib/components/ui/data-table.ts:1-55 at b40b289a, without
3
+ // the per-column filter row and column groups the node pages do not use.
4
+
5
+ export type DataTableAlign = "left" | "right" | "center";
6
+
7
+ export type DataColumn<Row> = {
8
+ /** Stable key: sort state, the cell's data-label, the cell snippet's branch. */
9
+ key: string;
10
+ /** Header label. */
11
+ label: string;
12
+ align?: DataTableAlign;
13
+ sortable?: boolean;
14
+ /** Scalar accessor for sorting, the default text and the search. */
15
+ get?: (row: Row) => string | number | null | undefined;
16
+ /** The primary column: on the phone reflow it is the card heading. */
17
+ title?: boolean;
18
+ cellClass?: string;
19
+ headClass?: string;
20
+ /** Tabular figures, right-aligned unless align says otherwise. */
21
+ numeric?: boolean;
22
+ };
23
+
24
+ export type DataTableSort = { key: string; asc: boolean };
@@ -0,0 +1,41 @@
1
+ // The icon registry: Home Assistant's mdi-style names mapped onto Lucide
2
+ // components, so a template names an icon once. Ported from openccu-loom
3
+ // assets/ui/src/lib/icons.ts:1-20, :205-300 and resolveIcon at b40b289a,
4
+ // reduced to the icons the node pages use.
5
+ import type { Component } from "svelte";
6
+ import Ban from "@lucide/svelte/icons/ban";
7
+ import Circle from "@lucide/svelte/icons/circle";
8
+ import CircleAlert from "@lucide/svelte/icons/circle-alert";
9
+ import CircleCheck from "@lucide/svelte/icons/circle-check";
10
+ import Info from "@lucide/svelte/icons/info";
11
+ import LinkIcon from "@lucide/svelte/icons/link";
12
+ import List from "@lucide/svelte/icons/list";
13
+ import ListChecks from "@lucide/svelte/icons/list-checks";
14
+
15
+ export type IconName =
16
+ | "mdi:alert-circle"
17
+ | "mdi:cancel"
18
+ | "mdi:check-circle"
19
+ | "mdi:circle-outline"
20
+ | "mdi:format-list-bulleted"
21
+ | "mdi:information-outline"
22
+ | "mdi:link"
23
+ | "mdi:list-checks"
24
+ | "mdi:text-box-search-outline";
25
+
26
+ const REGISTRY: Record<IconName, Component> = {
27
+ "mdi:alert-circle": CircleAlert as unknown as Component,
28
+ "mdi:cancel": Ban as unknown as Component,
29
+ "mdi:check-circle": CircleCheck as unknown as Component,
30
+ "mdi:circle-outline": Circle as unknown as Component,
31
+ "mdi:format-list-bulleted": List as unknown as Component,
32
+ "mdi:information-outline": Info as unknown as Component,
33
+ "mdi:link": LinkIcon as unknown as Component,
34
+ "mdi:list-checks": ListChecks as unknown as Component,
35
+ "mdi:text-box-search-outline": List as unknown as Component,
36
+ };
37
+
38
+ /** The component for an icon name; an unknown name renders the info icon. */
39
+ export function resolveIcon(name: IconName): Component {
40
+ return REGISTRY[name] ?? (Info as unknown as Component);
41
+ }
@@ -0,0 +1,21 @@
1
+ // The UI kit: the components the node pages are built from, for a host
2
+ // that wants its own pages to look the same.
3
+ export { default as Badge } from "./Badge.svelte";
4
+ export { default as Button } from "./Button.svelte";
5
+ export { default as Card } from "./Card.svelte";
6
+ export { default as DataTable } from "./DataTable.svelte";
7
+ export { default as EmptyState } from "./EmptyState.svelte";
8
+ export { default as ErrorState } from "./ErrorState.svelte";
9
+ export { default as Icon } from "./Icon.svelte";
10
+ export { default as Input } from "./Input.svelte";
11
+ export { default as LoadingState } from "./LoadingState.svelte";
12
+ export { default as PageHeader } from "./PageHeader.svelte";
13
+ export { default as PageShell } from "./PageShell.svelte";
14
+ export { default as Select } from "./Select.svelte";
15
+ export { default as Spinner } from "./Spinner.svelte";
16
+ export { default as Tabs } from "./Tabs.svelte";
17
+ export type { DataColumn, DataTableAlign, DataTableSort } from "./data-table";
18
+ export type { TabItem } from "./tabs";
19
+ export type { IconName } from "./icons";
20
+ export { resolveIcon } from "./icons";
21
+ export { cn, makeTextMatcher } from "./utils";
@@ -0,0 +1,13 @@
1
+ // The tab descriptor of Tabs.svelte. Ported from openccu-loom
2
+ // assets/ui/src/lib/components/ui/tabs.ts:1-19 at b40b289a.
3
+ import type { IconName } from "./icons";
4
+
5
+ export type TabItem = {
6
+ /** Compared against the strip's `active`. */
7
+ key: string;
8
+ label: string;
9
+ /** A link for route-driven tabs; without it the tab is a button reporting through onSelect. */
10
+ href?: string;
11
+ icon?: IconName;
12
+ badge?: string | number;
13
+ };