@alfakit/react 0.1.0 → 0.1.2

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 (71) hide show
  1. package/CHANGELOG.md +17 -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 +2 -5
  7. package/dist/components/actions/action-modals.js.map +1 -1
  8. package/dist/components/fields/color-picker.js +1 -0
  9. package/dist/components/fields/color-picker.js.map +1 -1
  10. package/dist/components/fields/file-upload.js +11 -10
  11. package/dist/components/fields/file-upload.js.map +1 -1
  12. package/dist/components/fields/one-time-code-input.js +1 -1
  13. package/dist/components/fields/one-time-code-input.js.map +1 -1
  14. package/dist/components/fields/repeater-table.js +12 -1
  15. package/dist/components/fields/repeater-table.js.map +1 -1
  16. package/dist/components/fields/select.js +3 -3
  17. package/dist/components/fields/select.js.map +1 -1
  18. package/dist/components/file-manager/file-manager.js +1 -1
  19. package/dist/components/file-manager/file-manager.js.map +1 -1
  20. package/dist/components/form/form-block.d.ts.map +1 -1
  21. package/dist/components/form/form-block.js +13 -1
  22. package/dist/components/form/form-block.js.map +1 -1
  23. package/dist/components/form/form-context.js +6 -2
  24. package/dist/components/form/form-context.js.map +1 -1
  25. package/dist/components/layout/fused-group.js +1 -0
  26. package/dist/components/layout/fused-group.js.map +1 -1
  27. package/dist/components/layout/tabs.d.ts.map +1 -1
  28. package/dist/components/layout/tabs.js +54 -15
  29. package/dist/components/layout/tabs.js.map +1 -1
  30. package/dist/components/panel/global-search.js +1 -0
  31. package/dist/components/panel/global-search.js.map +1 -1
  32. package/dist/components/panel/shortcut-keys.js +1 -0
  33. package/dist/components/panel/shortcut-keys.js.map +1 -1
  34. package/dist/components/panel/tenant-menu.js +2 -2
  35. package/dist/components/panel/tenant-menu.js.map +1 -1
  36. package/dist/components/table/cells.js +9 -1
  37. package/dist/components/table/cells.js.map +1 -1
  38. package/dist/components/table/grouping.js +11 -1
  39. package/dist/components/table/grouping.js.map +1 -1
  40. package/dist/components.css +347 -0
  41. package/dist/eject-manifest.json +2 -2
  42. package/dist/standalone/alfakit-toaster.d.ts.map +1 -1
  43. package/dist/standalone/alfakit-toaster.js +8 -1
  44. package/dist/standalone/alfakit-toaster.js.map +1 -1
  45. package/dist/standalone.css +47 -0
  46. package/dist/starter-kit/widgets-loaded.js +1 -1
  47. package/dist/starter-kit/widgets-loaded.js.map +1 -1
  48. package/dist/starter-kit/widgets-triggers.js +1 -0
  49. package/dist/starter-kit/widgets-triggers.js.map +1 -1
  50. package/dist/themes.css +2 -334
  51. package/package.json +2 -2
  52. package/src/components/actions/action-button.tsx +7 -4
  53. package/src/components/actions/action-modals.tsx +3 -1
  54. package/src/components/fields/color-picker.tsx +1 -0
  55. package/src/components/fields/file-upload.tsx +21 -10
  56. package/src/components/fields/one-time-code-input.tsx +1 -1
  57. package/src/components/fields/repeater-table.tsx +12 -1
  58. package/src/components/fields/select.tsx +7 -3
  59. package/src/components/file-manager/file-manager.tsx +1 -1
  60. package/src/components/form/form-block.tsx +24 -0
  61. package/src/components/form/form-context.tsx +2 -5
  62. package/src/components/layout/fused-group.tsx +5 -1
  63. package/src/components/layout/tabs.tsx +72 -17
  64. package/src/components/panel/global-search.tsx +1 -1
  65. package/src/components/panel/shortcut-keys.tsx +4 -1
  66. package/src/components/panel/tenant-menu.tsx +2 -2
  67. package/src/components/table/cells.tsx +18 -3
  68. package/src/components/table/grouping.tsx +15 -1
  69. package/src/standalone/alfakit-toaster.tsx +21 -2
  70. package/src/starter-kit/widgets-loaded.tsx +1 -1
  71. package/src/starter-kit/widgets-triggers.tsx +1 -1
