create-grist-widget 0.3.7 → 0.3.9

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 (32) hide show
  1. package/README.md +79 -7
  2. package/bin/create-grist-widget.mjs +485 -90
  3. package/examples/attachment-gallery/WidgetApp.tsx +202 -0
  4. package/examples/bulk-mark-done/WidgetApp.tsx +43 -0
  5. package/examples/declared-columns/WidgetApp.tsx +42 -0
  6. package/examples/form-edit/WidgetApp.tsx +225 -0
  7. package/examples/hello-world/WidgetApp.tsx +15 -0
  8. package/examples/mark-done/WidgetApp.tsx +44 -0
  9. package/examples/reads-demo/WidgetApp.tsx +69 -0
  10. package/examples/rest-fetch/WidgetApp.tsx +49 -0
  11. package/examples/safe-parse/WidgetApp.tsx +61 -0
  12. package/examples/schema-migration/WidgetApp.tsx +67 -0
  13. package/examples/schema-preview/WidgetApp.tsx +26 -0
  14. package/examples/slice-hooks/WidgetApp.tsx +47 -0
  15. package/examples/spreadsheet-grid/GristDataSheetGrid.tsx +376 -0
  16. package/examples/spreadsheet-grid/GristRdgGrid.tsx +189 -0
  17. package/examples/spreadsheet-grid/GristSchemaToolbar.tsx +293 -0
  18. package/examples/spreadsheet-grid/WidgetApp.tsx +133 -0
  19. package/examples/spreadsheet-grid/grist-rdg-choice-column.tsx +616 -0
  20. package/examples/spreadsheet-grid/grist-rdg-columns.ts +397 -0
  21. package/examples/task-board/WidgetApp.tsx +229 -0
  22. package/examples/theme-demo/WidgetApp.tsx +26 -0
  23. package/examples/widget-options/WidgetApp.tsx +34 -0
  24. package/examples/writes-demo/WidgetApp.tsx +75 -0
  25. package/manifest.json +304 -0
  26. package/package.json +14 -2
  27. package/src/git-gate.mjs +30 -0
  28. package/template/scripts/deploy.mjs +35 -1
  29. package/ui-source/ShadcnDataSheetGrid.css +117 -0
  30. package/ui-source/ShadcnDataSheetGrid.tsx +46 -0
  31. package/ui-source/badge.tsx +49 -0
  32. package/vendor/compose-example.mjs +252 -0
