@valentinkolb/cloud 0.4.0 → 0.5.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 (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,162 @@
1
+ import { charts } from "@valentinkolb/stdlib";
2
+ import type { JSX } from "solid-js";
3
+ import { createSignal, onCleanup, onMount, Show } from "solid-js";
4
+
5
+ /**
6
+ * Chart — minimal Solid wrapper around `stdlib.charts`.
7
+ *
8
+ * **Live-update story.** `charts.<kind>(opts)` returns an SVG string;
9
+ * Solid's `innerHTML` is reactive, so any time a prop (signal, store
10
+ * slice, derived value) changes, the SVG re-renders. No manual
11
+ * subscription, no imperative DOM patching. Trade-off: every change
12
+ * is a full SVG re-build, not a diff — fine for dashboard cadences
13
+ * (poll, websocket, store updates). Don't use this for 60fps streaming.
14
+ *
15
+ * **Sizing.** stdlib emits `<svg viewBox="0 0 W H">` with no width/
16
+ * height attributes, so the SVG would otherwise fall back to the
17
+ * browser's replaced-element default (300×150) and either overflow
18
+ * or look squished. We measure the wrapping `<div>` with a
19
+ * ResizeObserver and pass the actual pixel dimensions to stdlib —
20
+ * the viewBox matches the container, no aspect distortion, no
21
+ * letterboxing. Sizing the wrapper itself is the caller's job
22
+ * (`class="h-56 w-full"`, flex child, etc.). On SSR (no observer)
23
+ * the chart renders at stdlib's default size; the first client-side
24
+ * frame re-measures and re-renders.
25
+ *
26
+ * **Why so thin.** The props are a discriminated union over each
27
+ * stdlib chart function — `kind: "line"` brings in exactly the params
28
+ * `charts.line` expects, `kind: "bar"` brings in `charts.bar`'s, etc.
29
+ * Zero invented API, zero option renaming. If stdlib gains a new
30
+ * option, it's automatically available at every callsite.
31
+ *
32
+ * **Theming.** stdlib charts use `currentColor` for axes / ticks /
33
+ * tick labels — set the wrapping element's `color` (via Tailwind
34
+ * `text-dimmed` / `text-primary` / dark variants) and everything
35
+ * inherits. Series colors come from `--stdlib-chart-c1..c8` CSS
36
+ * custom properties; override on the parent for per-chart palettes.
37
+ *
38
+ * ```tsx
39
+ * <Chart kind="line" class="h-48 text-dimmed"
40
+ * series={[{ data: points() }]}
41
+ * yAxis={{ format: v => `€${v}k` }} />
42
+ *
43
+ * <Chart kind="donut" class="h-48" data={slices()} />
44
+ *
45
+ * <Chart kind="sparkline" class="w-24 h-6 text-emerald-600" data={trend()} />
46
+ * ```
47
+ */
48
+
49
+ /** All chart kinds shipped by `stdlib.charts`. */
50
+ export type ChartKind = keyof typeof charts;
51
+
52
+ /**
53
+ * Per-kind props: `kind` discriminator + the exact options that
54
+ * `charts.<kind>` accepts, **minus** `width` / `height` (the wrapper
55
+ * owns those — they're derived from container measurement). Solid's
56
+ * component model handles discriminated unions natively, so callsites
57
+ * get full type safety.
58
+ */
59
+ export type ChartProps = {
60
+ [K in ChartKind]: { kind: K; class?: string; style?: JSX.CSSProperties | string } & Omit<
61
+ Parameters<(typeof charts)[K]>[0],
62
+ "width" | "height"
63
+ >;
64
+ }[ChartKind];
65
+
66
+ /**
67
+ * Internal — strips wrapper-only keys from props and forwards the
68
+ * rest (plus measured size) to `charts[kind]`. The `any` is the
69
+ * price for dispatching one function call across 8 different option
70
+ * types; an explicit per-kind switch would type it but balloon the
71
+ * component for no runtime benefit.
72
+ */
73
+ const renderSvg = (props: ChartProps, width: number, height: number): string => {
74
+ const { kind, class: _class, style: _style, ...opts } = props;
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ return (charts[kind] as (o: unknown) => string)({ ...(opts as any), width, height });
77
+ };
78
+
79
+ /** Empty-data short-circuit. Kept per-kind because stdlib's payload
80
+ * key differs (series vs data vs groups). We're conservative: only
81
+ * block on truly empty inputs; partially-filled series get rendered
82
+ * as-is and stdlib handles the gaps. */
83
+ const isEmpty = (props: ChartProps): boolean => {
84
+ if (props.kind === "line" || props.kind === "scatter") {
85
+ return !props.series?.length || props.series.every((s) => !s.data.length);
86
+ }
87
+ if (props.kind === "bar" || props.kind === "donut" || props.kind === "pie") {
88
+ return !props.data?.length;
89
+ }
90
+ if (props.kind === "histogram" || props.kind === "sparkline") {
91
+ return !props.data?.length;
92
+ }
93
+ if (props.kind === "boxplot") {
94
+ return !props.groups?.length;
95
+ }
96
+ return false;
97
+ };
98
+
99
+ const Chart = (props: ChartProps): JSX.Element => {
100
+ let containerRef: HTMLDivElement | undefined;
101
+ // Initial size matches stdlib's chart-function defaults so the SSR
102
+ // render is sensible. The observer updates this on the first
103
+ // client-side frame; the SVG re-renders reactively via innerHTML.
104
+ const [size, setSize] = createSignal({ width: 480, height: 280 });
105
+
106
+ onMount(() => {
107
+ if (!containerRef) return;
108
+ // Seed immediately from layout — avoids one wasted re-render in
109
+ // the case where the container already has its final size at
110
+ // mount time (the common case for dashboard widgets).
111
+ const rect = containerRef.getBoundingClientRect();
112
+ if (rect.width > 0 && rect.height > 0) {
113
+ setSize({ width: Math.round(rect.width), height: Math.round(rect.height) });
114
+ }
115
+ const ro = new ResizeObserver((entries) => {
116
+ const entry = entries[0];
117
+ if (!entry) return;
118
+ const { width, height } = entry.contentRect;
119
+ // Floor to integer pixels; sub-pixel jitter would trigger an
120
+ // SVG re-render on every scroll/zoom otherwise.
121
+ if (width > 0 && height > 0) {
122
+ setSize((prev) => {
123
+ const w = Math.round(width);
124
+ const h = Math.round(height);
125
+ return prev.width === w && prev.height === h ? prev : { width: w, height: h };
126
+ });
127
+ }
128
+ });
129
+ ro.observe(containerRef);
130
+ onCleanup(() => ro.disconnect());
131
+ });
132
+
133
+ return (
134
+ <Show
135
+ when={!isEmpty(props)}
136
+ fallback={
137
+ <div
138
+ ref={containerRef}
139
+ class={`flex items-center justify-center text-xs text-dimmed ${props.class ?? ""}`}
140
+ style={props.style}
141
+ >
142
+ No data
143
+ </div>
144
+ }
145
+ >
146
+ {/* The wrapping div is what the ResizeObserver watches. `block`
147
+ + the caller's sizing classes (h-48, w-full, flex-1, …) drive
148
+ the available space; the SVG inside fills it via viewBox =
149
+ container size. `innerHTML` is reactive in Solid — re-runs
150
+ on every prop / size change, so live data updates propagate
151
+ without ceremony. */}
152
+ <div
153
+ ref={containerRef}
154
+ class={`block ${props.class ?? ""}`}
155
+ style={props.style}
156
+ innerHTML={renderSvg(props, size().width, size().height)}
157
+ />
158
+ </Show>
159
+ );
160
+ };
161
+
162
+ export default Chart;
@@ -0,0 +1,54 @@
1
+ import { For, Show } from "solid-js";
2
+ import CopyButton from "./CopyButton";
3
+ import { type CodeDisplayLanguage, highlightCodeDisplayLines } from "./code-highlight";
4
+
5
+ export type { CodeDisplayLanguage };
6
+
7
+ export type CodeDisplayProps = {
8
+ code: string;
9
+ title?: string;
10
+ language?: CodeDisplayLanguage;
11
+ copy?: boolean;
12
+ lineNumbers?: boolean;
13
+ class?: string;
14
+ };
15
+
16
+ export default function CodeDisplay(props: CodeDisplayProps) {
17
+ const lines = () => highlightCodeDisplayLines(props.code, language());
18
+ const lineNumbers = () => props.lineNumbers ?? true;
19
+ const language = () => props.language ?? "text";
20
+ const hasHeader = () => Boolean(props.title || props.copy !== false);
21
+
22
+ return (
23
+ <div
24
+ class={`code-display my-3 overflow-hidden rounded-lg bg-zinc-100 ring-1 ring-inset ring-zinc-200/70 dark:bg-zinc-900/70 dark:ring-zinc-800/80 ${props.class ?? ""}`}
25
+ >
26
+ <Show when={hasHeader()}>
27
+ <div class="flex items-center justify-between gap-3 px-3 py-1.5">
28
+ <Show when={props.title}>{(title) => <p class="truncate text-xs font-semibold text-secondary">{title()}</p>}</Show>
29
+ <Show when={props.copy !== false}>
30
+ <CopyButton
31
+ text={props.code}
32
+ class="focus-ui inline-flex h-6 w-6 items-center justify-center rounded text-[10px] text-dimmed hover:bg-white/80 hover:text-primary dark:hover:bg-zinc-800"
33
+ />
34
+ </Show>
35
+ </div>
36
+ </Show>
37
+
38
+ <div class={`code-display-code overflow-x-auto px-3 ${hasHeader() ? "pb-2" : "py-2"} font-mono text-xs leading-5`}>
39
+ <div class="min-w-max">
40
+ <For each={lines()}>
41
+ {(line, index) => (
42
+ <div class={lineNumbers() ? "grid grid-cols-[2rem_1fr]" : "grid grid-cols-[1fr]"}>
43
+ <Show when={lineNumbers()}>
44
+ <span class="select-none pr-3 text-right tabular-nums text-zinc-400 dark:text-zinc-600">{index() + 1}</span>
45
+ </Show>
46
+ <code class="whitespace-pre pr-4 font-mono text-primary" innerHTML={line || " "} />
47
+ </div>
48
+ )}
49
+ </For>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ );
54
+ }
@@ -20,7 +20,7 @@ export type ContextMenuProps = ParentProps<{
20
20
  }>;
21
21
 
22
22
  const ITEM_BASE_CLASSES =
23
- "flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-white/30 dark:hover:bg-white/10";
23
+ "flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-zinc-100 dark:hover:bg-white/10";
24
24
 
25
25
  const getVariantClasses = (variant?: "danger") =>
26
26
  variant === "danger" ? "text-red-600 dark:text-red-400" : "text-zinc-700 dark:text-zinc-300";
@@ -178,7 +178,7 @@ export default function ContextMenu(props: ContextMenuProps) {
178
178
  ref={menuRef}
179
179
  role="menu"
180
180
  aria-label="Context menu"
181
- class="fixed z-50 w-52 max-w-[min(22rem,calc(100vw-1rem))] overflow-y-auto rounded-xl border border-zinc-300/60 bg-white/95 p-0 text-zinc-900 shadow-lg ring-1 ring-black/5 backdrop-blur-sm dark:border-zinc-600/50 dark:bg-zinc-950/95 dark:text-zinc-100"
181
+ class="fixed z-50 w-52 max-w-[min(22rem,calc(100vw-1rem))] overflow-y-auto rounded-xl border border-zinc-300/60 bg-white/95 p-0 text-zinc-900 [box-shadow:var(--theme-shadow-float)] ring-1 ring-black/5 backdrop-blur-sm dark:border-zinc-600/50 dark:bg-zinc-950/95 dark:text-zinc-100"
182
182
  style={{
183
183
  left: `${Math.min(coords().x, window.innerWidth - 220)}px`,
184
184
  top: `${Math.min(coords().y, window.innerHeight - 320)}px`,
@@ -0,0 +1,269 @@
1
+ import { createEffect, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
2
+ import Placeholder from "./Placeholder";
3
+
4
+ export type DataTableColumn<T> = {
5
+ id: string;
6
+ header: JSX.Element | ((ctx: { col: DataTableColumn<T> }) => JSX.Element);
7
+ subtitle?: JSX.Element | ((ctx: { col: DataTableColumn<T> }) => JSX.Element);
8
+ value?: keyof T | ((row: T) => unknown);
9
+ class?: string;
10
+ headerClass?: string;
11
+ cellClass?: string;
12
+ };
13
+
14
+ export type DataTableRenderCell<T> = (ctx: {
15
+ row: T;
16
+ col: DataTableColumn<T>;
17
+ value: unknown;
18
+ render: (value: unknown) => JSX.Element;
19
+ }) => JSX.Element;
20
+
21
+ export type DataTableRenderHeader<T> = (ctx: { col: DataTableColumn<T>; render: () => JSX.Element }) => JSX.Element;
22
+
23
+ export type DataTableFooter<T> = {
24
+ values?: Record<string, unknown>;
25
+ renderCell?: (ctx: { col: DataTableColumn<T>; value: unknown; render: (value: unknown) => JSX.Element }) => JSX.Element;
26
+ };
27
+
28
+ export type DataTableProps<T> = {
29
+ rows: readonly T[];
30
+ columns: readonly DataTableColumn<T>[];
31
+ getRowId?: (row: T) => string;
32
+ selectedRowId?: string | null;
33
+ rowClass?: string | ((row: T) => string | undefined);
34
+ hoverRows?: boolean;
35
+ onRowClick?: (row: T) => void;
36
+ onRowDoubleClick?: (row: T) => void;
37
+ renderCell?: DataTableRenderCell<T>;
38
+ renderHeader?: DataTableRenderHeader<T>;
39
+ footer?: DataTableFooter<T>;
40
+ hasMore?: boolean;
41
+ loadingMore?: boolean;
42
+ onLoadMore?: () => void;
43
+ empty?: JSX.Element;
44
+ density?: "compact" | "normal";
45
+ stickyHeader?: boolean;
46
+ highlightColumns?: boolean;
47
+ verticalAlign?: "top" | "middle" | "bottom";
48
+ cellContentClass?: string;
49
+ fillHeight?: boolean;
50
+ class?: string;
51
+ tableClass?: string;
52
+ scrollPreserveKey?: string | false;
53
+ };
54
+
55
+ const defaultRender = (value: unknown): JSX.Element => {
56
+ if (value === null || value === undefined || value === "") return "—";
57
+ if (value instanceof Date) return value.toLocaleString();
58
+ if (typeof value === "boolean") return value ? "Yes" : "No";
59
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
60
+ if (typeof value === "string") return value;
61
+ return JSON.stringify(value);
62
+ };
63
+
64
+ const renderColumnPart = <T,>(
65
+ part: DataTableColumn<T>["header"] | DataTableColumn<T>["subtitle"],
66
+ col: DataTableColumn<T>,
67
+ ): JSX.Element => {
68
+ if (typeof part === "function") return part({ col });
69
+ return part;
70
+ };
71
+
72
+ export default function DataTable<T>(props: DataTableProps<T>) {
73
+ const [hoveredColumn, setHoveredColumn] = createSignal<number | null>(null);
74
+ let scrollRef: HTMLDivElement | undefined;
75
+ let loadMoreRef: HTMLDivElement | undefined;
76
+ let hasMore = false;
77
+ let loadingMore = false;
78
+ let onLoadMore: (() => void) | undefined;
79
+ const rowId = (row: T) => props.getRowId?.(row);
80
+ const isInteractive = () => !!props.onRowClick || !!props.onRowDoubleClick;
81
+ const shouldHoverRows = () => props.hoverRows ?? isInteractive();
82
+ const shouldRenderLoadMoreSentinel = () => !!props.onLoadMore;
83
+ const cellPadding = () => (props.density === "compact" ? "px-3 py-1.5" : "px-3 py-2");
84
+ const headerPadding = () => (props.density === "compact" ? "px-3 py-1.5" : "px-3 py-2");
85
+ const cellContentClass = () => props.cellContentClass ?? "truncate";
86
+ const cellVerticalAlignClass = () =>
87
+ props.verticalAlign === "top" ? "align-top" : props.verticalAlign === "bottom" ? "align-bottom" : "align-middle";
88
+ const tableClass = () => props.tableClass ?? `w-full text-xs ${props.fillHeight ? "h-full" : ""}`;
89
+ const columnHoverClass = (index: number) =>
90
+ props.highlightColumns !== false && shouldHoverRows() && hoveredColumn() === index ? "bg-zinc-950/[0.015] dark:bg-black/[0.12]" : "";
91
+ const setHoveredColumnIfEnabled = (index: number) => {
92
+ if (shouldHoverRows()) setHoveredColumn(index);
93
+ };
94
+
95
+ const isNearBottom = () => {
96
+ if (!scrollRef) return false;
97
+ return scrollRef.scrollTop + scrollRef.clientHeight >= scrollRef.scrollHeight - 240;
98
+ };
99
+
100
+ const maybeLoadMore = () => {
101
+ if (!hasMore || loadingMore || !onLoadMore) return;
102
+ if (!isNearBottom()) return;
103
+ onLoadMore();
104
+ };
105
+
106
+ const valueOf = (row: T, col: DataTableColumn<T>) => {
107
+ if (typeof col.value === "function") return col.value(row);
108
+ if (col.value) return row[col.value];
109
+ return undefined;
110
+ };
111
+
112
+ const renderHeaderDefault = (col: DataTableColumn<T>): JSX.Element => (
113
+ <div class="flex flex-col gap-0.5 leading-tight">
114
+ <span class="text-primary font-semibold">{renderColumnPart(col.header, col)}</span>
115
+ <Show when={col.subtitle !== undefined}>
116
+ <span class="text-[10px] text-dimmed font-normal">{renderColumnPart(col.subtitle, col)}</span>
117
+ </Show>
118
+ </div>
119
+ );
120
+
121
+ const renderCellDefault = (row: T, col: DataTableColumn<T>) => defaultRender(valueOf(row, col));
122
+
123
+ const onRowKeyDown = (event: KeyboardEvent, row: T) => {
124
+ if (!isInteractive()) return;
125
+ if (event.key !== "Enter" && event.key !== " ") return;
126
+ event.preventDefault();
127
+ props.onRowClick?.(row);
128
+ };
129
+
130
+ const rowClass = (row: T) => {
131
+ if (typeof props.rowClass === "function") return props.rowClass(row) ?? "";
132
+ return props.rowClass ?? "";
133
+ };
134
+
135
+ onMount(() => {
136
+ if (typeof IntersectionObserver === "undefined" || !scrollRef || !loadMoreRef) return;
137
+ const observer = new IntersectionObserver(
138
+ (entries) => {
139
+ if (entries.some((entry) => entry.isIntersecting)) maybeLoadMore();
140
+ },
141
+ { root: scrollRef, rootMargin: "240px" },
142
+ );
143
+ observer.observe(loadMoreRef);
144
+ onCleanup(() => observer.disconnect());
145
+ });
146
+
147
+ createEffect(() => {
148
+ props.rows.length;
149
+ hasMore = !!props.hasMore;
150
+ loadingMore = !!props.loadingMore;
151
+ onLoadMore = props.onLoadMore;
152
+ maybeLoadMore();
153
+ });
154
+
155
+ return (
156
+ <Show when={props.columns.length > 0} fallback={<Placeholder surface="paper">No columns.</Placeholder>}>
157
+ <div
158
+ ref={scrollRef}
159
+ role="region"
160
+ aria-label="Data table"
161
+ class={props.class ?? "paper overflow-auto flex-1 min-h-0"}
162
+ data-scroll-preserve={props.scrollPreserveKey || undefined}
163
+ onScroll={maybeLoadMore}
164
+ onMouseLeave={() => setHoveredColumn(null)}
165
+ >
166
+ <table class={tableClass()}>
167
+ <thead class={props.stickyHeader === false ? undefined : "sticky top-0 z-10 bg-zinc-50 dark:bg-zinc-950"}>
168
+ <tr class="border-b border-zinc-100 dark:border-zinc-800">
169
+ <For each={props.columns}>
170
+ {(col, index) => (
171
+ <th
172
+ class={`${headerPadding()} text-left ${columnHoverClass(index())} ${col.headerClass ?? ""} ${col.class ?? ""}`}
173
+ onMouseEnter={() => setHoveredColumnIfEnabled(index())}
174
+ >
175
+ {props.renderHeader ? props.renderHeader({ col, render: () => renderHeaderDefault(col) }) : renderHeaderDefault(col)}
176
+ </th>
177
+ )}
178
+ </For>
179
+ </tr>
180
+ </thead>
181
+ <Show when={props.footer}>
182
+ {(footer) => (
183
+ <tfoot class="sticky bottom-0 z-10 bg-zinc-50 dark:bg-zinc-950">
184
+ <tr class="border-t border-zinc-100 dark:border-zinc-800">
185
+ <For each={props.columns}>
186
+ {(col, index) => {
187
+ const value = () => footer().values?.[col.id];
188
+ return (
189
+ <td
190
+ class={`px-3 py-1.5 text-[11px] text-dimmed ${columnHoverClass(index())}`}
191
+ onMouseEnter={() => setHoveredColumnIfEnabled(index())}
192
+ >
193
+ {footer().renderCell
194
+ ? footer().renderCell!({ col, value: value(), render: defaultRender })
195
+ : defaultRender(value())}
196
+ </td>
197
+ );
198
+ }}
199
+ </For>
200
+ </tr>
201
+ </tfoot>
202
+ )}
203
+ </Show>
204
+ <tbody>
205
+ <Show
206
+ when={props.rows.length > 0}
207
+ fallback={
208
+ <tr>
209
+ <td class="p-0" colspan={props.columns.length}>
210
+ <Placeholder>{props.empty ?? "No records"}</Placeholder>
211
+ </td>
212
+ </tr>
213
+ }
214
+ >
215
+ <For each={props.rows}>
216
+ {(row) => {
217
+ const id = () => rowId(row);
218
+ const isSelected = () => props.selectedRowId && id() === props.selectedRowId;
219
+ return (
220
+ <tr
221
+ class={`border-b border-zinc-100 dark:border-zinc-800/60 last:border-0 ${
222
+ shouldHoverRows() ? `${isInteractive() ? "cursor-pointer" : ""} hover:bg-blue-500/[0.08] dark:hover:bg-blue-400/[0.12]` : ""
223
+ } ${isSelected() ? "bg-blue-50 dark:bg-blue-900/20" : ""} ${rowClass(row)}`}
224
+ tabIndex={isInteractive() ? 0 : undefined}
225
+ onClick={() => props.onRowClick?.(row)}
226
+ onDblClick={() => props.onRowDoubleClick?.(row)}
227
+ onKeyDown={(e) => onRowKeyDown(e, row)}
228
+ >
229
+ <For each={props.columns}>
230
+ {(col, index) => {
231
+ const value = () => valueOf(row, col);
232
+ return (
233
+ <td
234
+ class={`${cellPadding()} ${cellVerticalAlignClass()} max-w-[260px] ${columnHoverClass(index())} ${col.cellClass ?? ""} ${col.class ?? ""}`}
235
+ onMouseEnter={() => setHoveredColumnIfEnabled(index())}
236
+ >
237
+ <div class={cellContentClass()}>
238
+ {props.renderCell
239
+ ? props.renderCell({
240
+ row,
241
+ col,
242
+ value: value(),
243
+ render: (v) => renderCellDefault(row, { ...col, value: () => v }),
244
+ })
245
+ : defaultRender(value())}
246
+ </div>
247
+ </td>
248
+ );
249
+ }}
250
+ </For>
251
+ </tr>
252
+ );
253
+ }}
254
+ </For>
255
+ <Show when={props.fillHeight}>
256
+ <tr aria-hidden="true">
257
+ <td class="h-full p-0" colspan={props.columns.length} />
258
+ </tr>
259
+ </Show>
260
+ </Show>
261
+ </tbody>
262
+ </table>
263
+ <Show when={shouldRenderLoadMoreSentinel()}>
264
+ <div ref={loadMoreRef} class="h-1" aria-hidden="true" />
265
+ </Show>
266
+ </div>
267
+ </Show>
268
+ );
269
+ }