@@ -6,10 +6,10 @@ import {
6
6
  editableImageName,
7
7
  fetchEditableImage,
8
8
  fieldMemory,
9
+ fileManagerSize,
9
10
  filesFromClipboard,
10
11
  fileUploadAccept,
11
12
  fileUploadPaths,
12
- formatBytes,
13
13
  insertUploadedFile,
14
14
  moveRepeaterItem,
15
15
  panelUploadOptions,
@@ -29,7 +29,7 @@ import {
29
29
  XIcon,
30
30
  } from 'lucide-react'
31
31
  import { useRef, useState, type ClipboardEvent, type DragEvent } from 'react'
32
- import { useChoice, usePanel, useT } from '../../context'
32
+ import { useChoice, useFormatter, usePanel, useT } from '../../context'
33
33
  import { useStore } from '../../hooks/use-store'
34
34
  import { cn } from '../../lib/utils'
35
35
  import { useField, useFormStore } from '../form/form-context'
@@ -47,6 +47,7 @@ interface LocalFile extends UploadedFile {
47
47
 
48
48
  export function FileUploadField({ component }: { component: FileUploadSchema }) {
49
49
  const t = useT()
50
+ const formatter = useFormatter()
50
51
  const choice = useChoice()
51
52
  const panel = usePanel()
52
53
  const { value, setValue, error, id, disabled, readOnly, aria } = useField<
@@ -375,7 +376,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
375
376
  variant={circle ? 'secondary' : 'ghost'}
376
377
  size="icon-xs"
377
378
  className={cn(circle && 'rounded-full shadow')}
378
- aria-label={t('remove', {}, 'Remove')}
379
+ aria-label={t('file_upload.remove', { name: file.name }, 'Remove :name')}
379
380
  onClick={() => {
380
381
  if (file.error || file.progress !== undefined) {
381
382
  releasePreview(file.preview)
@@ -467,7 +468,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
467
468
  : file.progress !== undefined && !file.error
468
469
  ? (component.uploadingMessage ?? t('form.uploading', {}, 'Uploading…'))
469
470
  : file.size
470
- ? formatBytes(file.size)
471
+ ? fileManagerSize(file.size, formatter)
471
472
  : file.temporary
472
473
  ? t('form.uploading', {}, 'Pending')
473
474
  : '')}
@@ -486,7 +487,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
486
487
  type="button"
487
488
  variant="ghost"
488
489
  size="icon-xs"
489
- aria-label={t('form.move_up', {}, 'Move up')}
490
+ aria-label={t('file_upload.move_up', { name: file.name }, 'Move :name up')}
490
491
  disabled={index === 0}
491
492
  onClick={() => move(index, index - 1)}
492
493
  >
@@ -496,7 +497,11 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
496
497
  type="button"
497
498
  variant="ghost"
498
499
  size="icon-xs"
499
- aria-label={t('form.move_down', {}, 'Move down')}
500
+ aria-label={t(
501
+ 'file_upload.move_down',
502
+ { name: file.name },
503
+ 'Move :name down',
504
+ )}
500
505
  disabled={index === files.length - 1}
501
506
  onClick={() => move(index, index + 1)}
502
507
  >
@@ -510,7 +515,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
510
515
  variant={circle ? 'secondary' : 'ghost'}
511
516
  size="icon-xs"
512
517
  className={cn(circle && 'rounded-full shadow')}
513
- aria-label={t('image_editor.title', {}, 'Edit image')}
518
+ aria-label={t('file_upload.edit', { name: file.name }, 'Edit :name')}
514
519
  title={t('image_editor.title', {}, 'Edit image')}
515
520
  data-action="edit-image"
516
521
  onClick={() => void editExisting(file)}
@@ -524,7 +529,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
524
529
  href={uploadedFileLinks(file).open}
525
530
  target="_blank"
526
531
  rel="noopener noreferrer"
527
- aria-label={`${t('open', {}, 'Open')} ${t('opens_in_new_tab', {}, '(opens in a new tab)')}`}
532
+ aria-label={`${t('file_upload.open', { name: file.name }, 'Open :name')} ${t('opens_in_new_tab', {}, '(opens in a new tab)')}`}
528
533
  >
529
534
  <ExternalLinkIcon />
530
535
  </a>
@@ -535,7 +540,11 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
535
540
  <a
536
541
  href={uploadedFileLinks(file).download}
537
542
  download
538
- aria-label={t('download', {}, 'Download')}
543
+ aria-label={t(
544
+ 'file_upload.download',
545
+ { name: file.name },
546
+ 'Download :name',
547
+ )}
539
548
  >
540
549
  <DownloadIcon />
541
550
  </a>
@@ -596,7 +605,9 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
596
605
  <span className="text-muted-foreground text-xs">
597
606
  {[
598
607
  component.acceptedFileTypes?.join(', '),
599
- component.maxSize ? `≤ ${formatBytes(component.maxSize * 1024, 0)}` : null,
608
+ component.maxSize
609
+ ? `≤ ${fileManagerSize(component.maxSize * 1024, formatter)}`
610
+ : null,
600
611
  component.minFiles
601
612
  ? choice(
602
613
  'form.min_files',
@@ -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),
@@ -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(
@@ -152,11 +163,24 @@ export function FormBlock({ block }: { block: FormBlockSchema }) {
152
163
  if (submitting.current && !submitVisit.current && visit.method !== 'get') {
153
164
  submitVisit.current = visit.id
154
165
  }
166
+
167
+ if (visit.method !== 'get') visitBlock.current = latestBlock.current
155
168
  })
156
169
 
157
170
  const offSuccess = router.on('success', (event) => {
158
171
  const id = submitVisit.current
159
172
 
173
+ // Another action changed the record: show the server's new form unless it holds edits.
174
+ if (!id && shouldResyncForm(pageFlash(event.detail.page), store)) {
175
+ if (latestBlock.current !== visitBlock.current) {
176
+ store.fill(latestBlock.current)
177
+ } else {
178
+ resyncPending.current = true
179
+ }
180
+
181
+ return
182
+ }
183
+
160
184
  if (!id || (event.detail.visitId ?? id) !== id) return
161
185
 
162
186
  // 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 } 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
 
@@ -65,10 +65,7 @@ export function useField<T = unknown>(field: BaseField): FieldBinding<T> {
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
  () =>
@@ -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
  )}
@@ -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
@@ -133,7 +133,7 @@ export function GlobalSearch({ variant = 'topbar' }: { variant?: 'topbar' | 'sid
133
133
  </span>
134
134
  )}
135
135
  {hydrated && suffix?.type === 'keyBinding' && (
136
- <kbd className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
136
+ <kbd dir="ltr" className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
137
137
  {formatKeyBinding(suffix.keyBinding)}
138
138
  </kbd>
139
139
  )}
@@ -24,7 +24,10 @@ export function ShortcutKeys({ binding, className }: { binding: string; classNam
24
24
  {t('shortcuts.then', {}, 'then')}
25
25
  </span>
26
26
  )}
27
- <kbd className="bg-muted text-muted-foreground rounded border px-1.5 font-mono text-[10px] leading-5">
27
+ <kbd
28
+ dir="ltr"
29
+ className="bg-muted text-muted-foreground rounded border px-1.5 font-mono text-[10px] leading-5"
30
+ >
28
31
  {step}
29
32
  </kbd>
30
33
  </span>
@@ -151,7 +151,7 @@ export function TenantMenu({ variant = 'sidebar' }: { variant?: 'sidebar' | 'top
151
151
  <DropdownMenuItem asChild>
152
152
  <Link href={tenancy.profileUrl} className="w-full">
153
153
  <Icon name="alfakit::panels.tenant-menu.profile-button" fallback={SettingsIcon} />{' '}
154
- {t('tenancy.profile', {}, 'Settings')}
154
+ {tenancy.profileLabel ?? t('tenancy.profile', {}, 'Settings')}
155
155
  </Link>
156
156
  </DropdownMenuItem>
157
157
  )
@@ -164,7 +164,7 @@ export function TenantMenu({ variant = 'sidebar' }: { variant?: 'sidebar' | 'top
164
164
  <DropdownMenuItem asChild>
165
165
  <Link href={tenancy.registrationUrl} className="w-full">
166
166
  <Icon name="alfakit::panels.tenant-menu.registration-button" fallback={PlusIcon} />{' '}
167
- {t('tenancy.register', {}, 'New')}
167
+ {tenancy.registrationLabel ?? t('tenancy.register', {}, 'New')}
168
168
  </Link>
169
169
  </DropdownMenuItem>
170
170
  )
@@ -26,7 +26,7 @@ import {
26
26
  } from '@alfakit/core'
27
27
  import { router } from '@inertiajs/react'
28
28
  import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
29
- import { createElement, useEffect, useRef, useState } from 'react'
29
+ import { createElement, useEffect, useId, useRef, useState } from 'react'
30
30
  import { usePanel, useRegistry, useT } from '../../context'
31
31
  import { extraProps } from '../../lib/attributes'
32
32
  import {
@@ -587,6 +587,8 @@ function NativeSelectCell(props: CellProps) {
587
587
  cell.state === null || cell.state === undefined ? '' : String(cell.state),
588
588
  )
589
589
 
590
+ const errorId = useId()
591
+
590
592
  return (
591
593
  <span onClick={(event) => event.stopPropagation()}>
592
594
  <select
@@ -596,7 +598,9 @@ function NativeSelectCell(props: CellProps) {
596
598
  setValue(event.target.value)
597
599
  void update(event.target.value === '' ? null : event.target.value)
598
600
  }}
601
+ aria-label={column.label || column.name}
599
602
  aria-invalid={error ? true : undefined}
603
+ aria-describedby={error ? errorId : undefined}
600
604
  className={cn(
601
605
  'border-input dark:bg-input/30 focus-visible:ring-ring/50 h-8 min-w-32 rounded-md border bg-transparent px-2 text-sm shadow-xs outline-none focus-visible:ring-[3px]',
602
606
  error && 'border-destructive',
@@ -611,7 +615,11 @@ function NativeSelectCell(props: CellProps) {
611
615
  </option>
612
616
  ))}
613
617
  </select>
614
- {error && <span className="text-destructive block text-xs">{error}</span>}
618
+ {error && (
619
+ <span id={errorId} className="text-destructive block text-xs">
620
+ {error}
621
+ </span>
622
+ )}
615
623
  </span>
616
624
  )
617
625
  }
@@ -626,6 +634,7 @@ function TextInputCell(props: CellProps) {
626
634
  const initial = cell.state === null || cell.state === undefined ? '' : String(cell.state)
627
635
  // `prefix()` / `suffix()`: text affixes around the input, like the TextInput field.
628
636
  const grouped = Boolean(cell.prefix || cell.suffix)
637
+ const errorId = useId()
629
638
 
630
639
  const field = (
631
640
  <span className={cn('relative block', grouped && 'min-w-0 flex-1')}>
@@ -648,7 +657,9 @@ function TextInputCell(props: CellProps) {
648
657
  step={schema.step}
649
658
  inputMode={schema.inputMode as React.HTMLAttributes<HTMLInputElement>['inputMode']}
650
659
  disabled={Boolean(cell.disabled) || saving}
660
+ aria-label={column.label || column.name}
651
661
  aria-invalid={error ? true : undefined}
662
+ aria-describedby={error ? errorId : undefined}
652
663
  className={cn(
653
664
  'h-8 min-w-32',
654
665
  schema.prefixIcon && 'ps-8',
@@ -706,7 +717,11 @@ function TextInputCell(props: CellProps) {
706
717
  ) : (
707
718
  field
708
719
  )}
709
- {error && <span className="text-destructive block text-xs">{error}</span>}
720
+ {error && (
721
+ <span id={errorId} className="text-destructive block text-xs">
722
+ {error}
723
+ </span>
724
+ )}
710
725
  </span>
711
726
  )
712
727
  }
@@ -14,6 +14,7 @@ import {
14
14
  import { ChevronDownIcon, ChevronRightIcon, LayersIcon } from 'lucide-react'
15
15
  import { useEffect, useState } from 'react'
16
16
  import { useT } from '../../context'
17
+ import { colorClass, textColorClasses } from '../../lib/colors'
17
18
  import { Icon } from '../../lib/icons'
18
19
  import { cn } from '../../lib/utils'
19
20
  import { Checkbox } from '../ui/checkbox'
@@ -183,7 +184,7 @@ export function GroupHeaderRow({
183
184
  selection?: { checked: boolean | 'indeterminate'; onChange: (checked: boolean) => void }
184
185
  }) {
185
186
  const t = useT()
186
- const content = (
187
+ const text = (
187
188
  <span className="flex flex-col items-start">
188
189
  <span className="font-medium">{group.title}</span>
189
190
  {group.description && (
@@ -191,6 +192,19 @@ export function GroupHeaderRow({
191
192
  )}
192
193
  </span>
193
194
  )
195
+ // An enum group shows its `HasIcon` icon, in its `HasColor` colour, before the title.
196
+ const content = group.icon ? (
197
+ <span data-slot="table-group-title" className="flex items-center gap-2">
198
+ <Icon
199
+ name={group.icon}
200
+ className={cn('size-4 shrink-0', colorClass(textColorClasses, group.color, 'gray'))}
201
+ aria-hidden="true"
202
+ />
203
+ {text}
204
+ </span>
205
+ ) : (
206
+ text
207
+ )
194
208
 
195
209
  return (
196
210
  <TableRow data-slot="table-group" className="bg-muted/50 hover:bg-muted/50">
@@ -1,5 +1,6 @@
1
- import { resolveToastPosition, type ToastPosition } from '@alfakit/core'
1
+ import { resolveToastPosition, STARTER_KIT_TOASTER_ID, type ToastPosition } from '@alfakit/core'
2
2
  import { useEffect, useMemo } from 'react'
3
+ import { setToasterId } from '../components/notifications/flash-toasts'
3
4
  import { Toaster } from '../components/ui/sonner'
4
5
  import { useOptionalAlfakit, type AlfakitContextValue } from '../context'
5
6
  import { claimToaster, createStandaloneContext, ensureFlashToasts } from './runtime'
@@ -46,7 +47,25 @@ export function AlfakitToaster({
46
47
  ensureFlashToasts(context.registry)
47
48
  }, [context])
48
49
 
50
+ // Our own toaster gets an id, so a `<Toaster />` the app's layout already mounts (the
51
+ // starter kits have one) doesn't show every Alfakit toast a second time.
52
+ useEffect(() => {
53
+ if (!toaster) return undefined
54
+
55
+ setToasterId(STARTER_KIT_TOASTER_ID)
56
+
57
+ return () => setToasterId(undefined)
58
+ }, [toaster])
59
+
49
60
  if (!toaster) return null
50
61
 
51
- return <Toaster position={resolveToastPosition(position, dir)} dir={dir} richColors closeButton />
62
+ return (
63
+ <Toaster
64
+ id={STARTER_KIT_TOASTER_ID}
65
+ position={resolveToastPosition(position, dir)}
66
+ dir={dir}
67
+ richColors
68
+ closeButton
69
+ />
70
+ )
52
71
  }
@@ -207,7 +207,7 @@ function TenantChooser({ panel }: { panel: PanelSchema }) {
207
207
  {tenancy.tenants.length > 0 && <DropdownMenuSeparator />}
208
208
  <DropdownMenuItem onSelect={() => router.visit(tenancy.registrationUrl!)}>
209
209
  <Icon name="alfakit::panels.tenant-menu.registration-button" fallback={PlusIcon} />
210
- {t('tenancy.register', {}, 'New')}
210
+ {tenancy.registrationLabel ?? t('tenancy.register', {}, 'New')}
211
211
  </DropdownMenuItem>
212
212
  </>
213
213
  )}
@@ -49,7 +49,7 @@ export function SearchTrigger({ source, onLoad }: WidgetProps & { onLoad?: () =>
49
49
  <SearchIcon className="size-4" aria-hidden="true" />
50
50
  <span className="sr-only sm:not-sr-only sm:flex-1 sm:text-start">{placeholder}</span>
51
51
  {hydrated && binding && (
52
- <kbd className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
52
+ <kbd dir="ltr" className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
53
53
  {formatKeyBinding(binding)}
54
54
  </kbd>
55
55
  )}