learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1import { createMemo, createSignal, For, Show, splitProps } from "solid-js";
2import type { Accessor, Component, JSX } from "solid-js";
3
4export type Column<T> = {
5 key: keyof T | string;
6 header: string;
7 sortable?: boolean;
8 render?: (row: T, index: number) => JSX.Element;
9 width?: string;
10};
11
12type DataTableProps<T> = {
13 columns: Column<T>[];
14 data: T[];
15 getRowId: (row: T) => string;
16 selectable?: boolean;
17 expandable?: (row: T) => JSX.Element | null;
18 onSelectionChange?: (selectedIds: string[]) => void;
19 class?: string;
20};
21
22type SortDirection = "asc" | "desc" | null;
23
24const SortIcon: Component<{ direction: SortDirection }> = (props) => (
25 <svg class="w-4 h-4 ml-1 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor">
26 <Show when={props.direction === "asc"}>
27 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
28 </Show>
29 <Show when={props.direction === "desc"}>
30 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
31 </Show>
32 <Show when={!props.direction}>
33 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l4-4 4 4M8 15l4 4 4-4" />
34 </Show>
35 </svg>
36);
37
38export function DataTable<T>(props: DataTableProps<T>): JSX.Element {
39 const [local, _others] = splitProps(props, [
40 "columns",
41 "data",
42 "getRowId",
43 "selectable",
44 "expandable",
45 "onSelectionChange",
46 "class",
47 ]);
48
49 const [sortKey, setSortKey] = createSignal<string | null>(null);
50 const [sortDir, setSortDir] = createSignal<SortDirection>(null);
51 const [selected, setSelected] = createSignal<Set<string>>(new Set());
52 const [expanded, setExpanded] = createSignal<Set<string>>(new Set());
53
54 const sortedData: Accessor<T[]> = createMemo(() => {
55 const key = sortKey();
56 const dir = sortDir();
57 if (!key || !dir) return local.data;
58
59 return [...local.data].sort((a, b) => {
60 const aVal = (a as Record<string, unknown>)[key];
61 const bVal = (b as Record<string, unknown>)[key];
62 if (aVal === bVal) return 0;
63 if (aVal == null) return 1;
64 if (bVal == null) return -1;
65 const cmp = aVal < bVal ? -1 : 1;
66 return dir === "asc" ? cmp : -cmp;
67 });
68 });
69
70 const handleSort = (key: string) => {
71 if (sortKey() === key) {
72 setSortDir((d) => (d === "asc" ? "desc" : d === "desc" ? null : "asc"));
73 if (sortDir() === null) setSortKey(null);
74 } else {
75 setSortKey(key);
76 setSortDir("asc");
77 }
78 };
79
80 const toggleSelect = (id: string) => {
81 setSelected((prev) => {
82 const next = new Set(prev);
83 if (next.has(id)) next.delete(id);
84 else next.add(id);
85 local.onSelectionChange?.([...next]);
86 return next;
87 });
88 };
89
90 const toggleSelectAll = () => {
91 if (selected().size === local.data.length) {
92 setSelected(new Set<string>());
93 local.onSelectionChange?.([]);
94 } else {
95 const all = new Set(local.data.map(local.getRowId));
96 setSelected(all);
97 local.onSelectionChange?.([...all]);
98 }
99 };
100
101 const toggleExpand = (id: string) => {
102 setExpanded((prev) => {
103 const next = new Set(prev);
104 if (next.has(id)) next.delete(id);
105 else next.add(id);
106 return next;
107 });
108 };
109
110 const getCellValue = (row: T, col: Column<T>, index: number): JSX.Element => {
111 if (col.render) return col.render(row, index);
112 const value = (row as Record<string, unknown>)[col.key as string];
113 return <>{value != null ? String(value) : ""}</>;
114 };
115
116 return (
117 <div class={`overflow-x-auto ${local.class || ""}`}>
118 <table class="w-full text-sm text-left">
119 <thead class="text-xs text-gray-400 uppercase bg-gray-900 border-b border-gray-700">
120 <tr>
121 <Show when={local.expandable}>
122 <th class="w-8 px-2 py-3" />
123 </Show>
124 <Show when={local.selectable}>
125 <th class="w-8 px-2 py-3">
126 <input
127 type="checkbox"
128 checked={selected().size === local.data.length && local.data.length > 0}
129 onChange={toggleSelectAll}
130 class="w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500" />
131 </th>
132 </Show>
133 <For each={local.columns}>
134 {(col) => (
135 <th
136 class={`px-4 py-3 ${col.sortable ? "cursor-pointer hover:bg-gray-800 select-none" : ""}`}
137 style={{ width: col.width }}
138 onClick={() => col.sortable && handleSort(col.key as string)}>
139 <span class="flex items-center">
140 {col.header}
141 <Show when={col.sortable}>
142 <SortIcon direction={sortKey() === col.key ? sortDir() : null} />
143 </Show>
144 </span>
145 </th>
146 )}
147 </For>
148 </tr>
149 </thead>
150 <tbody>
151 <For each={sortedData()}>
152 {(row, index) => {
153 const id = local.getRowId(row);
154 const isExpanded = () => expanded().has(id);
155 const expandedContent = () => local.expandable?.(row);
156
157 return (
158 <>
159 <tr class="border-b border-gray-800 hover:bg-gray-800/50 text-gray-300">
160 <Show when={local.expandable}>
161 <td class="px-2 py-3">
162 <Show when={expandedContent()}>
163 <button
164 onClick={() => toggleExpand(id)}
165 class="p-1 hover:bg-gray-700 rounded"
166 aria-expanded={isExpanded()}
167 aria-label={isExpanded() ? "Collapse row" : "Expand row"}>
168 <svg
169 class={`w-4 h-4 transition-transform ${isExpanded() ? "rotate-90" : ""}`}
170 fill="none"
171 viewBox="0 0 24 24"
172 stroke="currentColor">
173 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
174 </svg>
175 </button>
176 </Show>
177 </td>
178 </Show>
179 <Show when={local.selectable}>
180 <td class="px-2 py-3">
181 <input
182 type="checkbox"
183 checked={selected().has(id)}
184 onChange={() => toggleSelect(id)}
185 class="w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500" />
186 </td>
187 </Show>
188 <For each={local.columns}>
189 {(col) => <td class="px-4 py-3">{getCellValue(row, col, index())}</td>}
190 </For>
191 </tr>
192 <Show when={isExpanded() && expandedContent()}>
193 <tr class="bg-gray-900/50">
194 <td
195 colSpan={local.columns.length + (local.selectable ? 1 : 0) + (local.expandable ? 1 : 0)}
196 class="px-4 py-3">
197 {expandedContent()}
198 </td>
199 </tr>
200 </Show>
201 </>
202 );
203 }}
204 </For>
205 </tbody>
206 </table>
207 </div>
208 );
209}