@@ -0,0 +1,397 @@
1
+ import React, { memo } from "react"
2
+ import {
3
+ type CellComponent,
4
+ type CellProps,
5
+ createTextColumn,
6
+ keyColumn,
7
+ textColumn,
8
+ type Column,
9
+ } from "react-datasheet-grid"
10
+
11
+ import {
12
+ encodeGristValue,
13
+ normalizeGristChoiceListEntries,
14
+ resolveGristFormulaKind,
15
+ type GristReplicaColumn,
16
+ } from "grist-widget-sdk"
17
+
18
+ import {
19
+ createGristChoiceColumn,
20
+ createGristChoiceListColumn,
21
+ createGristRefListColumn,
22
+ } from "./grist-rdg-choice-column"
23
+
24
+ /**
25
+ * Decoded row shape exposed to react-datasheet-grid. `useGrist().records`
26
+ * always carries a numeric `id` (it's the Grist row id), and the SDK already
27
+ * decodes cell values via `keepEncoded: false`.
28
+ */
29
+ export type GristRdgRow = Record<string, unknown> & { id?: number }
30
+
31
+ /**
32
+ * Grist column types we let the schema toolbar create / change to.
33
+ *
34
+ * Refs / RefLists are intentionally excluded: they require a target table id
35
+ * and a UX flow we don't want in the minimal demo.
36
+ */
37
+ export const GRIST_TOOLBAR_COLUMN_TYPES = [
38
+ "Text",
39
+ "Numeric",
40
+ "Int",
41
+ "Bool",
42
+ "Date",
43
+ "DateTime",
44
+ "Choice",
45
+ "Any",
46
+ ] as const
47
+
48
+ export type GristToolbarColumnType = (typeof GRIST_TOOLBAR_COLUMN_TYPES)[number]
49
+
50
+ function looksLikeDateTime(type: string | undefined): boolean {
51
+ return (
52
+ type === "DateTime" ||
53
+ (typeof type === "string" && type.startsWith("DateTime:"))
54
+ )
55
+ }
56
+
57
+ function looksLikeComplex(type: string | undefined): boolean {
58
+ if (!type) return false
59
+ return type === "Attachments"
60
+ }
61
+
62
+ function looksLikeRef(type: string | undefined): boolean {
63
+ return typeof type === "string" && type.startsWith("Ref:")
64
+ }
65
+
66
+ function looksLikeRefList(type: string | undefined): boolean {
67
+ return typeof type === "string" && type.startsWith("RefList:")
68
+ }
69
+
70
+ function isGristHelperColumnId(colId: string): boolean {
71
+ return colId.startsWith("gristHelper_")
72
+ }
73
+
74
+ /** True for `computed` and `trigger` formulas — they cannot be written to. */
75
+ export function isFormulaColumn(col: GristReplicaColumn | undefined): boolean {
76
+ if (!col) return false
77
+ const kind = resolveGristFormulaKind(col)
78
+ return kind === "computed" || kind === "trigger"
79
+ }
80
+
81
+ /** True for non-scalar column types we display as read-only JSON. */
82
+ export function isComplexColumn(col: GristReplicaColumn | undefined): boolean {
83
+ return looksLikeComplex(col?.type)
84
+ }
85
+
86
+ function isDetailedRefValue(
87
+ v: unknown
88
+ ): v is { rowId: number; display?: string | null } {
89
+ if (!v || typeof v !== "object") return false
90
+ const rowId = (v as { rowId?: unknown }).rowId
91
+ return typeof rowId === "number"
92
+ }
93
+
94
+ function chipStyle(ok: boolean): Record<string, string | number> {
95
+ if (ok) {
96
+ return {
97
+ border: "1px solid #86efac",
98
+ backgroundColor: "#f0fdf4",
99
+ color: "#166534",
100
+ }
101
+ }
102
+ return {
103
+ border: "1px solid #dc2626",
104
+ backgroundColor: "#fef2f2",
105
+ color: "#991b1b",
106
+ }
107
+ }
108
+
109
+ const RefChipCell = memo(function RefChipCell({
110
+ rowData,
111
+ }: CellProps<unknown, unknown>) {
112
+ let label = ""
113
+ let ok = false
114
+ if (isDetailedRefValue(rowData)) {
115
+ const display =
116
+ typeof rowData.display === "string" ? rowData.display.trim() : ""
117
+ label = display !== "" ? display : String(rowData.rowId)
118
+ ok = display !== ""
119
+ } else if (typeof rowData === "number") {
120
+ label = String(rowData)
121
+ ok = false
122
+ } else if (rowData != null) {
123
+ label = String(rowData)
124
+ ok = false
125
+ }
126
+
127
+ const children = label
128
+ ? React.createElement(
129
+ "span",
130
+ {
131
+ style: {
132
+ borderRadius: 10,
133
+ padding: "1px 6px",
134
+ lineHeight: 1.4,
135
+ ...chipStyle(ok),
136
+ },
137
+ },
138
+ label
139
+ )
140
+ : null
141
+ return React.createElement(
142
+ "div",
143
+ {
144
+ className: "dsg-input",
145
+ style: { pointerEvents: "none", minHeight: 22, padding: "2px 6px" },
146
+ },
147
+ children
148
+ )
149
+ })
150
+
151
+ RefChipCell.displayName = "RefChipCell"
152
+
153
+ function refChipColumn(): Partial<Column<unknown, unknown, unknown>> {
154
+ return {
155
+ ...(textColumn as unknown as Partial<Column<unknown, unknown, unknown>>),
156
+ component: RefChipCell as unknown as CellComponent<unknown, unknown>,
157
+ disabled: true,
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Build the set of writable column ids (we exclude formula and complex
163
+ * columns from row writes — Grist would reject formula writes, and complex
164
+ * columns need their own UX).
165
+ */
166
+ export function writableColIds(
167
+ colIds: readonly string[],
168
+ schemaColumns: Record<string, GristReplicaColumn>
169
+ ): Set<string> {
170
+ const out = new Set<string>()
171
+ for (const colId of colIds) {
172
+ if (colId === "id") continue
173
+ if (colId === "manualSort") continue
174
+ if (isGristHelperColumnId(colId)) continue
175
+ const col = schemaColumns[colId]
176
+ if (isFormulaColumn(col)) continue
177
+ if (isComplexColumn(col)) continue
178
+ out.add(colId)
179
+ }
180
+ return out
181
+ }
182
+
183
+ function manualSortKey(row: GristRdgRow): number | null {
184
+ const v = row.manualSort
185
+ if (typeof v === "number" && !Number.isNaN(v)) return v
186
+ if (typeof v === "string" && v.trim() !== "") {
187
+ const n = Number(v)
188
+ if (!Number.isNaN(n)) return n
189
+ }
190
+ return null
191
+ }
192
+
193
+ /**
194
+ * Row order for Grist tables: `manualSort` ascending when present on any row,
195
+ * otherwise numeric `id` (document order fallback).
196
+ */
197
+ export function sortRowsByGristOrder(
198
+ rows: readonly GristRdgRow[]
199
+ ): GristRdgRow[] {
200
+ const hasManual = rows.some((r) => manualSortKey(r) !== null)
201
+ if (!hasManual) {
202
+ return [...rows].sort((a, b) => {
203
+ const ida = typeof a.id === "number" ? a.id : 0
204
+ const idb = typeof b.id === "number" ? b.id : 0
205
+ return ida - idb
206
+ })
207
+ }
208
+ return [...rows].sort((a, b) => {
209
+ const ma = manualSortKey(a)
210
+ const mb = manualSortKey(b)
211
+ if (ma !== null && mb !== null && ma !== mb) return ma - mb
212
+ if (ma !== null && mb === null) return -1
213
+ if (ma === null && mb !== null) return 1
214
+ const ida = typeof a.id === "number" ? a.id : 0
215
+ const idb = typeof b.id === "number" ? b.id : 0
216
+ return ida - idb
217
+ })
218
+ }
219
+
220
+ /**
221
+ * Encode the writable subset of a row's fields back into Grist wire form.
222
+ * Used for `table.create` / `table.update` payloads.
223
+ */
224
+ export function encodeRowFields(
225
+ row: Record<string, unknown>,
226
+ schemaColumns: Record<string, GristReplicaColumn>,
227
+ writable: ReadonlySet<string>
228
+ ): Record<string, unknown> {
229
+ const out: Record<string, unknown> = {}
230
+ for (const colId of writable) {
231
+ const value = row[colId]
232
+ if (value === undefined) continue
233
+ const type = schemaColumns[colId]?.type
234
+ const encodedInput =
235
+ typeof type === "string" &&
236
+ type.startsWith("RefList:") &&
237
+ value &&
238
+ typeof value === "object" &&
239
+ Array.isArray((value as { rowIds?: unknown }).rowIds)
240
+ ? (value as { rowIds: number[] }).rowIds
241
+ : value
242
+ out[colId] = encodeGristValue(encodedInput, schemaColumns[colId])
243
+ }
244
+ return out
245
+ }
246
+
247
+ /** Read-only "complex" cell — JSON-stringified copy, no edit / paste. */
248
+ function complexDisplayColumn(): Partial<Column<unknown, unknown, string>> {
249
+ return {
250
+ ...(textColumn as Partial<Column<unknown, unknown, string>>),
251
+ copyValue: ({ rowData }) =>
252
+ rowData == null
253
+ ? null
254
+ : typeof rowData === "string"
255
+ ? rowData
256
+ : JSON.stringify(rowData),
257
+ pasteValue: ({ rowData }) => rowData,
258
+ deleteValue: ({ rowData }) => rowData,
259
+ }
260
+ }
261
+
262
+ /**
263
+ * DateTime variant of `textColumn` — values stay as `Date` objects (the SDK
264
+ * decodes Grist DateTime cells to JS `Date`), and we display / parse ISO 8601.
265
+ *
266
+ * Native HTML `datetime-local` would be nicer but it's a bigger refactor; ISO
267
+ * text is the lowest-friction path for the minimal widget.
268
+ */
269
+ function dateTimeTextColumn() {
270
+ return createTextColumn<Date | null>({
271
+ placeholder: "YYYY-MM-DDTHH:mm:ssZ",
272
+ parseUserInput: (raw) => {
273
+ const s = raw.trim()
274
+ if (!s) return null
275
+ const d = new Date(s)
276
+ return Number.isNaN(d.getTime()) ? null : d
277
+ },
278
+ parsePastedValue: (raw) => {
279
+ const s = raw.trim()
280
+ if (!s) return null
281
+ const d = new Date(s)
282
+ return Number.isNaN(d.getTime()) ? null : d
283
+ },
284
+ formatBlurredInput: (value) =>
285
+ value instanceof Date ? value.toISOString() : "",
286
+ formatInputOnFocus: (value) =>
287
+ value instanceof Date ? value.toISOString() : "",
288
+ formatForCopy: (value) =>
289
+ value instanceof Date ? value.toISOString() : "",
290
+ })
291
+ }
292
+
293
+ /**
294
+ * Date variant that is resilient to malformed row values.
295
+ *
296
+ * RDG's built-in `dateColumn` assumes the cell value is always a `Date` and can
297
+ * throw when a bad value leaks through (e.g. from malformed source data).
298
+ * This column accepts Date/string/null and normalizes safely.
299
+ */
300
+ function safeDateTextColumn() {
301
+ return createTextColumn<Date | string | null>({
302
+ placeholder: "YYYY-MM-DD",
303
+ parseUserInput: (raw) => {
304
+ const s = raw.trim()
305
+ if (!s) return null
306
+ const d = new Date(s)
307
+ return Number.isNaN(d.getTime()) ? null : d
308
+ },
309
+ parsePastedValue: (raw) => {
310
+ const s = raw.trim()
311
+ if (!s) return null
312
+ const d = new Date(s)
313
+ return Number.isNaN(d.getTime()) ? null : d
314
+ },
315
+ formatBlurredInput: (value) => {
316
+ if (value == null) return ""
317
+ if (value instanceof Date) return value.toISOString().slice(0, 10)
318
+ if (typeof value === "string") return value
319
+ return ""
320
+ },
321
+ formatInputOnFocus: (value) => {
322
+ if (value == null) return ""
323
+ if (value instanceof Date) return value.toISOString().slice(0, 10)
324
+ if (typeof value === "string") return value
325
+ return ""
326
+ },
327
+ formatForCopy: (value) => {
328
+ if (value == null) return ""
329
+ if (value instanceof Date) return value.toISOString().slice(0, 10)
330
+ if (typeof value === "string") return value
331
+ return ""
332
+ },
333
+ })
334
+ }
335
+
336
+ /**
337
+ * Pick the right RDG column variant for a Grist column.
338
+ *
339
+ * The returned column is wrapped in `keyColumn(colId, ...)` so RDG reads /
340
+ * writes the right key on the row object, and is marked `disabled` for
341
+ * formula / complex / id columns.
342
+ */
343
+ export function gristColumnToRdgColumn(
344
+ colId: string,
345
+ col: GristReplicaColumn | undefined
346
+ ): Partial<Column<GristRdgRow, unknown, unknown>> {
347
+ const label = col?.label || colId
348
+ const type = col?.type
349
+ const readOnly =
350
+ colId === "id" ||
351
+ isFormulaColumn(col) ||
352
+ isComplexColumn(col) ||
353
+ looksLikeRef(type)
354
+
355
+ let base: Partial<Column<unknown, unknown, unknown>>
356
+ if (isComplexColumn(col)) {
357
+ base = complexDisplayColumn() as Partial<Column<unknown, unknown, unknown>>
358
+ } else if (looksLikeRefList(type)) {
359
+ base = createGristRefListColumn() as Partial<
360
+ Column<unknown, unknown, unknown>
361
+ >
362
+ } else if (looksLikeRef(type)) {
363
+ base = refChipColumn() as Partial<Column<unknown, unknown, unknown>>
364
+ } else if (type === "Bool") {
365
+ base = textColumn as Partial<Column<unknown, unknown, unknown>>
366
+ } else if (type === "Int") {
367
+ base = textColumn as Partial<Column<unknown, unknown, unknown>>
368
+ } else if (type === "Numeric") {
369
+ base = textColumn as Partial<Column<unknown, unknown, unknown>>
370
+ } else if (type === "Date") {
371
+ base = safeDateTextColumn() as Partial<Column<unknown, unknown, unknown>>
372
+ } else if (looksLikeDateTime(type)) {
373
+ base = dateTimeTextColumn() as Partial<Column<unknown, unknown, unknown>>
374
+ } else if (type === "Choice") {
375
+ const choiceEntries = normalizeGristChoiceListEntries(col?.widgetOptions)
376
+ base =
377
+ choiceEntries.length > 0
378
+ ? (createGristChoiceColumn(choiceEntries) as Partial<
379
+ Column<unknown, unknown, unknown>
380
+ >)
381
+ : (textColumn as Partial<Column<unknown, unknown, unknown>>)
382
+ } else if (type === "ChoiceList") {
383
+ const choiceEntries = normalizeGristChoiceListEntries(col?.widgetOptions)
384
+ base = createGristChoiceListColumn(choiceEntries) as Partial<
385
+ Column<unknown, unknown, unknown>
386
+ >
387
+ } else {
388
+ base = textColumn as Partial<Column<unknown, unknown, unknown>>
389
+ }
390
+
391
+ return {
392
+ ...keyColumn(colId, base as Partial<Column<unknown, unknown, unknown>>),
393
+ id: colId,
394
+ title: label,
395
+ disabled: readOnly,
396
+ } as Partial<Column<GristRdgRow, unknown, unknown>>
397
+ }
@@ -0,0 +1,229 @@
1
+ import { useMemo, useState, type DragEvent } from "react"
2
+
3
+ import {
4
+ useGrist,
5
+ useWidgetMetadata,
6
+ type UseGristOptions,
7
+ } from "grist-widget-sdk"
8
+
9
+ import { Badge } from "@/components/ui/badge"
10
+ import { Card, CardContent } from "@/components/ui/card"
11
+
12
+ export const GRIST_OPTIONS: UseGristOptions = {
13
+ requiredAccess: "full",
14
+ allowSelectBy: true,
15
+ columns: [
16
+ { name: "Title", type: "Text" },
17
+ { name: "Status", type: "Text" },
18
+ { name: "Priority", type: "Text", optional: true },
19
+ ],
20
+ }
21
+
22
+ export const WIDGET_METADATA = {
23
+ title: "Task board",
24
+ description:
25
+ "Mini Kanban board grouped by Status. Drag a card to update Status; click to focus the row in linked sections.",
26
+ } as const
27
+
28
+ const DEFAULT_STATUSES = ["To do", "Doing", "Done"] as const
29
+
30
+ type Task = {
31
+ id: number
32
+ title: string
33
+ status: string
34
+ priority: string | null
35
+ }
36
+
37
+ function pickStatuses(rows: readonly Task[]): readonly string[] {
38
+ const seen = new Set<string>(DEFAULT_STATUSES)
39
+ for (const r of rows) {
40
+ if (r.status && !seen.has(r.status)) seen.add(r.status)
41
+ }
42
+ return [...seen]
43
+ }
44
+
45
+ function asString(value: unknown): string {
46
+ if (typeof value === "string") return value
47
+ if (value == null) return ""
48
+ return String(value)
49
+ }
50
+
51
+ export function WidgetApp() {
52
+ useWidgetMetadata(WIDGET_METADATA)
53
+ const w = useGrist()
54
+
55
+ // Resolve the physical column ids once per render. We can't call mapBack on
56
+ // every row record because mapBack is for a single record write; here we
57
+ // want to project the rows from raw `records` into a typed shape.
58
+ const titleColId = w.resolveMappedColumnId("Title")
59
+ const statusColId = w.resolveMappedColumnId("Status")
60
+ const priorityColId = w.resolveMappedColumnId("Priority")
61
+
62
+ const tasks = useMemo<Task[]>(() => {
63
+ if (
64
+ !w.columnMappingStatus.ok ||
65
+ !titleColId ||
66
+ !statusColId ||
67
+ !w.records
68
+ ) {
69
+ return []
70
+ }
71
+ return w.records.map((row) => {
72
+ const status = asString(row[statusColId])
73
+ const priority = priorityColId ? asString(row[priorityColId]) : ""
74
+ return {
75
+ id: row.id as number,
76
+ title: asString(row[titleColId]) || "(untitled)",
77
+ status: status || "To do",
78
+ priority: priority || null,
79
+ }
80
+ })
81
+ }, [
82
+ w.records,
83
+ w.columnMappingStatus,
84
+ titleColId,
85
+ statusColId,
86
+ priorityColId,
87
+ ])
88
+
89
+ const statuses = useMemo(() => pickStatuses(tasks), [tasks])
90
+ const [dragId, setDragId] = useState<number | null>(null)
91
+
92
+ if (w.status === "booting") {
93
+ return <Empty title="Connecting to Grist…" body="" />
94
+ }
95
+
96
+ if (!w.columnMappingStatus.ok) {
97
+ return (
98
+ <Empty
99
+ title="Missing column mappings"
100
+ body="Open the widget configuration panel and map the columns: Title (Text), Status (Text), Priority (Text, optional)."
101
+ />
102
+ )
103
+ }
104
+
105
+ if (tasks.length === 0) {
106
+ return (
107
+ <Empty
108
+ title="No rows to show"
109
+ body="Add rows to the selected table — they will appear grouped by Status."
110
+ />
111
+ )
112
+ }
113
+
114
+ async function moveToStatus(task: Task, nextStatus: string) {
115
+ if (task.status === nextStatus) return
116
+ try {
117
+ await w.table.update({
118
+ id: task.id,
119
+ fields: w.mapBack({ Status: nextStatus }),
120
+ })
121
+ } catch {
122
+ // The card will snap back via the next `records` tick when the
123
+ // optimistic update is rejected by Grist.
124
+ }
125
+ }
126
+
127
+ return (
128
+ <div className="flex h-svh min-h-0 flex-col gap-3 p-4">
129
+ <header className="flex items-baseline justify-between">
130
+ <h1 className="text-lg font-medium">Task board</h1>
131
+ <span className="text-xs text-muted-foreground">
132
+ {tasks.length} task{tasks.length === 1 ? "" : "s"}
133
+ </span>
134
+ </header>
135
+
136
+ <div className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-auto sm:grid-cols-2 lg:grid-cols-3">
137
+ {statuses.map((status) => {
138
+ const inStatus = tasks.filter((c) => c.status === status)
139
+ return (
140
+ <section
141
+ key={status}
142
+ className="flex min-h-32 flex-col gap-2 rounded-lg border bg-muted/40 p-3"
143
+ onDragOver={(e) => {
144
+ e.preventDefault()
145
+ e.dataTransfer.dropEffect = "move"
146
+ }}
147
+ onDrop={(e) => {
148
+ e.preventDefault()
149
+ if (dragId == null) return
150
+ const dragged = tasks.find((c) => c.id === dragId)
151
+ setDragId(null)
152
+ if (dragged) void moveToStatus(dragged, status)
153
+ }}
154
+ >
155
+ <header className="flex items-center justify-between">
156
+ <h2 className="text-sm font-semibold">{status}</h2>
157
+ <Badge variant="secondary">{inStatus.length}</Badge>
158
+ </header>
159
+ <div className="flex flex-col gap-2">
160
+ {inStatus.map((task) => (
161
+ <TaskCard
162
+ key={task.id}
163
+ task={task}
164
+ isSelected={w.record?.id === task.id}
165
+ onClick={() => void w.setCursorPosition({ rowId: task.id })}
166
+ onDragStart={() => setDragId(task.id)}
167
+ onDragEnd={() => setDragId(null)}
168
+ />
169
+ ))}
170
+ {inStatus.length === 0 ? (
171
+ <p className="text-xs text-muted-foreground">No tasks.</p>
172
+ ) : null}
173
+ </div>
174
+ </section>
175
+ )
176
+ })}
177
+ </div>
178
+ </div>
179
+ )
180
+ }
181
+
182
+ function TaskCard({
183
+ task,
184
+ isSelected,
185
+ onClick,
186
+ onDragStart,
187
+ onDragEnd,
188
+ }: {
189
+ task: Task
190
+ isSelected: boolean
191
+ onClick: () => void
192
+ onDragStart: (e: DragEvent<HTMLDivElement>) => void
193
+ onDragEnd: (e: DragEvent<HTMLDivElement>) => void
194
+ }) {
195
+ return (
196
+ <Card
197
+ draggable
198
+ onClick={onClick}
199
+ onDragStart={onDragStart}
200
+ onDragEnd={onDragEnd}
201
+ className={
202
+ "cursor-grab transition-shadow hover:shadow-md " +
203
+ (isSelected ? "ring-2 ring-primary" : "")
204
+ }
205
+ >
206
+ <CardContent className="flex flex-col gap-1 p-3">
207
+ <span className="text-sm font-medium">{task.title}</span>
208
+ {task.priority ? (
209
+ <Badge variant="outline" className="self-start">
210
+ {task.priority}
211
+ </Badge>
212
+ ) : null}
213
+ </CardContent>
214
+ </Card>
215
+ )
216
+ }
217
+
218
+ function Empty({ title, body }: { title: string; body: string }) {
219
+ return (
220
+ <div className="flex h-svh items-center justify-center p-6">
221
+ <div className="max-w-sm text-center">
222
+ <h2 className="font-medium">{title}</h2>
223
+ {body ? (
224
+ <p className="mt-2 text-sm text-muted-foreground">{body}</p>
225
+ ) : null}
226
+ </div>
227
+ </div>
228
+ )
229
+ }
@@ -0,0 +1,26 @@
1
+ import {
2
+ useGrist,
3
+ useWidgetMetadata,
4
+ type UseGristOptions,
5
+ } from "grist-widget-sdk"
6
+
7
+ export const GRIST_OPTIONS: UseGristOptions = {
8
+ requiredAccess: "read table",
9
+ suppressAlerts: ["section-not-linked"],
10
+ }
11
+
12
+ export const WIDGET_METADATA = {
13
+ title: "Theme",
14
+ description: "Read w.theme from the host document.",
15
+ } as const
16
+
17
+ export function WidgetApp() {
18
+ useWidgetMetadata(WIDGET_METADATA)
19
+ const w = useGrist()
20
+
21
+ return (
22
+ <div className="p-4" data-theme={w.theme ?? "light"}>
23
+ <p className="text-sm">Host theme: {w.theme ?? "light"}</p>
24
+ </div>
25
+ )
26
+ }
@@ -0,0 +1,34 @@
1
+ import {
2
+ useGrist,
3
+ useWidgetMetadata,
4
+ type UseGristOptions,
5
+ } from "grist-widget-sdk"
6
+
7
+ import { Button } from "@/components/ui/button"
8
+
9
+ export const GRIST_OPTIONS: UseGristOptions = {
10
+ requiredAccess: "read table",
11
+ suppressAlerts: ["section-not-linked"],
12
+ }
13
+
14
+ export const WIDGET_METADATA = {
15
+ title: "Widget options",
16
+ description: "Persist UI state with setWidgetOption / widgetOptions.",
17
+ } as const
18
+
19
+ export function WidgetApp() {
20
+ useWidgetMetadata(WIDGET_METADATA)
21
+ const w = useGrist()
22
+ const compact = w.widgetOptions?.compact === true
23
+
24
+ return (
25
+ <div className="p-4">
26
+ <Button
27
+ type="button"
28
+ onClick={() => void w.setWidgetOption("compact", !compact)}
29
+ >
30
+ {compact ? "Expand" : "Compact"}
31
+ </Button>
32
+ </div>
33
+ )
34
+ }