@alfakit/react 0.1.1 → 0.1.6

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 (219) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/components/actions/action-button.d.ts.map +1 -1
  3. package/dist/components/actions/action-button.js +5 -4
  4. package/dist/components/actions/action-button.js.map +1 -1
  5. package/dist/components/actions/action-modals.d.ts.map +1 -1
  6. package/dist/components/actions/action-modals.js +6 -5
  7. package/dist/components/actions/action-modals.js.map +1 -1
  8. package/dist/components/ai/copilot.d.ts +3 -1
  9. package/dist/components/ai/copilot.d.ts.map +1 -1
  10. package/dist/components/ai/copilot.js +4 -1
  11. package/dist/components/ai/copilot.js.map +1 -1
  12. package/dist/components/cms/live-preview.js +3 -2
  13. package/dist/components/cms/live-preview.js.map +1 -1
  14. package/dist/components/cms/page.js +1 -1
  15. package/dist/components/fields/builder.js +15 -6
  16. package/dist/components/fields/builder.js.map +1 -1
  17. package/dist/components/fields/color-picker.js +1 -0
  18. package/dist/components/fields/color-picker.js.map +1 -1
  19. package/dist/components/fields/file-upload.js +11 -10
  20. package/dist/components/fields/file-upload.js.map +1 -1
  21. package/dist/components/fields/image-editor-dialog.js +4 -2
  22. package/dist/components/fields/image-editor-dialog.js.map +1 -1
  23. package/dist/components/fields/item-actions.js +10 -10
  24. package/dist/components/fields/item-actions.js.map +1 -1
  25. package/dist/components/fields/key-value.d.ts.map +1 -1
  26. package/dist/components/fields/key-value.js +3 -3
  27. package/dist/components/fields/key-value.js.map +1 -1
  28. package/dist/components/fields/modal-table-select.js +6 -5
  29. package/dist/components/fields/modal-table-select.js.map +1 -1
  30. package/dist/components/fields/one-time-code-input.js +1 -1
  31. package/dist/components/fields/one-time-code-input.js.map +1 -1
  32. package/dist/components/fields/repeater-table.js +12 -1
  33. package/dist/components/fields/repeater-table.js.map +1 -1
  34. package/dist/components/fields/repeater.js +2 -2
  35. package/dist/components/fields/repeater.js.map +1 -1
  36. package/dist/components/fields/rich-editor.js +2 -1
  37. package/dist/components/fields/rich-editor.js.map +1 -1
  38. package/dist/components/fields/select.js +3 -3
  39. package/dist/components/fields/select.js.map +1 -1
  40. package/dist/components/file-manager/file-manager.js +1 -1
  41. package/dist/components/file-manager/file-manager.js.map +1 -1
  42. package/dist/components/form/form-block.d.ts.map +1 -1
  43. package/dist/components/form/form-block.js +14 -2
  44. package/dist/components/form/form-block.js.map +1 -1
  45. package/dist/components/form/form-context.js +7 -3
  46. package/dist/components/form/form-context.js.map +1 -1
  47. package/dist/components/kanban/kanban.js +4 -3
  48. package/dist/components/kanban/kanban.js.map +1 -1
  49. package/dist/components/layout/fused-group.js +1 -0
  50. package/dist/components/layout/fused-group.js.map +1 -1
  51. package/dist/components/layout/section.js +2 -2
  52. package/dist/components/layout/section.js.map +1 -1
  53. package/dist/components/layout/tabs.d.ts.map +1 -1
  54. package/dist/components/layout/tabs.js +54 -15
  55. package/dist/components/layout/tabs.js.map +1 -1
  56. package/dist/components/panel/app-sidebar.d.ts +2 -3
  57. package/dist/components/panel/app-sidebar.d.ts.map +1 -1
  58. package/dist/components/panel/app-sidebar.js +1 -24
  59. package/dist/components/panel/app-sidebar.js.map +1 -1
  60. package/dist/components/panel/brand.d.ts +10 -0
  61. package/dist/components/panel/brand.d.ts.map +1 -0
  62. package/dist/components/panel/brand.js +37 -0
  63. package/dist/components/panel/brand.js.map +1 -0
  64. package/dist/components/panel/global-search.js +1 -0
  65. package/dist/components/panel/global-search.js.map +1 -1
  66. package/dist/components/panel/index.d.ts +2 -1
  67. package/dist/components/panel/shortcut-keys.js +1 -0
  68. package/dist/components/panel/shortcut-keys.js.map +1 -1
  69. package/dist/components/panel/tenant-menu.js +2 -2
  70. package/dist/components/panel/tenant-menu.js.map +1 -1
  71. package/dist/components/panel/topbar.js +1 -1
  72. package/dist/components/realtime/live-records-pill.d.ts.map +1 -1
  73. package/dist/components/realtime/live-records-pill.js +6 -3
  74. package/dist/components/realtime/live-records-pill.js.map +1 -1
  75. package/dist/components/table/cells.d.ts +6 -0
  76. package/dist/components/table/cells.d.ts.map +1 -1
  77. package/dist/components/table/cells.js +15 -3
  78. package/dist/components/table/cells.js.map +1 -1
  79. package/dist/components/table/column-manager.js +8 -7
  80. package/dist/components/table/column-manager.js.map +1 -1
  81. package/dist/components/table/content-layout.js +2 -1
  82. package/dist/components/table/content-layout.js.map +1 -1
  83. package/dist/components/table/grouping.js +11 -1
  84. package/dist/components/table/grouping.js.map +1 -1
  85. package/dist/components/table/pagination.js +16 -13
  86. package/dist/components/table/pagination.js.map +1 -1
  87. package/dist/components/table/table-block.js +14 -4
  88. package/dist/components/table/table-block.js.map +1 -1
  89. package/dist/components/ui/dialog.d.ts.map +1 -1
  90. package/dist/components/ui/dialog.js +3 -1
  91. package/dist/components/ui/dialog.js.map +1 -1
  92. package/dist/components/ui/popover.d.ts.map +1 -1
  93. package/dist/components/ui/popover.js +3 -1
  94. package/dist/components/ui/popover.js.map +1 -1
  95. package/dist/components/ui/sheet.d.ts.map +1 -1
  96. package/dist/components/ui/sheet.js +3 -1
  97. package/dist/components/ui/sheet.js.map +1 -1
  98. package/dist/components/ui/tooltip.js +3 -1
  99. package/dist/components/ui/tooltip.js.map +1 -1
  100. package/dist/components.css +347 -0
  101. package/dist/context.d.ts +5 -0
  102. package/dist/context.d.ts.map +1 -1
  103. package/dist/context.js +5 -1
  104. package/dist/context.js.map +1 -1
  105. package/dist/create-alfakit.d.ts.map +1 -1
  106. package/dist/create-alfakit.js +8 -5
  107. package/dist/create-alfakit.js.map +1 -1
  108. package/dist/eject-manifest.json +16 -3
  109. package/dist/index.d.ts +6 -3
  110. package/dist/index.js +24 -21
  111. package/dist/layouts/app-layout.d.ts.map +1 -1
  112. package/dist/layouts/app-layout.js +2 -29
  113. package/dist/layouts/app-layout.js.map +1 -1
  114. package/dist/layouts/auth-layout.js +2 -2
  115. package/dist/layouts/auth-layout.js.map +1 -1
  116. package/dist/layouts/fullscreen-layout.js +1 -1
  117. package/dist/layouts/fullscreen-layout.js.map +1 -1
  118. package/dist/layouts/lazy-layouts.js +15 -0
  119. package/dist/layouts/lazy-layouts.js.map +1 -0
  120. package/dist/layouts/max-widths.js +34 -0
  121. package/dist/layouts/max-widths.js.map +1 -0
  122. package/dist/layouts/panel-layouts.d.ts +10 -0
  123. package/dist/layouts/panel-layouts.d.ts.map +1 -0
  124. package/dist/layouts/panel-layouts.js +20 -0
  125. package/dist/layouts/panel-layouts.js.map +1 -0
  126. package/dist/layouts/top-nav-layout.js +2 -2
  127. package/dist/layouts/top-nav-layout.js.map +1 -1
  128. package/dist/pages/page.d.ts.map +1 -1
  129. package/dist/pages/page.js +12 -7
  130. package/dist/pages/page.js.map +1 -1
  131. package/dist/standalone/alfakit-component.js +11 -6
  132. package/dist/standalone/alfakit-component.js.map +1 -1
  133. package/dist/standalone/alfakit-toaster.d.ts.map +1 -1
  134. package/dist/standalone/alfakit-toaster.js +8 -1
  135. package/dist/standalone/alfakit-toaster.js.map +1 -1
  136. package/dist/standalone.css +54 -0
  137. package/dist/starter-kit/dashboard-widgets.d.ts +33 -0
  138. package/dist/starter-kit/dashboard-widgets.d.ts.map +1 -0
  139. package/dist/starter-kit/dashboard-widgets.js +53 -0
  140. package/dist/starter-kit/dashboard-widgets.js.map +1 -0
  141. package/dist/starter-kit/impersonation-banner.js +34 -0
  142. package/dist/starter-kit/impersonation-banner.js.map +1 -0
  143. package/dist/starter-kit/index.d.ts +3 -2
  144. package/dist/starter-kit/index.js +3 -2
  145. package/dist/starter-kit/starter-kit.d.ts +6 -0
  146. package/dist/starter-kit/starter-kit.d.ts.map +1 -1
  147. package/dist/starter-kit/starter-kit.js +18 -7
  148. package/dist/starter-kit/starter-kit.js.map +1 -1
  149. package/dist/starter-kit/widgets-loaded.js +21 -5
  150. package/dist/starter-kit/widgets-loaded.js.map +1 -1
  151. package/dist/starter-kit/widgets-triggers.js +35 -3
  152. package/dist/starter-kit/widgets-triggers.js.map +1 -1
  153. package/dist/starter-kit/widgets.d.ts +13 -5
  154. package/dist/starter-kit/widgets.d.ts.map +1 -1
  155. package/dist/starter-kit/widgets.js +56 -18
  156. package/dist/starter-kit/widgets.js.map +1 -1
  157. package/dist/styles.css +15 -2
  158. package/dist/themes.css +8 -335
  159. package/package.json +2 -2
  160. package/src/components/actions/action-button.tsx +7 -4
  161. package/src/components/actions/action-modals.tsx +13 -1
  162. package/src/components/ai/copilot.tsx +14 -1
  163. package/src/components/cms/live-preview.tsx +5 -2
  164. package/src/components/fields/builder.tsx +14 -5
  165. package/src/components/fields/color-picker.tsx +1 -0
  166. package/src/components/fields/file-upload.tsx +21 -10
  167. package/src/components/fields/image-editor-dialog.tsx +7 -2
  168. package/src/components/fields/item-actions.tsx +32 -9
  169. package/src/components/fields/key-value.tsx +3 -2
  170. package/src/components/fields/modal-table-select.tsx +8 -3
  171. package/src/components/fields/one-time-code-input.tsx +1 -1
  172. package/src/components/fields/repeater-table.tsx +12 -1
  173. package/src/components/fields/repeater.tsx +2 -3
  174. package/src/components/fields/rich-editor.tsx +3 -0
  175. package/src/components/fields/select.tsx +7 -3
  176. package/src/components/file-manager/file-manager.tsx +1 -1
  177. package/src/components/form/form-block.tsx +26 -1
  178. package/src/components/form/form-context.tsx +3 -6
  179. package/src/components/kanban/kanban.tsx +16 -3
  180. package/src/components/layout/fused-group.tsx +5 -1
  181. package/src/components/layout/section.tsx +2 -2
  182. package/src/components/layout/tabs.tsx +72 -17
  183. package/src/components/panel/app-sidebar.tsx +2 -38
  184. package/src/components/panel/brand.tsx +46 -0
  185. package/src/components/panel/global-search.tsx +1 -1
  186. package/src/components/panel/shortcut-keys.tsx +4 -1
  187. package/src/components/panel/tenant-menu.tsx +2 -2
  188. package/src/components/realtime/live-records-pill.tsx +4 -3
  189. package/src/components/table/cells.tsx +27 -5
  190. package/src/components/table/column-manager.tsx +12 -10
  191. package/src/components/table/content-layout.tsx +5 -1
  192. package/src/components/table/grouping.tsx +15 -1
  193. package/src/components/table/pagination.tsx +16 -9
  194. package/src/components/table/table-block.tsx +25 -12
  195. package/src/components/ui/dialog.tsx +5 -1
  196. package/src/components/ui/popover.tsx +5 -1
  197. package/src/components/ui/sheet.tsx +5 -1
  198. package/src/components/ui/tooltip.tsx +5 -1
  199. package/src/context.tsx +10 -0
  200. package/src/create-alfakit.tsx +11 -2
  201. package/src/index.ts +2 -0
  202. package/src/layouts/app-layout.tsx +4 -28
  203. package/src/layouts/auth-layout.tsx +2 -2
  204. package/src/layouts/fullscreen-layout.tsx +1 -1
  205. package/src/layouts/lazy-layouts.ts +14 -0
  206. package/src/layouts/max-widths.ts +29 -0
  207. package/src/layouts/panel-layouts.ts +17 -0
  208. package/src/layouts/top-nav-layout.tsx +2 -2
  209. package/src/pages/page.tsx +21 -12
  210. package/src/standalone/alfakit-component.tsx +18 -7
  211. package/src/standalone/alfakit-toaster.tsx +21 -2
  212. package/src/starter-kit/dashboard-widgets.tsx +78 -0
  213. package/src/starter-kit/impersonation-banner.tsx +48 -0
  214. package/src/starter-kit/index.ts +2 -0
  215. package/src/starter-kit/starter-kit.tsx +31 -4
  216. package/src/starter-kit/widgets-loaded.tsx +23 -1
  217. package/src/starter-kit/widgets-triggers.tsx +37 -2
  218. package/src/starter-kit/widgets.tsx +95 -20
  219. package/src/styles.css +15 -2
