create-grist-widget 0.3.7 → 0.3.10

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 (33) 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/template/vite.config.ts +10 -0
  30. package/ui-source/ShadcnDataSheetGrid.css +117 -0
  31. package/ui-source/ShadcnDataSheetGrid.tsx +46 -0
  32. package/ui-source/badge.tsx +49 -0
  33. package/vendor/compose-example.mjs +252 -0
@@ -0,0 +1,616 @@
1
+ import { memo, useLayoutEffect, useMemo, useRef } from "react"
2
+ import { useState } from "react"
3
+ import { X } from "lucide-react"
4
+ import type { CellComponent, CellProps, Column } from "react-datasheet-grid"
5
+ import { textColumn } from "react-datasheet-grid"
6
+
7
+ import type { GristChoiceListEntry } from "grist-widget-sdk"
8
+
9
+ export type GristRdgChoiceColumnData = { choices: GristChoiceListEntry[] }
10
+ export type GristRdgChoiceListColumnData = { choices: GristChoiceListEntry[] }
11
+ export type GristRdgRefListValue = { rowIds: number[]; displays?: string[] }
12
+
13
+ function matchPastedChoice(
14
+ raw: string,
15
+ choices: GristChoiceListEntry[]
16
+ ): string | null {
17
+ const t = raw.replace(/[\n\r]+/g, " ").trim()
18
+ if (!t) return null
19
+ const lower = t.toLowerCase()
20
+ for (const c of choices) {
21
+ if (c.value === t) return c.value
22
+ if (c.label.toLowerCase() === lower) return c.value
23
+ }
24
+ return t
25
+ }
26
+
27
+ const ChoiceSelectCell = memo(function ChoiceSelectCell({
28
+ active,
29
+ focus,
30
+ rowData,
31
+ setRowData,
32
+ stopEditing,
33
+ disabled,
34
+ columnData,
35
+ }: CellProps<string | null, GristRdgChoiceColumnData>) {
36
+ const { choices } = columnData
37
+ const selectRef = useRef<HTMLSelectElement>(null)
38
+ const current = rowData == null || rowData === "" ? "" : String(rowData)
39
+ const meta = choices.find((c) => c.value === current)
40
+
41
+ useLayoutEffect(() => {
42
+ if (focus && selectRef.current) {
43
+ selectRef.current.focus()
44
+ }
45
+ }, [focus])
46
+
47
+ if (focus && active) {
48
+ return (
49
+ <select
50
+ ref={selectRef}
51
+ className="dsg-input"
52
+ style={{ width: "100%", pointerEvents: "auto" }}
53
+ value={current}
54
+ disabled={disabled}
55
+ tabIndex={-1}
56
+ onChange={(e) => {
57
+ const v = e.target.value
58
+ setRowData(v === "" ? null : v)
59
+ stopEditing({ nextRow: false })
60
+ }}
61
+ onKeyDown={(e) => {
62
+ if (e.key === "Escape") {
63
+ e.stopPropagation()
64
+ stopEditing({ nextRow: false })
65
+ }
66
+ }}
67
+ >
68
+ <option value="" />
69
+ {choices.map((c) => (
70
+ <option key={c.value} value={c.value}>
71
+ {c.label}
72
+ </option>
73
+ ))}
74
+ </select>
75
+ )
76
+ }
77
+
78
+ return (
79
+ <div
80
+ className="dsg-input"
81
+ style={{
82
+ pointerEvents: "none",
83
+ minHeight: 22,
84
+ display: "flex",
85
+ alignItems: "center",
86
+ padding: "0 6px",
87
+ overflow: "hidden",
88
+ textOverflow: "ellipsis",
89
+ whiteSpace: "nowrap",
90
+ }}
91
+ >
92
+ {current ? (
93
+ <span
94
+ style={{
95
+ display: "inline-flex",
96
+ alignItems: "center",
97
+ backgroundColor: meta?.fillColor ?? "transparent",
98
+ color: meta?.textColor ?? "inherit",
99
+ borderRadius: 10,
100
+ padding: "1px 6px",
101
+ lineHeight: 1.4,
102
+ border: meta ? "1px solid transparent" : "1px solid #dc2626",
103
+ }}
104
+ >
105
+ {meta?.label ?? current}
106
+ </span>
107
+ ) : (
108
+ ""
109
+ )}
110
+ </div>
111
+ )
112
+ })
113
+
114
+ ChoiceSelectCell.displayName = "ChoiceSelectCell"
115
+
116
+ /**
117
+ * react-datasheet-grid column for Grist `Choice` cells (string value + optional colors).
118
+ */
119
+ export function createGristChoiceColumn(
120
+ choices: GristChoiceListEntry[]
121
+ ): Partial<Column<string | null, GristRdgChoiceColumnData, string>> {
122
+ return {
123
+ ...textColumn,
124
+ component: ChoiceSelectCell as unknown as CellComponent<string | null, GristRdgChoiceColumnData>,
125
+ columnData: { choices },
126
+ copyValue: ({ rowData }) =>
127
+ rowData == null || rowData === "" ? "" : String(rowData),
128
+ pasteValue: ({ value }) => matchPastedChoice(value, choices),
129
+ deleteValue: () => null,
130
+ isCellEmpty: ({ rowData }) =>
131
+ rowData === null || rowData === undefined || rowData === "",
132
+ }
133
+ }
134
+
135
+ function parseChoiceListInput(
136
+ raw: string,
137
+ choices: GristChoiceListEntry[]
138
+ ): string[] | null {
139
+ const tokens = raw
140
+ .split(",")
141
+ .map((part) => matchPastedChoice(part, choices))
142
+ .filter((part): part is string => Boolean(part && part.trim() !== ""))
143
+ if (tokens.length === 0) return null
144
+ return Array.from(new Set(tokens))
145
+ }
146
+
147
+ function normalizeChoiceListValue(
148
+ value: string[] | string | null | undefined,
149
+ choices: GristChoiceListEntry[]
150
+ ): string[] {
151
+ if (Array.isArray(value)) return value
152
+ if (typeof value === "string")
153
+ return parseChoiceListInput(value, choices) ?? []
154
+ return []
155
+ }
156
+
157
+ const ChoiceListCell = memo(function ChoiceListCell({
158
+ active,
159
+ focus,
160
+ rowData,
161
+ setRowData,
162
+ stopEditing,
163
+ disabled,
164
+ columnData,
165
+ }: CellProps<string[] | null, GristRdgChoiceListColumnData>) {
166
+ const { choices } = columnData
167
+ const inputRef = useRef<HTMLInputElement>(null)
168
+ const values = useMemo(
169
+ () =>
170
+ normalizeChoiceListValue(
171
+ rowData as string[] | string | null | undefined,
172
+ choices
173
+ ),
174
+ [choices, rowData]
175
+ )
176
+ const metaByValue = useMemo(
177
+ () => new Map(choices.map((c) => [c.value, c])),
178
+ [choices]
179
+ )
180
+ const [pendingText, setPendingText] = useState("")
181
+
182
+ const setValues = (next: string[]) => {
183
+ setRowData(next.length > 0 ? Array.from(new Set(next)) : null)
184
+ }
185
+
186
+ useLayoutEffect(() => {
187
+ if (focus || active) {
188
+ const id = window.setTimeout(() => inputRef.current?.focus(), 0)
189
+ return () => window.clearTimeout(id)
190
+ }
191
+ }, [focus, active])
192
+
193
+ if (focus || active) {
194
+ return (
195
+ <div
196
+ className="dsg-input"
197
+ style={{ width: "100%", pointerEvents: "auto", padding: "2px 4px" }}
198
+ onMouseDown={(e) => {
199
+ // Keep the edit interaction owned by this cell, not the grid wrapper.
200
+ e.stopPropagation()
201
+ }}
202
+ >
203
+ <div
204
+ style={{
205
+ display: "flex",
206
+ alignItems: "center",
207
+ gap: 4,
208
+ flexWrap: "wrap",
209
+ }}
210
+ >
211
+ {values.map((v, index) => {
212
+ const meta = metaByValue.get(v)
213
+ const label = meta?.label ?? v
214
+ const invalid = !meta
215
+ return (
216
+ <span
217
+ key={`${v}-${index}`}
218
+ style={{
219
+ display: "inline-flex",
220
+ alignItems: "center",
221
+ gap: 4,
222
+ backgroundColor: meta?.fillColor ?? "transparent",
223
+ color: meta?.textColor ?? "inherit",
224
+ borderRadius: 10,
225
+ padding: "1px 4px 1px 6px",
226
+ lineHeight: 1.4,
227
+ border: invalid
228
+ ? "1px solid #dc2626"
229
+ : "1px solid transparent",
230
+ }}
231
+ >
232
+ <span>{label}</span>
233
+ <button
234
+ type="button"
235
+ aria-label={`Remove ${label}`}
236
+ disabled={disabled}
237
+ onMouseDown={(e) => e.preventDefault()}
238
+ onClick={() => {
239
+ setValues(values.filter((_, i) => i !== index))
240
+ }}
241
+ style={{
242
+ border: "none",
243
+ background: "transparent",
244
+ cursor: disabled ? "not-allowed" : "pointer",
245
+ lineHeight: 1,
246
+ padding: 0,
247
+ color: "inherit",
248
+ display: "inline-flex",
249
+ alignItems: "center",
250
+ justifyContent: "center",
251
+ }}
252
+ >
253
+ <X size={10} strokeWidth={2.5} />
254
+ </button>
255
+ </span>
256
+ )
257
+ })}
258
+ <input
259
+ ref={inputRef}
260
+ autoFocus
261
+ size={Math.max(6, pendingText.length + 1)}
262
+ style={{
263
+ height: 28,
264
+ width: "auto",
265
+ minWidth: 48,
266
+ maxWidth: 260,
267
+ flex: "0 1 auto",
268
+ display: "inline-flex",
269
+ border: "none",
270
+ outline: "none",
271
+ background: "transparent",
272
+ }}
273
+ value={pendingText}
274
+ disabled={disabled}
275
+ placeholder="Type value, Enter to add"
276
+ onChange={(e) => setPendingText(e.target.value)}
277
+ onFocus={(e) => {
278
+ e.stopPropagation()
279
+ }}
280
+ onMouseDown={(e) => {
281
+ e.stopPropagation()
282
+ }}
283
+ onPointerDown={(e) => {
284
+ e.stopPropagation()
285
+ window.setTimeout(() => inputRef.current?.focus(), 0)
286
+ }}
287
+ onKeyDown={(e) => {
288
+ // Keep keyboard input inside the text field; RDG listens globally
289
+ // and can steal these events before the input consumes them.
290
+ e.stopPropagation()
291
+ if (e.key === "Enter") {
292
+ e.preventDefault()
293
+ const next = pendingText.trim()
294
+ if (next) {
295
+ setValues([...values, next])
296
+ setPendingText("")
297
+ }
298
+ stopEditing({ nextRow: false })
299
+ return
300
+ }
301
+ if (e.key === "Escape") {
302
+ e.stopPropagation()
303
+ stopEditing({ nextRow: false })
304
+ }
305
+ }}
306
+ />
307
+ </div>
308
+ </div>
309
+ )
310
+ }
311
+
312
+ return (
313
+ <div
314
+ className="dsg-input"
315
+ style={{
316
+ pointerEvents: "none",
317
+ minHeight: 22,
318
+ display: "flex",
319
+ alignItems: "center",
320
+ gap: 4,
321
+ flexWrap: "wrap",
322
+ padding: "2px 6px",
323
+ }}
324
+ >
325
+ {values.map((v, index) => {
326
+ const meta = metaByValue.get(v)
327
+ const label = meta?.label ?? v
328
+ const invalid = !meta
329
+ return (
330
+ <span
331
+ key={`${v}-${index}`}
332
+ style={{
333
+ backgroundColor: meta?.fillColor ?? "transparent",
334
+ color: meta?.textColor ?? "inherit",
335
+ borderRadius: 10,
336
+ padding: "1px 6px",
337
+ lineHeight: 1.4,
338
+ border: invalid ? "1px solid #dc2626" : "1px solid transparent",
339
+ }}
340
+ >
341
+ {label}
342
+ </span>
343
+ )
344
+ })}
345
+ </div>
346
+ )
347
+ })
348
+
349
+ ChoiceListCell.displayName = "ChoiceListCell"
350
+
351
+ export function createGristChoiceListColumn(
352
+ choices: GristChoiceListEntry[]
353
+ ): Partial<Column<string[] | null, GristRdgChoiceListColumnData, string>> {
354
+ return {
355
+ component: ChoiceListCell as unknown as CellComponent<string[] | null, GristRdgChoiceListColumnData>,
356
+ columnData: { choices },
357
+ copyValue: ({ rowData }) =>
358
+ Array.isArray(rowData) ? rowData.join(", ") : "",
359
+ pasteValue: ({ value }) => parseChoiceListInput(value, choices),
360
+ deleteValue: () => null,
361
+ isCellEmpty: ({ rowData }) =>
362
+ !Array.isArray(rowData) || rowData.length === 0,
363
+ }
364
+ }
365
+
366
+ function normalizeRefListValue(
367
+ value: GristRdgRefListValue | number[] | null | undefined
368
+ ): GristRdgRefListValue {
369
+ if (!value) return { rowIds: [], displays: [] }
370
+ if (Array.isArray(value)) {
371
+ return {
372
+ rowIds: value.filter(
373
+ (v): v is number => typeof v === "number" && Number.isFinite(v) && v > 0
374
+ ),
375
+ displays: [],
376
+ }
377
+ }
378
+ if (typeof value === "object" && Array.isArray(value.rowIds)) {
379
+ const rowIds = value.rowIds.filter(
380
+ (v): v is number => typeof v === "number" && Number.isFinite(v) && v > 0
381
+ )
382
+ const displays = Array.isArray(value.displays)
383
+ ? value.displays.map((v) => String(v))
384
+ : []
385
+ return { rowIds, displays }
386
+ }
387
+ return { rowIds: [], displays: [] }
388
+ }
389
+
390
+ const RefListCell = memo(function RefListCell({
391
+ active,
392
+ focus,
393
+ rowData,
394
+ setRowData,
395
+ stopEditing,
396
+ disabled,
397
+ }: CellProps<GristRdgRefListValue | number[] | null, unknown>) {
398
+ const inputRef = useRef<HTMLInputElement>(null)
399
+ const normalized = useMemo(
400
+ () =>
401
+ normalizeRefListValue(
402
+ rowData as GristRdgRefListValue | number[] | null | undefined
403
+ ),
404
+ [rowData]
405
+ )
406
+ const rowIds = normalized.rowIds
407
+ const displays = normalized.displays ?? []
408
+ const [pendingText, setPendingText] = useState("")
409
+
410
+ const setValues = (nextRowIds: number[], nextDisplays?: string[]) => {
411
+ const ids = Array.from(
412
+ new Set(
413
+ nextRowIds.filter(
414
+ (v) => typeof v === "number" && Number.isFinite(v) && v > 0
415
+ )
416
+ )
417
+ )
418
+ if (ids.length === 0) {
419
+ setRowData(null)
420
+ return
421
+ }
422
+ setRowData({
423
+ rowIds: ids,
424
+ displays: Array.isArray(nextDisplays) ? nextDisplays : [],
425
+ } satisfies GristRdgRefListValue)
426
+ }
427
+
428
+ useLayoutEffect(() => {
429
+ if (focus || active) {
430
+ const id = window.setTimeout(() => inputRef.current?.focus(), 0)
431
+ return () => window.clearTimeout(id)
432
+ }
433
+ }, [focus, active])
434
+
435
+ if (focus || active) {
436
+ return (
437
+ <div
438
+ className="dsg-input"
439
+ style={{ width: "100%", pointerEvents: "auto", padding: "2px 4px" }}
440
+ onMouseDown={(e) => e.stopPropagation()}
441
+ >
442
+ <div
443
+ style={{
444
+ display: "flex",
445
+ alignItems: "center",
446
+ gap: 4,
447
+ flexWrap: "wrap",
448
+ }}
449
+ >
450
+ {rowIds.map((id, index) => {
451
+ const display =
452
+ typeof displays[index] === "string" ? displays[index].trim() : ""
453
+ const label = display !== "" ? display : String(id)
454
+ const ok = display !== ""
455
+ return (
456
+ <span
457
+ key={`${id}-${index}`}
458
+ style={{
459
+ display: "inline-flex",
460
+ alignItems: "center",
461
+ gap: 4,
462
+ backgroundColor: ok ? "#f0fdf4" : "#fef2f2",
463
+ color: ok ? "#166534" : "#991b1b",
464
+ borderRadius: 10,
465
+ padding: "1px 4px 1px 6px",
466
+ lineHeight: 1.4,
467
+ border: ok ? "1px solid #86efac" : "1px solid #dc2626",
468
+ }}
469
+ >
470
+ <span>{label}</span>
471
+ <button
472
+ type="button"
473
+ aria-label={`Remove ${label}`}
474
+ disabled={disabled}
475
+ onMouseDown={(e) => e.preventDefault()}
476
+ onClick={() => {
477
+ const nextIds = rowIds.filter((_, i) => i !== index)
478
+ const nextDisplays = displays.filter((_, i) => i !== index)
479
+ setValues(nextIds, nextDisplays)
480
+ }}
481
+ style={{
482
+ border: "none",
483
+ background: "transparent",
484
+ cursor: disabled ? "not-allowed" : "pointer",
485
+ lineHeight: 1,
486
+ padding: 0,
487
+ color: "inherit",
488
+ display: "inline-flex",
489
+ alignItems: "center",
490
+ justifyContent: "center",
491
+ }}
492
+ >
493
+ <X size={10} strokeWidth={2.5} />
494
+ </button>
495
+ </span>
496
+ )
497
+ })}
498
+ <input
499
+ ref={inputRef}
500
+ autoFocus
501
+ size={Math.max(6, pendingText.length + 1)}
502
+ style={{
503
+ height: 28,
504
+ width: "auto",
505
+ minWidth: 48,
506
+ maxWidth: 220,
507
+ flex: "0 1 auto",
508
+ display: "inline-flex",
509
+ border: "none",
510
+ outline: "none",
511
+ background: "transparent",
512
+ }}
513
+ value={pendingText}
514
+ disabled={disabled}
515
+ placeholder="Type ID, Enter to add"
516
+ onChange={(e) => setPendingText(e.target.value)}
517
+ onFocus={(e) => e.stopPropagation()}
518
+ onMouseDown={(e) => e.stopPropagation()}
519
+ onPointerDown={(e) => {
520
+ e.stopPropagation()
521
+ window.setTimeout(() => inputRef.current?.focus(), 0)
522
+ }}
523
+ onKeyDown={(e) => {
524
+ e.stopPropagation()
525
+ if (e.key === "Enter") {
526
+ e.preventDefault()
527
+ const n = Number.parseInt(pendingText.trim(), 10)
528
+ if (Number.isFinite(n) && n > 0) {
529
+ setValues([...rowIds, n], displays)
530
+ setPendingText("")
531
+ }
532
+ stopEditing({ nextRow: false })
533
+ return
534
+ }
535
+ if (e.key === "Escape") {
536
+ e.stopPropagation()
537
+ stopEditing({ nextRow: false })
538
+ }
539
+ }}
540
+ />
541
+ </div>
542
+ </div>
543
+ )
544
+ }
545
+
546
+ return (
547
+ <div
548
+ className="dsg-input"
549
+ style={{
550
+ pointerEvents: "none",
551
+ minHeight: 22,
552
+ display: "flex",
553
+ alignItems: "center",
554
+ gap: 4,
555
+ flexWrap: "wrap",
556
+ padding: "2px 6px",
557
+ }}
558
+ >
559
+ {rowIds.map((id, index) => {
560
+ const display =
561
+ typeof displays[index] === "string" ? displays[index].trim() : ""
562
+ const label = display !== "" ? display : String(id)
563
+ const ok = display !== ""
564
+ return (
565
+ <span
566
+ key={`${id}-${index}`}
567
+ style={{
568
+ backgroundColor: ok ? "#f0fdf4" : "#fef2f2",
569
+ color: ok ? "#166534" : "#991b1b",
570
+ borderRadius: 10,
571
+ padding: "1px 6px",
572
+ lineHeight: 1.4,
573
+ border: ok ? "1px solid #86efac" : "1px solid #dc2626",
574
+ }}
575
+ >
576
+ {label}
577
+ </span>
578
+ )
579
+ })}
580
+ </div>
581
+ )
582
+ })
583
+
584
+ RefListCell.displayName = "RefListCell"
585
+
586
+ export function createGristRefListColumn(): Partial<
587
+ Column<GristRdgRefListValue | number[] | null, unknown, string>
588
+ > {
589
+ return {
590
+ component: RefListCell as unknown as CellComponent<GristRdgRefListValue | number[] | null, unknown>,
591
+ copyValue: ({ rowData }) => {
592
+ const normalized = normalizeRefListValue(
593
+ rowData as GristRdgRefListValue | number[] | null | undefined
594
+ )
595
+ return normalized.rowIds.join(", ")
596
+ },
597
+ pasteValue: ({ value }) => {
598
+ const ids = value
599
+ .split(",")
600
+ .map((token) => Number.parseInt(token.trim(), 10))
601
+ .filter((n) => Number.isFinite(n) && n > 0)
602
+ if (ids.length === 0) return null
603
+ return {
604
+ rowIds: Array.from(new Set(ids)),
605
+ displays: [],
606
+ } satisfies GristRdgRefListValue
607
+ },
608
+ deleteValue: () => null,
609
+ isCellEmpty: ({ rowData }) => {
610
+ const normalized = normalizeRefListValue(
611
+ rowData as GristRdgRefListValue | number[] | null | undefined
612
+ )
613
+ return normalized.rowIds.length === 0
614
+ },
615
+ }
616
+ }