@toolpath/ui 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 (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/dist/index.d.ts +1174 -0
  4. package/dist/index.js +5934 -0
  5. package/package.json +68 -0
  6. package/src/attribution/action.tsx +13 -0
  7. package/src/attribution/at.tsx +28 -0
  8. package/src/attribution/attribution.tsx +16 -0
  9. package/src/attribution/by.tsx +21 -0
  10. package/src/attribution/format-time.ts +48 -0
  11. package/src/attribution/index.ts +20 -0
  12. package/src/badge/badge.tsx +38 -0
  13. package/src/badge/index.ts +1 -0
  14. package/src/breadcrumbs/breadcrumbs-context.tsx +26 -0
  15. package/src/breadcrumbs/breadcrumbs.tsx +24 -0
  16. package/src/breadcrumbs/index.ts +12 -0
  17. package/src/breadcrumbs/item.tsx +85 -0
  18. package/src/button/animated-ellipsis.tsx +44 -0
  19. package/src/button/button.tsx +168 -0
  20. package/src/button/index.ts +1 -0
  21. package/src/callout/callout.tsx +202 -0
  22. package/src/callout/index.ts +2 -0
  23. package/src/callout/use-dismissed-callouts.ts +78 -0
  24. package/src/card/card.tsx +19 -0
  25. package/src/card/index.ts +2 -0
  26. package/src/checkbox/checkbox.tsx +76 -0
  27. package/src/checkbox/index.ts +1 -0
  28. package/src/combobox/button.tsx +99 -0
  29. package/src/combobox/combobox-context.tsx +42 -0
  30. package/src/combobox/combobox.tsx +60 -0
  31. package/src/combobox/empty.tsx +21 -0
  32. package/src/combobox/group-label.tsx +20 -0
  33. package/src/combobox/group.tsx +11 -0
  34. package/src/combobox/icon.tsx +11 -0
  35. package/src/combobox/index.tsx +64 -0
  36. package/src/combobox/input.tsx +77 -0
  37. package/src/combobox/item-indicator.tsx +16 -0
  38. package/src/combobox/item.tsx +58 -0
  39. package/src/combobox/list.tsx +19 -0
  40. package/src/combobox/popover.tsx +62 -0
  41. package/src/combobox/trigger.tsx +33 -0
  42. package/src/combobox/value.tsx +12 -0
  43. package/src/common/index.ts +1 -0
  44. package/src/common/svg.tsx +17 -0
  45. package/src/dialog/actions.tsx +17 -0
  46. package/src/dialog/confirm-button.tsx +25 -0
  47. package/src/dialog/dialog-context.tsx +21 -0
  48. package/src/dialog/dialog.tsx +37 -0
  49. package/src/dialog/dismiss-button.tsx +29 -0
  50. package/src/dialog/index.ts +37 -0
  51. package/src/dialog/message.tsx +20 -0
  52. package/src/dialog/provider.tsx +102 -0
  53. package/src/dialog/title.tsx +20 -0
  54. package/src/editable-cell/editable-cell.tsx +307 -0
  55. package/src/editable-cell/index.ts +7 -0
  56. package/src/editable-cell/use-editable-grid.tsx +296 -0
  57. package/src/field/field.tsx +22 -0
  58. package/src/field/index.ts +2 -0
  59. package/src/helpers/cn.ts +6 -0
  60. package/src/helpers/index.ts +1 -0
  61. package/src/icon-button/icon-button.tsx +126 -0
  62. package/src/icon-button/index.ts +2 -0
  63. package/src/index.ts +32 -0
  64. package/src/input/index.ts +1 -0
  65. package/src/input/input.tsx +276 -0
  66. package/src/link/index.ts +4 -0
  67. package/src/link/link-context.tsx +33 -0
  68. package/src/link/link.tsx +24 -0
  69. package/src/menu/divider.tsx +17 -0
  70. package/src/menu/index.ts +40 -0
  71. package/src/menu/item.tsx +149 -0
  72. package/src/menu/menu-context.tsx +24 -0
  73. package/src/menu/menu.tsx +14 -0
  74. package/src/menu/popover.tsx +69 -0
  75. package/src/menu/submenu-trigger.tsx +48 -0
  76. package/src/menu/submenu.tsx +20 -0
  77. package/src/menu/trigger.tsx +43 -0
  78. package/src/notification/index.ts +16 -0
  79. package/src/notification/list.tsx +123 -0
  80. package/src/notification/provider.tsx +75 -0
  81. package/src/page-header/index.ts +2 -0
  82. package/src/page-header/page-header.tsx +14 -0
  83. package/src/pagination/index.ts +1 -0
  84. package/src/pagination/pagination.tsx +136 -0
  85. package/src/panels/collapsed.tsx +32 -0
  86. package/src/panels/group.tsx +27 -0
  87. package/src/panels/index.ts +57 -0
  88. package/src/panels/layout-context.ts +5 -0
  89. package/src/panels/orientation-context.tsx +5 -0
  90. package/src/panels/panel.tsx +95 -0
  91. package/src/panels/separator.tsx +44 -0
  92. package/src/pin/index.ts +1 -0
  93. package/src/pin/pin.tsx +99 -0
  94. package/src/progress-bar/index.ts +1 -0
  95. package/src/progress-bar/progress-bar.tsx +48 -0
  96. package/src/quantity-input/index.ts +1 -0
  97. package/src/quantity-input/quantity-input.tsx +158 -0
  98. package/src/scroll-area/index.ts +1 -0
  99. package/src/scroll-area/scroll-area.tsx +82 -0
  100. package/src/slider/index.ts +1 -0
  101. package/src/slider/slider.tsx +75 -0
  102. package/src/star/index.ts +1 -0
  103. package/src/star/star.tsx +99 -0
  104. package/src/switch/index.ts +1 -0
  105. package/src/switch/switch.tsx +33 -0
  106. package/src/table/body.tsx +15 -0
  107. package/src/table/cell-context.tsx +48 -0
  108. package/src/table/cell.tsx +83 -0
  109. package/src/table/group-empty-state.tsx +28 -0
  110. package/src/table/group-header.tsx +62 -0
  111. package/src/table/header-cell.tsx +158 -0
  112. package/src/table/header-row.tsx +28 -0
  113. package/src/table/index.ts +56 -0
  114. package/src/table/is-group-empty-state.ts +17 -0
  115. package/src/table/is-group-header.ts +5 -0
  116. package/src/table/row-context.tsx +61 -0
  117. package/src/table/row-disabled-context.tsx +7 -0
  118. package/src/table/row.tsx +98 -0
  119. package/src/table/table-context.tsx +127 -0
  120. package/src/table/table-empty.tsx +57 -0
  121. package/src/table/table.tsx +547 -0
  122. package/src/table/tree-icon.tsx +147 -0
  123. package/src/table/use-checkbox.ts +200 -0
  124. package/src/table/use-column-layout.ts +117 -0
  125. package/src/table/use-groups.ts +134 -0
  126. package/src/table/use-keyboard-navigation.ts +274 -0
  127. package/src/table/use-sort-functions.ts +90 -0
  128. package/src/tabs/index.ts +20 -0
  129. package/src/tabs/list.tsx +29 -0
  130. package/src/tabs/tab.tsx +141 -0
  131. package/src/tabs/tabs-context.tsx +38 -0
  132. package/src/tabs/tabs.tsx +27 -0
  133. package/src/textarea/index.ts +1 -0
  134. package/src/textarea/textarea.tsx +88 -0
  135. package/src/toast/index.ts +19 -0
  136. package/src/toast/list.tsx +133 -0
  137. package/src/toast/provider.tsx +36 -0
  138. package/src/toggle/index.ts +17 -0
  139. package/src/toggle/indicator.tsx +27 -0
  140. package/src/toggle/item.tsx +54 -0
  141. package/src/toggle/toggle-context.tsx +68 -0
  142. package/src/toggle/toggle.tsx +124 -0
  143. package/src/toggle/use-indicator.tsx +69 -0
  144. package/src/tooltip/index.ts +1 -0
  145. package/src/tooltip/tooltip.tsx +122 -0
  146. package/theme.css +70 -0
@@ -0,0 +1,307 @@
1
+ import React, { FC, ReactNode, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
2
+ import { cn } from '../helpers'
3
+ import { ArrowBendUpLeftIcon } from '@phosphor-icons/react'
4
+ import { Input } from '../input'
5
+
6
+ export interface EditingCell {
7
+ rowId: string | number
8
+ field: string
9
+ }
10
+
11
+ export type NavigateDirection = 'next' | 'prev' | 'up' | 'down'
12
+
13
+ export interface EditableCellProps {
14
+ rowId: string | number
15
+ field: string
16
+ value: number | string | null | undefined
17
+ type?: 'text' | 'number'
18
+ editingCell: EditingCell | null
19
+ additionalCells?: Array<EditingCell>
20
+ modifierHeld?: boolean
21
+ onStartEdit?: (rowId: string | number, field: string) => void
22
+ onToggleAdditional?: (rowId: string | number, field: string) => void
23
+ onSelectThrough?: (rowId: string | number, field: string) => void
24
+ onExtendSelection?: (direction: 'up' | 'down') => void
25
+ onCommit: (rowId: string | number, field: string, rawValue: string, dirty?: boolean) => void
26
+ onNavigate?: (
27
+ rowId: string | number,
28
+ field: string,
29
+ rawValue: string,
30
+ direction: NavigateDirection,
31
+ dirty?: boolean,
32
+ ) => void
33
+ onCancel: () => void
34
+ children: ReactNode
35
+ }
36
+
37
+ /**
38
+ * Module-level store that survives full component remounts caused by
39
+ * react-table-library's Virtualized component recreating its
40
+ * innerElementType on every render, which unmounts/remounts all rows.
41
+ */
42
+ const liveEdit: {
43
+ key: string | null
44
+ value: string
45
+ selectionStart: number
46
+ selectionEnd: number
47
+ dirty: boolean
48
+ } = {
49
+ key: null,
50
+ value: '',
51
+ selectionStart: 0,
52
+ selectionEnd: 0,
53
+ dirty: false,
54
+ }
55
+
56
+ const cellKey = (rowId: string | number, field: string) => `${rowId}::${field}`
57
+
58
+ export const EditableCell: FC<EditableCellProps> = ({
59
+ rowId,
60
+ field,
61
+ value,
62
+ type = 'number',
63
+ editingCell,
64
+ additionalCells,
65
+ modifierHeld = false,
66
+ onStartEdit,
67
+ onToggleAdditional,
68
+ onSelectThrough,
69
+ onExtendSelection,
70
+ onCommit,
71
+ onNavigate,
72
+ onCancel,
73
+ children,
74
+ }) => {
75
+ const isEditing = editingCell?.rowId === rowId && editingCell?.field === field
76
+ const isAdditional = additionalCells?.some((c) => c.rowId === rowId && c.field === field) ?? false
77
+ const hasActiveEdit = editingCell !== null
78
+ const isSameColumn = editingCell?.field === field
79
+ const isCrossColumn = hasActiveEdit && !isSameColumn
80
+ const showBlocked = isCrossColumn && modifierHeld
81
+ const inputRef = useRef<HTMLInputElement>(null)
82
+ const dirtyRef = useRef(false)
83
+ const navigatingRef = useRef(false)
84
+ const unmountingRef = useRef(false)
85
+ const valueRef = useRef(value)
86
+ valueRef.current = value
87
+ const callbacksRef = useRef({
88
+ onCommit,
89
+ onNavigate,
90
+ onCancel,
91
+ onExtendSelection,
92
+ })
93
+ callbacksRef.current = { onCommit, onNavigate, onCancel, onExtendSelection }
94
+
95
+ useLayoutEffect(() => {
96
+ unmountingRef.current = false
97
+ return () => {
98
+ unmountingRef.current = true
99
+ }
100
+ }, [])
101
+
102
+ useEffect(() => {
103
+ if (!isEditing || !inputRef.current) {
104
+ return
105
+ }
106
+ const key = cellKey(rowId, field)
107
+ if (liveEdit.key === key) {
108
+ inputRef.current.value = liveEdit.value
109
+ dirtyRef.current = liveEdit.dirty
110
+ inputRef.current.focus()
111
+ inputRef.current.setSelectionRange(
112
+ Math.min(liveEdit.selectionStart, inputRef.current.value.length),
113
+ Math.min(liveEdit.selectionEnd, inputRef.current.value.length),
114
+ )
115
+ } else {
116
+ const v = valueRef.current
117
+ inputRef.current.value = v != null ? String(v) : ''
118
+ liveEdit.key = key
119
+ liveEdit.value = inputRef.current.value
120
+ liveEdit.selectionStart = 0
121
+ liveEdit.selectionEnd = inputRef.current.value.length
122
+ liveEdit.dirty = false
123
+ dirtyRef.current = false
124
+ inputRef.current.focus()
125
+ inputRef.current.select()
126
+ }
127
+ navigatingRef.current = false
128
+ }, [isEditing, rowId, field])
129
+
130
+ const getValue = useCallback(() => inputRef.current?.value ?? '', [])
131
+
132
+ const syncLive = useCallback(() => {
133
+ if (inputRef.current) {
134
+ dirtyRef.current = true
135
+ liveEdit.dirty = true
136
+ liveEdit.value = inputRef.current.value
137
+ liveEdit.selectionStart = inputRef.current.selectionStart ?? 0
138
+ liveEdit.selectionEnd = inputRef.current.selectionEnd ?? liveEdit.selectionStart
139
+ }
140
+ }, [])
141
+
142
+ const syncSelection = useCallback(() => {
143
+ if (inputRef.current) {
144
+ liveEdit.selectionStart = inputRef.current.selectionStart ?? 0
145
+ liveEdit.selectionEnd = inputRef.current.selectionEnd ?? liveEdit.selectionStart
146
+ }
147
+ }, [])
148
+
149
+ const clearLive = useCallback(() => {
150
+ liveEdit.key = null
151
+ liveEdit.value = ''
152
+ liveEdit.selectionStart = 0
153
+ liveEdit.selectionEnd = 0
154
+ liveEdit.dirty = false
155
+ }, [])
156
+
157
+ const handleKeyDown = useCallback(
158
+ (e: React.KeyboardEvent) => {
159
+ const { onCommit, onNavigate, onCancel, onExtendSelection } = callbacksRef.current
160
+ const dirty = dirtyRef.current
161
+ if (e.key === 'Enter') {
162
+ e.preventDefault()
163
+ clearLive()
164
+ onCommit(rowId, field, getValue(), dirty)
165
+ } else if (e.key === 'Escape') {
166
+ e.preventDefault()
167
+ clearLive()
168
+ onCancel()
169
+ } else if (e.key === 'Tab') {
170
+ e.preventDefault()
171
+ navigatingRef.current = true
172
+ clearLive()
173
+ onNavigate?.(rowId, field, getValue(), e.shiftKey ? 'prev' : 'next', dirty)
174
+ } else if (e.key === 'ArrowDown') {
175
+ e.preventDefault()
176
+ if (e.shiftKey && onExtendSelection) {
177
+ onExtendSelection('down')
178
+ } else {
179
+ navigatingRef.current = true
180
+ clearLive()
181
+ onNavigate?.(rowId, field, getValue(), 'down', dirty)
182
+ }
183
+ } else if (e.key === 'ArrowUp') {
184
+ e.preventDefault()
185
+ if (e.shiftKey && onExtendSelection) {
186
+ onExtendSelection('up')
187
+ } else {
188
+ navigatingRef.current = true
189
+ clearLive()
190
+ onNavigate?.(rowId, field, getValue(), 'up', dirty)
191
+ }
192
+ }
193
+ },
194
+ [rowId, field, getValue, clearLive],
195
+ )
196
+
197
+ const handleBlur = useCallback(() => {
198
+ if (navigatingRef.current || unmountingRef.current) {
199
+ return
200
+ }
201
+ clearLive()
202
+ callbacksRef.current.onCommit(rowId, field, getValue(), dirtyRef.current)
203
+ }, [rowId, field, getValue, clearLive])
204
+
205
+ const isMultiSelectKey = (e: React.MouseEvent) => e.metaKey || e.ctrlKey || e.shiftKey
206
+
207
+ const handleDisplayMouseDown = useCallback(
208
+ (e: React.MouseEvent) => {
209
+ if (isMultiSelectKey(e) && hasActiveEdit && isSameColumn) {
210
+ e.preventDefault()
211
+ e.stopPropagation()
212
+ return
213
+ }
214
+
215
+ if (hasActiveEdit && !isMultiSelectKey(e)) {
216
+ window.setTimeout(() => {
217
+ onStartEdit?.(rowId, field)
218
+ }, 0)
219
+ }
220
+ },
221
+ [field, hasActiveEdit, isSameColumn, onStartEdit, rowId],
222
+ )
223
+
224
+ const handleDisplayClick = useCallback(
225
+ (e: React.MouseEvent) => {
226
+ if (!hasActiveEdit || isCrossColumn) {
227
+ onStartEdit?.(rowId, field)
228
+ return
229
+ }
230
+ if (e.shiftKey && onSelectThrough) {
231
+ e.stopPropagation()
232
+ onSelectThrough(rowId, field)
233
+ return
234
+ }
235
+ if ((e.metaKey || e.ctrlKey) && onToggleAdditional) {
236
+ e.stopPropagation()
237
+ onToggleAdditional(rowId, field)
238
+ return
239
+ }
240
+ onStartEdit?.(rowId, field)
241
+ },
242
+ [rowId, field, hasActiveEdit, isCrossColumn, onStartEdit, onToggleAdditional, onSelectThrough],
243
+ )
244
+
245
+ if (isEditing) {
246
+ return (
247
+ <div
248
+ className="m-0 p-0 -mx-1"
249
+ onClick={(e) => e.stopPropagation()}
250
+ onMouseDown={(e) => e.stopPropagation()}
251
+ >
252
+ <Input
253
+ ref={inputRef}
254
+ name={`editable-${field}-${rowId}`}
255
+ id={`editable-${field}-${rowId}`}
256
+ size="md"
257
+ icon={ArrowBendUpLeftIcon}
258
+ iconPosition="right"
259
+ inputMode={type === 'number' ? 'numeric' : undefined}
260
+ onBlur={handleBlur}
261
+ onKeyDown={handleKeyDown}
262
+ onInput={syncLive}
263
+ onSelect={syncSelection}
264
+ className="px-0.5 focus-within:ring-1 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
265
+ />
266
+ </div>
267
+ )
268
+ }
269
+
270
+ if (isAdditional) {
271
+ return (
272
+ <div
273
+ className="rounded -mx-1 -my-0.5 px-1.5 py-1 h-6 bg-info/10 cursor-text dark:bg-info/20"
274
+ onClick={handleDisplayClick}
275
+ onMouseDown={handleDisplayMouseDown}
276
+ >
277
+ <div className="opacity-50">{children}</div>
278
+ </div>
279
+ )
280
+ }
281
+
282
+ if (!onStartEdit) {
283
+ return <div className="mx-0.5">{children}</div>
284
+ }
285
+
286
+ return (
287
+ <div
288
+ className={cn(
289
+ '-mx-1.5 h-8 flex items-center flex flex-row flex-1',
290
+ showBlocked ? 'cursor-not-allowed pointer-events-none' : 'cursor-text',
291
+ )}
292
+ onClick={handleDisplayClick}
293
+ onMouseDown={handleDisplayMouseDown}
294
+ >
295
+ <div
296
+ className={cn(
297
+ 'rounded mx-0.5 -my-0.5 px-1.5 py-1 h-6 w-full',
298
+ showBlocked
299
+ ? 'cursor-not-allowed pointer-events-none'
300
+ : 'cursor-text hover:bg-gray-50 dark:hover:bg-zinc-800',
301
+ )}
302
+ >
303
+ {children}
304
+ </div>
305
+ </div>
306
+ )
307
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ EditableCell,
3
+ type EditableCellProps,
4
+ type EditingCell,
5
+ type NavigateDirection,
6
+ } from './editable-cell'
7
+ export { useEditableGrid, type EditableGridOptions } from './use-editable-grid'
@@ -0,0 +1,296 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { EditingCell, NavigateDirection } from './editable-cell'
3
+
4
+ export interface EditableGridOptions<T> {
5
+ data: Array<T>
6
+ fields: ReadonlyArray<string>
7
+ getId: (item: T) => string | number
8
+ isFieldEditable?: (item: T, field: string) => boolean
9
+ onCommit: (rowId: string | number, field: string, rawValue: string) => void
10
+ onBatchCommit?: (
11
+ cells: Array<{ rowId: string | number; field: string }>,
12
+ rawValue: string,
13
+ ) => void
14
+ }
15
+
16
+ export const useEditableGrid = <T,>({
17
+ data,
18
+ fields,
19
+ getId,
20
+ isFieldEditable,
21
+ onCommit,
22
+ onBatchCommit,
23
+ }: EditableGridOptions<T>) => {
24
+ const [editingCell, setEditingCell] = useState<EditingCell | null>(null)
25
+ const editingCellRef = useRef(editingCell)
26
+ editingCellRef.current = editingCell
27
+ const [additionalCells, setAdditionalCells] = useState<Array<EditingCell>>([])
28
+ const additionalCellsRef = useRef(additionalCells)
29
+ additionalCellsRef.current = additionalCells
30
+ const [modifierHeld, setModifierHeld] = useState(false)
31
+
32
+ useEffect(() => {
33
+ if (!editingCell) {
34
+ setModifierHeld(false)
35
+ return
36
+ }
37
+ const isModifier = (e: KeyboardEvent) => e.shiftKey || e.ctrlKey || e.metaKey
38
+ const onDown = (e: KeyboardEvent) => {
39
+ if (isModifier(e)) {
40
+ setModifierHeld(true)
41
+ }
42
+ }
43
+ const onUp = (e: KeyboardEvent) => {
44
+ if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
45
+ setModifierHeld(false)
46
+ }
47
+ }
48
+ window.addEventListener('keydown', onDown)
49
+ window.addEventListener('keyup', onUp)
50
+ return () => {
51
+ window.removeEventListener('keydown', onDown)
52
+ window.removeEventListener('keyup', onUp)
53
+ }
54
+ }, [editingCell])
55
+
56
+ const commitAll = useCallback(
57
+ (rowId: string | number, field: string, rawValue: string) => {
58
+ const extras = additionalCellsRef.current
59
+ if (extras.length > 0 && onBatchCommit) {
60
+ onBatchCommit([{ rowId, field }, ...extras], rawValue)
61
+ } else {
62
+ onCommit(rowId, field, rawValue)
63
+ for (const cell of extras) {
64
+ onCommit(cell.rowId, cell.field, rawValue)
65
+ }
66
+ }
67
+ },
68
+ [onCommit, onBatchCommit],
69
+ )
70
+
71
+ const clearAll = useCallback(() => {
72
+ setEditingCell(null)
73
+ setAdditionalCells([])
74
+ }, [])
75
+
76
+ const handleStartEdit = useCallback((rowId: string | number, field: string) => {
77
+ setEditingCell({ rowId, field })
78
+ setAdditionalCells([])
79
+ }, [])
80
+
81
+ const handleSelectColumn = useCallback(
82
+ (field: string) => {
83
+ if (data.length === 0) {
84
+ return
85
+ }
86
+ const firstId = getId(data[0])
87
+ if (isFieldEditable && !isFieldEditable(data[0], field)) {
88
+ return
89
+ }
90
+ setEditingCell({ rowId: firstId, field })
91
+ setAdditionalCells(
92
+ data.slice(1).reduce<Array<EditingCell>>((acc, item) => {
93
+ if (!isFieldEditable || isFieldEditable(item, field)) {
94
+ acc.push({ rowId: getId(item), field })
95
+ }
96
+ return acc
97
+ }, []),
98
+ )
99
+ },
100
+ [data, getId, isFieldEditable],
101
+ )
102
+
103
+ const handleToggleAdditional = useCallback((rowId: string | number, field: string) => {
104
+ const ec = editingCellRef.current
105
+ if (!ec || field !== ec.field) {
106
+ return
107
+ }
108
+ if (rowId === ec.rowId && field === ec.field) {
109
+ return
110
+ }
111
+ setAdditionalCells((prev) => {
112
+ const exists = prev.some((c) => c.rowId === rowId && c.field === field)
113
+ if (exists) {
114
+ return prev.filter((c) => !(c.rowId === rowId && c.field === field))
115
+ }
116
+ return [...prev, { rowId, field }]
117
+ })
118
+ }, [])
119
+
120
+ const handleSelectThrough = useCallback(
121
+ (rowId: string | number, field: string) => {
122
+ const ec = editingCellRef.current
123
+ if (!ec || field !== ec.field) {
124
+ return
125
+ }
126
+ if (rowId === ec.rowId) {
127
+ return
128
+ }
129
+
130
+ const extras = additionalCellsRef.current
131
+ const anchorRowId = extras.length > 0 ? extras[extras.length - 1].rowId : ec.rowId
132
+
133
+ const anchorIdx = data.findIndex((d) => getId(d) === anchorRowId)
134
+ const targetIdx = data.findIndex((d) => getId(d) === rowId)
135
+ if (anchorIdx === -1 || targetIdx === -1) {
136
+ return
137
+ }
138
+
139
+ const lo = Math.min(anchorIdx, targetIdx)
140
+ const hi = Math.max(anchorIdx, targetIdx)
141
+
142
+ const rangeCells: Array<EditingCell> = []
143
+ for (let i = lo; i <= hi; i++) {
144
+ const id = getId(data[i])
145
+ if (id !== ec.rowId) {
146
+ rangeCells.push({ rowId: id, field })
147
+ }
148
+ }
149
+
150
+ setAdditionalCells((prev) => {
151
+ const merged = [...prev]
152
+ for (const rc of rangeCells) {
153
+ if (!merged.some((c) => c.rowId === rc.rowId && c.field === rc.field)) {
154
+ merged.push(rc)
155
+ }
156
+ }
157
+ return merged
158
+ })
159
+ },
160
+ [data, getId],
161
+ )
162
+
163
+ const handleExtendSelection = useCallback(
164
+ (direction: 'up' | 'down') => {
165
+ const ec = editingCellRef.current
166
+ if (!ec) {
167
+ return
168
+ }
169
+ const allRowIds = [ec.rowId, ...additionalCellsRef.current.map((c) => c.rowId)]
170
+ const indices = allRowIds
171
+ .map((id) => data.findIndex((d) => getId(d) === id))
172
+ .filter((i) => i !== -1)
173
+
174
+ const frontier = direction === 'down' ? Math.max(...indices) : Math.min(...indices)
175
+ const nextIdx = direction === 'down' ? frontier + 1 : frontier - 1
176
+
177
+ if (nextIdx < 0 || nextIdx >= data.length) {
178
+ return
179
+ }
180
+
181
+ const nextRowId = getId(data[nextIdx])
182
+ if (nextRowId === ec.rowId) {
183
+ return
184
+ }
185
+
186
+ setAdditionalCells((prev) => {
187
+ if (prev.some((c) => c.rowId === nextRowId && c.field === ec.field)) {
188
+ return prev
189
+ }
190
+ return [...prev, { rowId: nextRowId, field: ec.field }]
191
+ })
192
+ },
193
+ [data, getId],
194
+ )
195
+
196
+ const handleCommit = useCallback(
197
+ (rowId: string | number, field: string, rawValue: string, dirty = true) => {
198
+ if (dirty) {
199
+ commitAll(rowId, field, rawValue)
200
+ }
201
+ clearAll()
202
+ },
203
+ [commitAll, clearAll],
204
+ )
205
+
206
+ const handleCancel = useCallback(() => {
207
+ clearAll()
208
+ }, [clearAll])
209
+
210
+ const handleNavigate = useCallback(
211
+ (
212
+ rowId: string | number,
213
+ field: string,
214
+ rawValue: string,
215
+ direction: NavigateDirection,
216
+ dirty = true,
217
+ ) => {
218
+ handleCommit(rowId, field, rawValue, dirty)
219
+
220
+ const rowIndex = data.findIndex((d) => getId(d) === rowId)
221
+ const fieldIndex = fields.indexOf(field)
222
+ if (rowIndex === -1 || fieldIndex === -1) {
223
+ return
224
+ }
225
+
226
+ let nextRow = rowIndex
227
+ let nextField = fieldIndex
228
+
229
+ if (direction === 'next') {
230
+ nextField++
231
+ if (nextField >= fields.length) {
232
+ nextField = 0
233
+ nextRow++
234
+ }
235
+ } else if (direction === 'prev') {
236
+ nextField--
237
+ if (nextField < 0) {
238
+ nextField = fields.length - 1
239
+ nextRow--
240
+ }
241
+ } else if (direction === 'down') {
242
+ nextRow++
243
+ } else if (direction === 'up') {
244
+ nextRow--
245
+ }
246
+
247
+ if (nextRow < 0 || nextRow >= data.length) {
248
+ return
249
+ }
250
+
251
+ const targetItem = data[nextRow]
252
+ const targetRowId = getId(targetItem)
253
+ const targetField = fields[nextField]
254
+
255
+ if (isFieldEditable && !isFieldEditable(targetItem, targetField)) {
256
+ if (direction === 'prev') {
257
+ if (nextRow <= 0) {
258
+ return
259
+ }
260
+ setEditingCell({
261
+ rowId: getId(data[nextRow - 1]),
262
+ field: fields[fields.length - 1],
263
+ })
264
+ } else {
265
+ const nextEditableIndex = fields.findIndex(
266
+ (f, i) => i > 0 && (!isFieldEditable || isFieldEditable(targetItem, f)),
267
+ )
268
+ if (nextEditableIndex !== -1) {
269
+ setEditingCell({
270
+ rowId: targetRowId,
271
+ field: fields[nextEditableIndex],
272
+ })
273
+ }
274
+ }
275
+ return
276
+ }
277
+
278
+ setEditingCell({ rowId: targetRowId, field: targetField })
279
+ },
280
+ [data, fields, getId, handleCommit, isFieldEditable],
281
+ )
282
+
283
+ return {
284
+ editingCell,
285
+ additionalCells,
286
+ modifierHeld,
287
+ handleStartEdit,
288
+ handleSelectColumn,
289
+ handleToggleAdditional,
290
+ handleSelectThrough,
291
+ handleExtendSelection,
292
+ handleCommit,
293
+ handleCancel,
294
+ handleNavigate,
295
+ }
296
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export interface FieldProps {
4
+ title: string
5
+ description?: string
6
+ content?: ReactNode
7
+ children?: ReactNode
8
+ }
9
+
10
+ /** A labelled settings row with an optional right-aligned control. */
11
+ export const Field = ({ title, description, content, children }: FieldProps) => (
12
+ <div className="flex items-center justify-between gap-4 border-b border-gray-100 px-6 py-5 last:border-0 dark:border-zinc-800">
13
+ <div className="flex-1">
14
+ <div className="font-semibold text-gray-900 dark:text-white">{title}</div>
15
+ {description ? (
16
+ <div className="mt-1 text-sm text-gray-400 dark:text-zinc-400">{description}</div>
17
+ ) : null}
18
+ {content}
19
+ </div>
20
+ <div className="shrink-0">{children}</div>
21
+ </div>
22
+ )
@@ -0,0 +1,2 @@
1
+ export { Field } from './field'
2
+ export type { FieldProps } from './field'
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export const cn = (...inputs: Array<ClassValue>) => {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1 @@
1
+ export * from './cn'