@@ -4,7 +4,7 @@ import type {
4
4
  ImageEditorData,
5
5
  ImageEditorDragMode,
6
6
  } from '@alfakit/core'
7
- import { ImageEditor, loadEditorImage } from '@alfakit/core'
7
+ import { ImageEditor, imageEditorOptionsKey, loadEditorImage } from '@alfakit/core'
8
8
  import {
9
9
  FlipHorizontal2Icon,
10
10
  FlipVertical2Icon,
@@ -63,9 +63,14 @@ export function ImageEditorDialog({
63
63
  const [saving, setSaving] = useState(false)
64
64
  const hintId = useId()
65
65
 
66
+ // Restart only for another image or other settings: the schema object itself is new on
67
+ // every render of a template item (a new builder block), which kept restarting the load.
68
+ const optionsKey = imageEditorOptionsKey(options)
69
+
66
70
  useEffect(() => {
67
71
  if (!request) return
68
72
 
73
+ const options = JSON.parse(optionsKey) as FileUploadImageEditorSchema
69
74
  let cancelled = false
70
75
 
71
76
  loadEditorImage(request.file)
@@ -97,7 +102,7 @@ export function ImageEditorDialog({
97
102
  return null
98
103
  })
99
104
  }
100
- }, [request, options])
105
+ }, [request, optionsKey])
101
106
 
102
107
  const finish = (file: File | null) => {
103
108
  request?.resolve(file)
@@ -12,6 +12,7 @@ import {
12
12
  ItemMoveTracker,
13
13
  addActionJustify,
14
14
  itemActionContext,
15
+ itemActionIcon,
15
16
  resolveItemAction,
16
17
  withItemActionState,
17
18
  } from '@alfakit/core'
@@ -154,19 +155,41 @@ export function useRunItemAction(context: ActionContext) {
154
155
  }
155
156
  }
156
157
 
157
- /** Default looks of the built-in actions, for payloads without `itemActions`. */
158
- export function useItemActionFallbacks(addLabel = ''): Record<ItemActionName, ItemActionFallback> {
158
+ /** Default looks of the built-in actions, for payloads without `itemActions` (icons follow their `alfakit::` aliases). */
159
+ export function useItemActionFallbacks(
160
+ addLabel = '',
161
+ kind: 'repeater' | 'builder' = 'repeater',
162
+ ): Record<ItemActionName, ItemActionFallback> {
159
163
  const t = useT()
160
164
 
161
165
  return {
162
166
  add: { label: addLabel, icon: 'plus', variant: 'outline' },
163
- delete: { label: t('form.remove_item', {}, 'Remove'), icon: 'trash-2', color: 'danger' },
164
- clone: { label: t('form.clone', {}, 'Clone'), icon: 'copy' },
165
- reorder: { label: t('form.reorder_item', {}, 'Reorder'), icon: 'grip-vertical' },
166
- moveUp: { label: t('form.move_up', {}, 'Move up'), icon: 'chevron-up' },
167
- moveDown: { label: t('form.move_down', {}, 'Move down'), icon: 'chevron-down' },
168
- collapse: { label: t('form.collapse_item', {}, 'Collapse'), icon: 'chevron-up' },
169
- expand: { label: t('form.expand_item', {}, 'Expand'), icon: 'chevron-down' },
167
+ delete: {
168
+ label: t('form.remove_item', {}, 'Remove'),
169
+ icon: itemActionIcon(kind, 'delete', 'trash-2'),
170
+ color: 'danger',
171
+ },
172
+ clone: { label: t('form.clone', {}, 'Clone'), icon: itemActionIcon(kind, 'clone', 'copy') },
173
+ reorder: {
174
+ label: t('form.reorder_item', {}, 'Reorder'),
175
+ icon: itemActionIcon(kind, 'reorder', 'grip-vertical'),
176
+ },
177
+ moveUp: {
178
+ label: t('form.move_up', {}, 'Move up'),
179
+ icon: itemActionIcon(kind, 'move-up', 'chevron-up'),
180
+ },
181
+ moveDown: {
182
+ label: t('form.move_down', {}, 'Move down'),
183
+ icon: itemActionIcon(kind, 'move-down', 'chevron-down'),
184
+ },
185
+ collapse: {
186
+ label: t('form.collapse_item', {}, 'Collapse'),
187
+ icon: itemActionIcon(kind, 'collapse', 'chevron-up'),
188
+ },
189
+ expand: {
190
+ label: t('form.expand_item', {}, 'Expand'),
191
+ icon: itemActionIcon(kind, 'expand', 'chevron-down'),
192
+ },
170
193
  collapseAll: {
171
194
  label: t('form.collapse_all', {}, 'Collapse all'),
172
195
  icon: 'chevrons-down-up',
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  fieldIds,
3
+ itemActionIcon,
3
4
  moveRepeaterItem,
4
5
  reorderedItemKeys,
5
6
  resolveItemAction,
@@ -51,11 +52,11 @@ export function KeyValueField({ component }: { component: KeyValueSchema }) {
51
52
  })
52
53
  const deleteAction = resolveItemAction('delete', component, undefined, {
53
54
  label: t('form.remove_row', {}, 'Remove row'),
54
- icon: 'trash-2',
55
+ icon: itemActionIcon('key-value', 'delete', 'trash-2'),
55
56
  })
56
57
  const reorderAction = resolveItemAction('reorder', component, undefined, {
57
58
  label: t('form.reorder_item', {}, 'Reorder'),
58
- icon: 'grip-vertical',
59
+ icon: itemActionIcon('key-value', 'reorder', 'grip-vertical'),
59
60
  })
60
61
 
61
62
  const update = (next: Row[]) => setValue(next)
@@ -20,7 +20,7 @@ import {
20
20
  XIcon,
21
21
  } from 'lucide-react'
22
22
  import { useEffect, useMemo, useRef, useState } from 'react'
23
- import { useChoice, useT } from '../../context'
23
+ import { useChoice, useFormatter, useT } from '../../context'
24
24
  import { useStore } from '../../hooks/use-store'
25
25
  import { Icon } from '../../lib/icons'
26
26
  import { cn } from '../../lib/utils'
@@ -135,6 +135,7 @@ interface PickerProps {
135
135
 
136
136
  function ModalTablePicker({ component, initial, onClose, onConfirm }: PickerProps) {
137
137
  const t = useT()
138
+ const format = useFormatter()
138
139
  const choice = useChoice()
139
140
  const form = useFormStore()
140
141
  const multiple = Boolean(component.multiple)
@@ -399,8 +400,12 @@ function ModalTablePicker({ component, initial, onClose, onConfirm }: PickerProp
399
400
  {paging.total !== undefined
400
401
  ? t(
401
402
  'table.showing',
402
- { first: paging.from, last: paging.to, total: paging.total },
403
- `Showing ${paging.from}–${paging.to} of ${paging.total}`,
403
+ {
404
+ first: format.number(paging.from),
405
+ last: format.number(paging.to),
406
+ total: format.number(paging.total),
407
+ },
408
+ 'Showing :first–:last of :total',
404
409
  )
405
410
  : `${paging.from}–${paging.to}`}
406
411
  </span>
@@ -25,7 +25,6 @@ export function OneTimeCodeInputField({ component }: { component: OneTimeCodeInp
25
25
  inputMode="numeric"
26
26
  autoComplete="one-time-code"
27
27
  pattern="[0-9]*"
28
- maxLength={component.length}
29
28
  value={value ?? ''}
30
29
  onChange={(event) => {
31
30
  const code = oneTimeCodeDigits(event.target.value, component.length)
@@ -45,6 +44,7 @@ export function OneTimeCodeInputField({ component }: { component: OneTimeCodeInp
45
44
  required={component.required}
46
45
  autoFocus={component.autofocus}
47
46
  data-slot="one-time-code-input"
47
+ data-input=""
48
48
  {...aria}
49
49
  {...extraProps(
50
50
  component.extraInputAttributes,
@@ -7,6 +7,7 @@ import type {
7
7
  TableColumnSchema,
8
8
  } from '@alfakit/core'
9
9
  import { resolveItemAction, tableColumnAlignmentClass, tableColumnCells } from '@alfakit/core'
10
+ import type { CSSProperties } from 'react'
10
11
  import { useT } from '../../context'
11
12
  import type { VirtualListBinding } from '../../hooks/use-virtual-list'
12
13
  import { cn } from '../../lib/utils'
@@ -41,6 +42,16 @@ export interface RepeaterTableProps {
41
42
  extraActionsFor?: (index: number) => ActionSchema[] | undefined
42
43
  }
43
44
 
45
+ /**
46
+ * A fixed `width()` (`9rem`, `120px`) is also the column's minimum, so the table scrolls
47
+ * sideways instead of squeezing its fields; percentages stay flexible.
48
+ */
49
+ function columnWidthStyle(width?: string | null): CSSProperties | undefined {
50
+ if (!width) return undefined
51
+
52
+ return width.trim().endsWith('%') ? { width } : { width, minWidth: width }
53
+ }
54
+
44
55
  /** Header cells shared by the Repeater and RepeatableEntry tables. */
45
56
  export function TableColumnHeaders({ columns }: { columns: TableColumnSchema[] }) {
46
57
  return (
@@ -49,7 +60,7 @@ export function TableColumnHeaders({ columns }: { columns: TableColumnSchema[] }
49
60
  <th
50
61
  key={`${column.name ?? 'column'}-${index}`}
51
62
  scope="col"
52
- style={column.width ? { width: column.width } : undefined}
63
+ style={columnWidthStyle(column.width)}
53
64
  className={cn(
54
65
  'text-muted-foreground px-3 py-2 text-xs font-medium',
55
66
  tableColumnAlignmentClass(column),
@@ -13,7 +13,7 @@ import {
13
13
  removeIndexedState,
14
14
  removeRepeaterItem,
15
15
  reorderedItemKeys,
16
- replacePlaceholder,
16
+ templateForIndex,
17
17
  virtualizeOptions,
18
18
  } from '@alfakit/core'
19
19
  import { useState } from 'react'
@@ -125,8 +125,7 @@ function RepeaterFieldContent({ component }: { component: RepeaterSchema }) {
125
125
  )
126
126
 
127
127
  const schemaFor = (index: number): SchemaComponent[] =>
128
- component.items[index]?.schema ??
129
- replacePlaceholder(component.template, '__INDEX__', String(index))
128
+ component.items[index]?.schema ?? templateForIndex(component.template, index)
130
129
 
131
130
  const canAdd =
132
131
  component.addable &&
@@ -9,6 +9,7 @@ import {
9
9
  attachToolbarKeyboard,
10
10
  commandLabel,
11
11
  detailsNodeConfigs,
12
+ documentNonce,
12
13
  EditorAttachments,
13
14
  fieldIds,
14
15
  fileAttachmentError,
@@ -403,6 +404,8 @@ function RichEditorInput({
403
404
  useEffect(() => () => attachments.destroy(), [attachments])
404
405
 
405
406
  const editor = useEditor({
407
+ // TipTap injects its base CSS; with the page's CSP nonce a nonce-based policy allows it.
408
+ injectNonce: documentNonce(),
406
409
  extensions: [
407
410
  StarterKit.configure({ link: false }),
408
411
  Link.configure(linkExtensionOptions(component.linkProtocols)),
@@ -65,7 +65,9 @@ export function SelectField({ component }: { component: SelectSchema }) {
65
65
  className="border-input dark:bg-input/30 focus-visible:ring-ring/50 h-9 w-full rounded-md border bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:ring-[3px]"
66
66
  >
67
67
  {component.selectablePlaceholder && (
68
- <option value="">{component.placeholder ?? ''}</option>
68
+ <option value="">
69
+ {component.placeholder ?? t('select_option', {}, 'Select an option')}
70
+ </option>
69
71
  )}
70
72
  {component.options.map((option) =>
71
73
  isOptionGroup(option) ? (
@@ -103,7 +105,9 @@ export function SelectField({ component }: { component: SelectSchema }) {
103
105
  aria-readonly={readOnly || undefined}
104
106
  className="w-full"
105
107
  >
106
- <SelectValue placeholder={component.placeholder ?? ''} />
108
+ <SelectValue
109
+ placeholder={component.placeholder ?? t('select_option', {}, 'Select an option')}
110
+ />
107
111
  </SelectTrigger>
108
112
  <SelectContent>
109
113
  {component.selectablePlaceholder &&
@@ -411,7 +415,7 @@ function ComboboxSelect({
411
415
  error && 'border-destructive',
412
416
  )}
413
417
  >
414
- <span className="flex flex-1 flex-wrap items-center gap-1 text-start">
418
+ <span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 text-start">
415
419
  {selected.length === 0 && (
416
420
  <span className="text-muted-foreground">
417
421
  {component.placeholder ?? t('select_option', {}, 'Select an option')}
@@ -322,7 +322,7 @@ export function FileManagerView({ schema, onChoose, onCancel }: FileManagerViewP
322
322
  )}
323
323
  <nav
324
324
  aria-label={tt('breadcrumb', {}, 'Folder path')}
325
- className="min-w-0 flex-1"
325
+ className="min-w-0 flex-[1_1_12rem]"
326
326
  data-slot="file-manager-breadcrumb"
327
327
  >
328
328
  <ol className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
@@ -13,6 +13,7 @@ import {
13
13
  registerFormSubmitter,
14
14
  registerPageForm,
15
15
  resolvePartialProps,
16
+ shouldResyncForm,
16
17
  wizardProgressStore,
17
18
  } from '@alfakit/core'
18
19
  import { router } from '@inertiajs/react'
@@ -93,6 +94,10 @@ export function FormBlock({ block }: { block: FormBlockSchema }) {
93
94
  const latestBlock = useRef(block)
94
95
  const submittedBlock = useRef<FormBlockSchema | null>(null)
95
96
  const refillPending = useRef(false)
97
+ // Another action succeeded and its new block has not arrived yet.
98
+ const resyncPending = useRef(false)
99
+ // The block when the latest non-GET visit started, to tell whether its response arrived.
100
+ const visitBlock = useRef<FormBlockSchema | null>(null)
96
101
 
97
102
  useEffect(() => {
98
103
  latestBlock.current = block
@@ -101,6 +106,12 @@ export function FormBlock({ block }: { block: FormBlockSchema }) {
101
106
  refillPending.current = false
102
107
  store.fill(block)
103
108
  }
109
+
110
+ if (resyncPending.current) {
111
+ resyncPending.current = false
112
+
113
+ if (!store.isDirty) store.fill(block)
114
+ }
104
115
  }, [block, store])
105
116
 
106
117
  const actions: ActionSchema[] = useMemo(
@@ -117,7 +128,8 @@ export function FormBlock({ block }: { block: FormBlockSchema }) {
117
128
  submitting.current = true
118
129
  submittedBlock.current = latestBlock.current
119
130
  store.setProcessing(true)
120
- runner.execute(action, ctx, store.state.values)
131
+ // A click that blurred a live field started a refresh: send the state it returns.
132
+ store.whenSettled(() => runner.execute(action, ctx, store.state.values))
121
133
  },
122
134
  [runner, store],
123
135
  )
@@ -152,11 +164,24 @@ export function FormBlock({ block }: { block: FormBlockSchema }) {
152
164
  if (submitting.current && !submitVisit.current && visit.method !== 'get') {
153
165
  submitVisit.current = visit.id
154
166
  }
167
+
168
+ if (visit.method !== 'get') visitBlock.current = latestBlock.current
155
169
  })
156
170
 
157
171
  const offSuccess = router.on('success', (event) => {
158
172
  const id = submitVisit.current
159
173
 
174
+ // Another action changed the record: show the server's new form unless it holds edits.
175
+ if (!id && shouldResyncForm(pageFlash(event.detail.page), store)) {
176
+ if (latestBlock.current !== visitBlock.current) {
177
+ store.fill(latestBlock.current)
178
+ } else {
179
+ resyncPending.current = true
180
+ }
181
+
182
+ return
183
+ }
184
+
160
185
  if (!id || (event.detail.visitId ?? id) !== id) return
161
186
 
162
187
  // A halted action (rate limit, `halt()`) saved nothing: keep what the user typed.
@@ -1,5 +1,5 @@
1
1
  import type { BaseField, FieldAriaAttributes, FormState, FormStore } from '@alfakit/core'
2
- import { fieldAria } from '@alfakit/core'
2
+ import { fieldAria, fieldDomId, fieldError } from '@alfakit/core'
3
3
  import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
4
4
  import { useStore, useStoreSelector } from '../../hooks/use-store'
5
5
 
@@ -60,15 +60,12 @@ export function useField<T = unknown>(field: BaseField): FieldBinding<T> {
60
60
  return current as T
61
61
  })
62
62
 
63
- const error = useStoreSelector(store.store, (state) => state.errors[path]?.[0])
63
+ const error = useStoreSelector(store.store, (state) => fieldError(state.errors, path))
64
64
 
65
65
  const setValue = useCallback((next: T) => store.setValue(path, next), [store, path])
66
66
  const blur = useCallback(() => store.blur(path), [store, path])
67
67
 
68
- const id = useMemo(
69
- () => field.id ?? `alfakit-${path.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
70
- [field.id, path],
71
- )
68
+ const id = useMemo(() => field.id ?? fieldDomId(store.idPrefix, path), [field.id, store, path])
72
69
 
73
70
  const aria = useMemo(
74
71
  () =>
@@ -28,7 +28,14 @@ import {
28
28
  SearchIcon,
29
29
  } from 'lucide-react'
30
30
  import { Fragment, useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'
31
- import { useActionRunner, useDirection, useFormatter, usePanel, useT } from '../../context'
31
+ import {
32
+ useActionRunner,
33
+ useChoice,
34
+ useDirection,
35
+ useFormatter,
36
+ usePanel,
37
+ useT,
38
+ } from '../../context'
32
39
  import { useStore } from '../../hooks/use-store'
33
40
  import { badgeColorClasses, colorClass, cssColor } from '../../lib/colors'
34
41
  import { Icon } from '../../lib/icons'
@@ -424,6 +431,7 @@ function ColumnHeader({
424
431
  onCreate: () => void
425
432
  t: Translate
426
433
  }) {
434
+ const choice = useChoice()
427
435
  const wip = kanbanWipState(column)
428
436
  const shown =
429
437
  column.wipLimit !== undefined ? column.count : filtered ? column.total : column.count
@@ -436,7 +444,7 @@ function ColumnHeader({
436
444
  { shown: column.total, count: column.count },
437
445
  ':shown of :count cards',
438
446
  )
439
- : t('kanban.count', { count: column.count }, ':count cards')
447
+ : choice('kanban.count', column.count, {}, '{1} :count card|[0,*] :count cards')
440
448
  const style = column.color ? { borderTopColor: cssColor(column.color) } : undefined
441
449
  const toggleLabel = collapsed
442
450
  ? t('kanban.expand', { column: column.label }, 'Expand :column')
@@ -532,7 +540,12 @@ function ColumnHeader({
532
540
  )}
533
541
  {wip === 'over' && (
534
542
  <p className="text-destructive text-xs">
535
- {t('kanban.wip_over', { limit: column.wipLimit ?? 0 }, 'Over the limit of :limit cards')}
543
+ {choice(
544
+ 'kanban.wip_over',
545
+ column.wipLimit ?? 0,
546
+ { limit: column.wipLimit ?? 0 },
547
+ '{1} Over the limit of :limit card|[0,*] Over the limit of :limit cards',
548
+ )}
536
549
  </p>
537
550
  )}
538
551
  {wip === 'full' && column.wipStrict && (
@@ -25,7 +25,11 @@ export function FusedGroupComponent({ component }: { component: FusedGroupSchema
25
25
  {...extraProps(component.extraAttributes, 'flex flex-col gap-1.5')}
26
26
  >
27
27
  {component.label && (
28
- <span id={labelId} className="text-sm leading-none font-medium select-none">
28
+ <span
29
+ id={labelId}
30
+ data-slot="label"
31
+ className="text-sm leading-none font-medium select-none"
32
+ >
29
33
  {component.label}
30
34
  </span>
31
35
  )}
@@ -76,11 +76,11 @@ export function SectionComponent({ component }: { component: SectionSchema }) {
76
76
  {component.heading &&
77
77
  createElement(
78
78
  headingTag(component.headingLevel),
79
- { className: 'text-base leading-6 font-semibold' },
79
+ { className: 'text-base leading-6 font-semibold break-words' },
80
80
  component.heading,
81
81
  )}
82
82
  {component.description && (
83
- <p className="text-muted-foreground mt-1 text-sm">{component.description}</p>
83
+ <p className="text-muted-foreground mt-1 text-sm break-words">{component.description}</p>
84
84
  )}
85
85
  </div>
86
86
  {component.headerActions && component.headerActions.length > 0 && (
@@ -1,30 +1,51 @@
1
1
  import type { TabSchema, TabsSchema } from '@alfakit/core'
2
- import { readPersisted, schemaGapClass, writePersisted } from '@alfakit/core'
2
+ import {
3
+ componentHasErrors,
4
+ readPersisted,
5
+ schemaGapClass,
6
+ tabFromQuery,
7
+ tabQueryValue,
8
+ tabToReveal,
9
+ writePersisted,
10
+ } from '@alfakit/core'
3
11
  import { usePage } from '@inertiajs/react'
4
12
  import { useEffect, useState } from 'react'
13
+ import { useT } from '../../context'
5
14
  import { useHydrated } from '../../hooks/use-hydrated'
15
+ import { useStoreSelector } from '../../hooks/use-store'
6
16
  import { extraProps } from '../../lib/attributes'
7
17
  import { badgeColorClasses, colorClass } from '../../lib/colors'
8
18
  import { Icon } from '../../lib/icons'
9
19
  import { cn } from '../../lib/utils'
20
+ import { useOptionalFormStore } from '../form/form-context'
10
21
  import { SchemaRenderer } from '../schema/schema-renderer'
11
22
  import { Badge } from '../ui/badge'
12
23
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'
13
24
  import { DeferredSchema, useDeferredBadge } from './deferred'
14
25
 
26
+ // Used when tabs render outside a form. Module-level so the snapshot stays stable.
27
+ const NO_ERRORS: Record<string, string[]> = {}
28
+ const NO_FORM_STORE = {
29
+ get: () => ({ errors: NO_ERRORS }),
30
+ subscribe: () => () => {},
31
+ }
32
+
33
+ const TAB_INVALID_CLASSES =
34
+ 'text-destructive data-[state=active]:text-destructive dark:text-destructive dark:data-[state=active]:text-destructive'
35
+
15
36
  export function TabsComponent({ component }: { component: TabsSchema }) {
16
37
  const tabs = component.schema ?? []
17
38
  // The page URL (not `window.location`) so the server renders the same tab as the browser.
18
39
  const url = usePage().url
40
+ // The tab's readable value (`?tab=billing-address`), or its key in links from older versions.
19
41
  const fromQuery = component.queryStringKey
20
- ? new URL(url, 'http://localhost').searchParams.get(component.queryStringKey)
42
+ ? tabFromQuery(
43
+ tabs,
44
+ new URL(url, 'http://localhost').searchParams.get(component.queryStringKey),
45
+ )
21
46
  : null
22
47
  const [initial] = useState<string>(
23
- () =>
24
- tabs.find((tab) => tab.key === fromQuery)?.key ??
25
- tabs[Math.max(0, component.activeTab - 1)]?.key ??
26
- tabs[0]?.key ??
27
- '',
48
+ () => fromQuery ?? tabs[Math.max(0, component.activeTab - 1)]?.key ?? tabs[0]?.key ?? '',
28
49
  )
29
50
 
30
51
  const persist = component.persistTab
@@ -36,6 +57,21 @@ export function TabsComponent({ component }: { component: TabsSchema }) {
36
57
  hydrated && persist && !fromQuery ? readPersisted(persist.key, persist.storage) : null
37
58
  const active = chosen ?? (stored && tabs.some((tab) => tab.key === stored) ? stored : initial)
38
59
 
60
+ // Tabs holding a field with a validation error are marked, and when a submit brings new
61
+ // errors while the open tab has none, the first tab with an error opens.
62
+ const t = useT()
63
+ const store = useOptionalFormStore()
64
+ const errors = useStoreSelector(store?.store ?? NO_FORM_STORE, (state) => state.errors)
65
+ const [seenErrors, setSeenErrors] = useState<Record<string, string[]>>({})
66
+
67
+ if (seenErrors !== errors) {
68
+ setSeenErrors(errors)
69
+
70
+ const reveal = tabToReveal(tabs, active, seenErrors, errors)
71
+
72
+ if (reveal !== null) setChosen(reveal)
73
+ }
74
+
39
75
  const changeTab = (value: string) => {
40
76
  setChosen(value)
41
77
 
@@ -45,10 +81,11 @@ export function TabsComponent({ component }: { component: TabsSchema }) {
45
81
  useEffect(() => {
46
82
  if (!component.queryStringKey || typeof window === 'undefined') return
47
83
 
84
+ const tab = tabs.find((item) => item.key === active)
48
85
  const url = new URL(window.location.href)
49
- url.searchParams.set(component.queryStringKey, active)
86
+ url.searchParams.set(component.queryStringKey, tab ? tabQueryValue(tab) : active)
50
87
  window.history.replaceState(window.history.state, '', url)
51
- }, [active, component.queryStringKey])
88
+ }, [active, component.queryStringKey, tabs])
52
89
 
53
90
  return (
54
91
  <Tabs
@@ -76,14 +113,32 @@ export function TabsComponent({ component }: { component: TabsSchema }) {
76
113
  : 'relative max-w-full overflow-x-auto',
77
114
  )}
78
115
  >
79
- {tabs.map((tab) => (
80
- <TabsTrigger key={tab.key} value={tab.key}>
81
- {tab.icon && tab.iconPosition !== 'after' && <Icon name={tab.icon} />}
82
- {tab.label}
83
- {tab.icon && tab.iconPosition === 'after' && <Icon name={tab.icon} />}
84
- <TabBadge tab={tab} />
85
- </TabsTrigger>
86
- ))}
116
+ {tabs.map((tab) => {
117
+ const invalid = componentHasErrors(tab, errors)
118
+
119
+ return (
120
+ <TabsTrigger
121
+ key={tab.key}
122
+ value={tab.key}
123
+ data-invalid={invalid || undefined}
124
+ className={invalid ? TAB_INVALID_CLASSES : undefined}
125
+ >
126
+ {tab.icon && tab.iconPosition !== 'after' && <Icon name={tab.icon} />}
127
+ {tab.label}
128
+ {tab.icon && tab.iconPosition === 'after' && <Icon name={tab.icon} />}
129
+ <TabBadge tab={tab} />
130
+ {invalid && (
131
+ <>
132
+ <span
133
+ aria-hidden="true"
134
+ className="bg-destructive size-1.5 shrink-0 rounded-full"
135
+ />
136
+ <span className="sr-only">({t('translatable.has_errors')})</span>
137
+ </>
138
+ )}
139
+ </TabsTrigger>
140
+ )
141
+ })}
87
142
  </TabsList>
88
143
  {tabs.map((tab) => (
89
144
  <TabsContent
@@ -41,50 +41,14 @@ import {
41
41
  SidebarMenuSubItem,
42
42
  SidebarRail,
43
43
  } from '../ui/sidebar'
44
+ import { Brand } from './brand'
44
45
  import { GlobalSearch } from './global-search'
45
46
  import { NavLabel } from './nav-label'
46
47
  import { Slot } from './slot'
47
48
  import { TenantMenu } from './tenant-menu'
48
49
  import { UserMenu } from './user-menu'
49
50
 
50
- export function Brand({ className }: { className?: string }) {
51
- const panel = usePanel()
52
-
53
- return (
54
- <Link
55
- href={panel.url ?? '/'}
56
- className={cn('flex items-center gap-2 overflow-hidden', className)}
57
- >
58
- {panel.brandLogo ? (
59
- <>
60
- <img
61
- src={panel.brandLogo}
62
- alt={panel.brandName}
63
- style={{ height: panel.brandLogoHeight ?? '1.75rem' }}
64
- className={cn('max-w-full object-contain', panel.darkModeBrandLogo && 'dark:hidden')}
65
- />
66
- {panel.darkModeBrandLogo && (
67
- <img
68
- src={panel.darkModeBrandLogo}
69
- alt={panel.brandName}
70
- style={{ height: panel.brandLogoHeight ?? '1.75rem' }}
71
- className="hidden max-w-full object-contain dark:block"
72
- />
73
- )}
74
- </>
75
- ) : (
76
- <>
77
- <span className="bg-primary text-primary-foreground flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-bold">
78
- {panel.brandName.charAt(0)}
79
- </span>
80
- <span className="truncate text-sm font-semibold group-data-[collapsible=icon]:hidden">
81
- {panel.brandName}
82
- </span>
83
- </>
84
- )}
85
- </Link>
86
- )
87
- }
51
+ export { Brand } from './brand'
88
52
 
89
53
  function NavBadge({ item }: { item: NavigationItemSchema }) {
90
54
  const t = useT()