@alfakit/react 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/components/dev-tools/dev-tools-inspector.js +1 -1
  3. package/dist/components/dev-tools/dev-tools-inspector.js.map +1 -1
  4. package/dist/components/fields/checkbox-list.js +2 -2
  5. package/dist/components/fields/checkbox-list.js.map +1 -1
  6. package/dist/components/fields/rich-editor-extensions.js +14 -4
  7. package/dist/components/fields/rich-editor-extensions.js.map +1 -1
  8. package/dist/components/file-manager/file-manager.js +2 -0
  9. package/dist/components/file-manager/file-manager.js.map +1 -1
  10. package/dist/components/layout/elements.d.ts.map +1 -1
  11. package/dist/components/layout/elements.js +16 -5
  12. package/dist/components/layout/elements.js.map +1 -1
  13. package/dist/components/layout/mixed-content.d.ts +6 -4
  14. package/dist/components/layout/mixed-content.d.ts.map +1 -1
  15. package/dist/components/layout/mixed-content.js +6 -4
  16. package/dist/components/layout/mixed-content.js.map +1 -1
  17. package/dist/components/layout/section.js +4 -2
  18. package/dist/components/layout/section.js.map +1 -1
  19. package/dist/components/notifications/notifications-menu.d.ts.map +1 -1
  20. package/dist/components/notifications/notifications-menu.js +45 -17
  21. package/dist/components/notifications/notifications-menu.js.map +1 -1
  22. package/dist/components/panel/shortcuts-overlay.js +13 -10
  23. package/dist/components/panel/shortcuts-overlay.js.map +1 -1
  24. package/dist/components/table/column-manager.js +4 -4
  25. package/dist/components/table/column-manager.js.map +1 -1
  26. package/dist/components/table/spreadsheet.js +8 -1
  27. package/dist/components/table/spreadsheet.js.map +1 -1
  28. package/dist/components/translation-manager/translation-manager.js +1 -1
  29. package/dist/components/translation-manager/translation-manager.js.map +1 -1
  30. package/dist/components/widgets/stats-overview.js +3 -3
  31. package/dist/components/widgets/stats-overview.js.map +1 -1
  32. package/dist/eject-manifest.json +2 -2
  33. package/dist/pages/page.d.ts.map +1 -1
  34. package/dist/pages/page.js +4 -7
  35. package/dist/pages/page.js.map +1 -1
  36. package/dist/standalone/alfakit-component.d.ts +0 -10
  37. package/dist/standalone/alfakit-component.d.ts.map +1 -1
  38. package/dist/standalone/alfakit-component.js +3 -2
  39. package/dist/standalone/alfakit-component.js.map +1 -1
  40. package/dist/standalone/runtime.js +2 -1
  41. package/dist/standalone/runtime.js.map +1 -1
  42. package/dist/standalone.css +2 -1
  43. package/dist/starter-kit/navigation.d.ts +6 -1
  44. package/dist/starter-kit/navigation.d.ts.map +1 -1
  45. package/dist/starter-kit/navigation.js +3 -2
  46. package/dist/starter-kit/navigation.js.map +1 -1
  47. package/dist/starter-kit/starter-kit.d.ts.map +1 -1
  48. package/dist/starter-kit/starter-kit.js +1 -1
  49. package/dist/starter-kit/starter-kit.js.map +1 -1
  50. package/dist/styles.css +2 -1
  51. package/dist/themes.css +4 -0
  52. package/package.json +2 -2
  53. package/src/components/dev-tools/dev-tools-inspector.tsx +1 -1
  54. package/src/components/fields/checkbox-list.tsx +2 -2
  55. package/src/components/fields/rich-editor-extensions.tsx +24 -2
  56. package/src/components/file-manager/file-manager.tsx +3 -1
  57. package/src/components/layout/elements.tsx +27 -4
  58. package/src/components/layout/mixed-content.tsx +8 -4
  59. package/src/components/layout/section.tsx +2 -2
  60. package/src/components/notifications/notifications-menu.tsx +42 -1
  61. package/src/components/panel/shortcuts-overlay.tsx +29 -24
  62. package/src/components/table/column-manager.tsx +6 -4
  63. package/src/components/table/spreadsheet.tsx +18 -7
  64. package/src/components/translation-manager/translation-manager.tsx +1 -1
  65. package/src/components/widgets/stats-overview.tsx +6 -3
  66. package/src/pages/page.tsx +7 -3
  67. package/src/standalone/alfakit-component.tsx +7 -2
  68. package/src/standalone/runtime.ts +3 -0
  69. package/src/starter-kit/navigation.ts +12 -1
  70. package/src/starter-kit/starter-kit.tsx +3 -2
  71. package/src/styles.css +2 -1
@@ -11,6 +11,7 @@ import {
11
11
  customBlockNodeConfig,
12
12
  EditorSuggestions,
13
13
  fieldActionContext,
14
+ fixedContainingBlockOffset,
14
15
  groupCustomBlocks,
15
16
  groupTriggerButton,
16
17
  isCustomBlockResult,
@@ -358,6 +359,9 @@ export function ToolbarGroupButton({
358
359
  onRun: (name: string) => void
359
360
  }) {
360
361
  const buttons = group.buttons.filter((name) => commands[name])
362
+ // After a pick, typing goes on in the editor (the command focused it); the menu would
363
+ // otherwise return focus to its trigger. Closing without a pick (Escape) still does.
364
+ const picked = useRef(false)
361
365
 
362
366
  if (buttons.length === 0) return null
363
367
 
@@ -385,6 +389,13 @@ export function ToolbarGroupButton({
385
389
  align="start"
386
390
  aria-label={group.label}
387
391
  className={cn(!group.textual && 'flex min-w-0 flex-row gap-0.5')}
392
+ onCloseAutoFocus={(event) => {
393
+ if (!picked.current) return
394
+
395
+ picked.current = false
396
+ event.preventDefault()
397
+ editor.commands.focus()
398
+ }}
388
399
  >
389
400
  {buttons.map((name) => {
390
401
  const label = commandLabel(name, commands, translate)
@@ -396,7 +407,10 @@ export function ToolbarGroupButton({
396
407
  aria-label={group.textual ? undefined : label}
397
408
  title={label}
398
409
  className={cn(pressed && 'bg-accent text-accent-foreground')}
399
- onSelect={() => onRun(name)}
410
+ onSelect={() => {
411
+ picked.current = true
412
+ onRun(name)
413
+ }}
400
414
  >
401
415
  {icons(name)}
402
416
  {group.textual && <span>{label}</span>}
@@ -436,7 +450,15 @@ export function SuggestionPopup({
436
450
  data-slot="rich-editor-suggestions"
437
451
  className="bg-popover text-popover-foreground fixed z-50 min-w-48 overflow-hidden rounded-md border p-1 shadow-md"
438
452
  // Runtime coordinates cannot be Tailwind classes.
439
- style={state.coords ? { left: state.coords.left, top: state.coords.bottom + 4 } : undefined}
453
+ // Inside a modal the dialog's transform is the containing block of this fixed list.
454
+ style={
455
+ state.coords
456
+ ? {
457
+ left: state.coords.left - fixedContainingBlockOffset(editor.view.dom).x,
458
+ top: state.coords.bottom + 4 - fixedContainingBlockOffset(editor.view.dom).y,
459
+ }
460
+ : undefined
461
+ }
440
462
  >
441
463
  {state.loading && state.items.length === 0 ? (
442
464
  <div className="text-muted-foreground px-2 py-1.5 text-sm">
@@ -1389,7 +1389,8 @@ function Dialogs({
1389
1389
  open={dialog?.type === 'createFolder' || dialog?.type === 'rename'}
1390
1390
  onOpenChange={(open) => !open && close()}
1391
1391
  >
1392
- <DialogContent data-slot="file-manager-dialog">
1392
+ {/* The title and the labelled field say it all: no description. */}
1393
+ <DialogContent data-slot="file-manager-dialog" aria-describedby={undefined}>
1393
1394
  <form
1394
1395
  className="flex flex-col gap-4"
1395
1396
  onSubmit={(event) => {
@@ -1763,6 +1764,7 @@ function Preview({
1763
1764
  <DialogContent
1764
1765
  className="flex max-h-[90vh] flex-col sm:max-w-4xl"
1765
1766
  data-slot="file-manager-preview"
1767
+ aria-describedby={undefined}
1766
1768
  onKeyDown={onKeyDown}
1767
1769
  >
1768
1770
  <DialogHeader>
@@ -25,6 +25,8 @@ import { Icon } from '../../lib/icons'
25
25
  import { cn } from '../../lib/utils'
26
26
  import { usePageActionContext } from '../actions/action-context'
27
27
  import { ActionList } from '../actions/action-list'
28
+ import { useOptionalFormStore } from '../form/form-context'
29
+ import { useFieldActionContext } from '../schema/field-actions'
28
30
  import { RenderComponent } from '../schema/schema-renderer'
29
31
  import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
30
32
  import { MixedContent } from './mixed-content'
@@ -67,7 +69,12 @@ const CALLOUT_ICON_SIZES: Record<string, string> = {
67
69
  }
68
70
 
69
71
  export function CalloutComponent({ component }: { component: CalloutSchema }) {
70
- const context = usePageActionContext()
72
+ // Inside a form the actions run as field actions of this component, so `$get` / `$set`
73
+ // reach the form (like a section's header actions).
74
+ const form = useOptionalFormStore()
75
+ const pageContext = usePageActionContext()
76
+ const fieldContext = useFieldActionContext(component)
77
+ const context = form ? fieldContext : pageContext
71
78
  const tone = component.color ?? component.status
72
79
  // `iconColor()`, or the status colour once `color(null)` removed the background.
73
80
  const iconColor = component.iconColor ?? (component.color ? undefined : component.status)
@@ -113,19 +120,30 @@ export function CalloutComponent({ component }: { component: CalloutSchema }) {
113
120
  className={cn('mt-2', actionsAlignmentClass(component.footerActionsAlignment))}
114
121
  />
115
122
  )}
116
- <MixedContent items={component.footer} name="callout-footer" className="mt-2" />
123
+ <MixedContent
124
+ items={component.footer}
125
+ name="callout-footer"
126
+ className="mt-2"
127
+ context={context}
128
+ />
117
129
  </div>
118
130
  <MixedContent
119
131
  items={component.controls}
120
132
  name="callout-controls"
121
133
  className="-me-1 -mt-1 shrink-0 gap-1"
134
+ context={context}
122
135
  />
123
136
  </div>
124
137
  )
125
138
  }
126
139
 
127
140
  export function EmptyStateComponent({ component }: { component: EmptyStateComponentSchema }) {
128
- const context = usePageActionContext()
141
+ // Inside a form the actions run as field actions of this component, so `$get` / `$set`
142
+ // reach the form (like a section's header actions).
143
+ const form = useOptionalFormStore()
144
+ const pageContext = usePageActionContext()
145
+ const fieldContext = useFieldActionContext(component)
146
+ const context = form ? fieldContext : pageContext
129
147
 
130
148
  return (
131
149
  <div
@@ -158,7 +176,12 @@ export function EmptyStateComponent({ component }: { component: EmptyStateCompon
158
176
  {component.actions && component.actions.length > 0 && (
159
177
  <ActionList actions={component.actions} context={context} className="mt-2 justify-center" />
160
178
  )}
161
- <MixedContent items={component.footer} name="empty-state-footer" className="justify-center" />
179
+ <MixedContent
180
+ items={component.footer}
181
+ name="empty-state-footer"
182
+ className="justify-center"
183
+ context={context}
184
+ />
162
185
  </div>
163
186
  )
164
187
  }
@@ -1,4 +1,4 @@
1
- import type { AnyActionSchema, ContentSlotItem } from '@alfakit/core'
1
+ import type { ActionContext, AnyActionSchema, ContentSlotItem } from '@alfakit/core'
2
2
  import type { ReactNode } from 'react'
3
3
  import { cn } from '../../lib/utils'
4
4
  import { usePageActionContext } from '../actions/action-context'
@@ -7,19 +7,23 @@ import { RenderComponent } from '../schema/schema-renderer'
7
7
 
8
8
  /**
9
9
  * Actions and components placed in a layout component's fixed spot (a section's
10
- * `afterHeader()`, a callout's `controls()`). Actions run in the page's action context,
11
- * like section header actions.
10
+ * `afterHeader()`, a callout's `controls()`). Actions run in the owner's action context
11
+ * (`context`: inside a form, the owner's field context, so `$get` / `$set` work), else the
12
+ * page's.
12
13
  */
13
14
  export function MixedContent({
14
15
  items,
15
16
  name,
16
17
  className,
18
+ context: ownerContext,
17
19
  }: {
18
20
  items: ContentSlotItem[] | undefined
19
21
  name: string
20
22
  className?: string
23
+ context?: ActionContext
21
24
  }) {
22
- const context = usePageActionContext()
25
+ const pageContext = usePageActionContext()
26
+ const context = ownerContext ?? pageContext
23
27
 
24
28
  if (!items?.length) {
25
29
  return null
@@ -86,7 +86,7 @@ export function SectionComponent({ component }: { component: SectionSchema }) {
86
86
  {component.headerActions && component.headerActions.length > 0 && (
87
87
  <ActionList actions={component.headerActions} context={context} size="sm" />
88
88
  )}
89
- <MixedContent items={component.afterHeader} name="section-after-header" />
89
+ <MixedContent items={component.afterHeader} name="section-after-header" context={context} />
90
90
  {component.collapsible && (
91
91
  <CollapsibleTrigger asChild>
92
92
  <Button
@@ -123,7 +123,7 @@ export function SectionComponent({ component }: { component: SectionSchema }) {
123
123
  className={actionsAlignmentClass(component.footerActionsAlignment) || undefined}
124
124
  />
125
125
  )}
126
- <MixedContent items={component.footer} name="section-footer-content" />
126
+ <MixedContent items={component.footer} name="section-footer-content" context={context} />
127
127
  </div>
128
128
  ) : null
129
129
 
@@ -3,9 +3,13 @@ import {
3
3
  notificationTintStyles,
4
4
  onDatabaseNotificationsSent,
5
5
  onOpenDatabaseNotifications,
6
+ pwaStore,
7
+ startPwa,
8
+ togglePushNotifications,
6
9
  } from '@alfakit/core'
7
- import { BellIcon, CheckCheckIcon, Trash2Icon } from 'lucide-react'
10
+ import { BellIcon, BellOffIcon, BellRingIcon, CheckCheckIcon, Trash2Icon } from 'lucide-react'
8
11
  import { useEffect, useMemo, useState } from 'react'
12
+ import { toast } from 'sonner'
9
13
  import { useDirection, useFormatter, usePanel, useT } from '../../context'
10
14
  import { useStore } from '../../hooks/use-store'
11
15
  import { cssColor, textColorClasses } from '../../lib/colors'
@@ -86,6 +90,29 @@ export function NotificationsMenu({ variant = 'topbar' }: NotificationsMenuProps
86
90
  [center],
87
91
  )
88
92
 
93
+ // `pwa(push: true)` in a starter kit: the app's user menu has no AlfaKit entries, so Web
94
+ // Push is turned on or off from the bell's menu.
95
+ const pwa = useStore(pwaStore)
96
+ const kitPush = Boolean(panel.starterKit && panel.pwa?.push)
97
+ const pushToggle = kitPush && pwa.push !== 'unsupported'
98
+
99
+ useEffect(() => {
100
+ if (kitPush) startPwa(panel.pwa)
101
+ }, [kitPush, panel.pwa])
102
+
103
+ const togglePush = async () => {
104
+ const { message } = await togglePushNotifications()
105
+ const text = t(message.key, {}, message.fallback)
106
+
107
+ if (message.error) toast.error(text)
108
+ else toast.success(text)
109
+ }
110
+
111
+ const pushLabel =
112
+ pwa.push === 'subscribed'
113
+ ? t('pwa.notifications.disable', {}, 'Turn off notifications')
114
+ : t('pwa.notifications.enable', {}, 'Turn on notifications')
115
+
89
116
  if (!config || !center) {
90
117
  return null
91
118
  }
@@ -145,6 +172,20 @@ export function NotificationsMenu({ variant = 'topbar' }: NotificationsMenuProps
145
172
  <div className="flex items-center justify-between border-b px-3 py-2">
146
173
  <span className="text-sm font-semibold">{t('notifications', {}, 'Notifications')}</span>
147
174
  <div className="flex items-center gap-1">
175
+ {pushToggle && (
176
+ <Button
177
+ type="button"
178
+ variant="ghost"
179
+ size="icon-sm"
180
+ data-slot="pwa-push"
181
+ disabled={pwa.pushBusy || pwa.push === 'denied'}
182
+ title={pushLabel}
183
+ aria-label={pushLabel}
184
+ onClick={() => void togglePush()}
185
+ >
186
+ {pwa.push === 'subscribed' ? <BellOffIcon /> : <BellRingIcon />}
187
+ </Button>
188
+ )}
148
189
  {state.unreadCount > 0 && (
149
190
  <Button
150
191
  type="button"
@@ -1,6 +1,6 @@
1
1
  import type { ShortcutListGroup, ShortcutListItem } from '@alfakit/core'
2
2
  import {
3
- bindingFromEvent,
3
+ ShortcutRecorder,
4
4
  flattenActionGroup,
5
5
  saveShortcutOverrides,
6
6
  shortcutOverlayGroups,
@@ -52,50 +52,55 @@ export function ShortcutsOverlay({
52
52
  }
53
53
  }
54
54
 
55
- // While recording, the next key press is the new binding (Escape cancels). Listening in the
56
- // capture phase keeps the key from closing the dialog or running another shortcut.
55
+ // While recording, the keys pressed are the new binding: one key (with modifiers) or a
56
+ // sequence ("g o"), finished a second after the last key (Escape cancels). Listening in
57
+ // the capture phase keeps the keys from closing the dialog or running another shortcut.
57
58
  useEffect(() => {
58
59
  if (!recording) return
59
60
 
60
61
  shortcuts.setPaused(true)
61
62
 
62
- const listener = (event: KeyboardEvent) => {
63
- event.preventDefault()
64
- event.stopPropagation()
65
-
66
- if (event.key === 'Escape') {
63
+ const recorder = new ShortcutRecorder({
64
+ onDone: (binding) => {
67
65
  setRecording(null)
68
- setMessage(t('shortcuts.cancelled', {}, 'Change cancelled.'))
69
66
 
70
- return
71
- }
67
+ const conflict = shortcuts.conflict([binding], recording)
72
68
 
73
- const binding = bindingFromEvent(event)
69
+ if (conflict) {
70
+ setMessage(
71
+ t(
72
+ 'shortcuts.conflict',
73
+ { label: conflict.label },
74
+ `Already used by “${conflict.label}”.`,
75
+ ),
76
+ )
74
77
 
75
- if (!binding) return
78
+ return
79
+ }
76
80
 
77
- const conflict = shortcuts.conflict([binding], recording)
81
+ void save(recording, [binding])
82
+ },
83
+ })
78
84
 
79
- setRecording(null)
85
+ const listener = (event: KeyboardEvent) => {
86
+ event.preventDefault()
87
+ event.stopPropagation()
80
88
 
81
- if (conflict) {
82
- setMessage(
83
- t(
84
- 'shortcuts.conflict',
85
- { label: conflict.label },
86
- `Already used by “${conflict.label}”.`,
87
- ),
88
- )
89
+ if (event.key === 'Escape') {
90
+ recorder.cancel()
91
+ setRecording(null)
92
+ setMessage(t('shortcuts.cancelled', {}, 'Change cancelled.'))
89
93
 
90
94
  return
91
95
  }
92
96
 
93
- void save(recording, [binding])
97
+ recorder.press(event)
94
98
  }
95
99
 
96
100
  window.addEventListener('keydown', listener, true)
97
101
 
98
102
  return () => {
103
+ recorder.cancel()
99
104
  window.removeEventListener('keydown', listener, true)
100
105
  shortcuts.setPaused(false)
101
106
  }
@@ -114,13 +114,13 @@ export function ColumnManager({
114
114
  }
115
115
  }
116
116
 
117
- const save = async (order: string[]): Promise<boolean> => {
117
+ const save = async (order: string[], options: { reload?: boolean } = {}): Promise<boolean> => {
118
118
  if (!panel.endpoints.tableColumns) return false
119
119
 
120
120
  setSaving(true)
121
121
 
122
122
  try {
123
- await saveColumnOrder(panel.endpoints.tableColumns, token, component, order)
123
+ await saveColumnOrder(panel.endpoints.tableColumns, token, component, order, options)
124
124
 
125
125
  return true
126
126
  } catch (error) {
@@ -203,7 +203,8 @@ export function ColumnManager({
203
203
  setAnnouncement('')
204
204
  setOpen(false)
205
205
 
206
- if (orderDraft && orderChanged) await save(orderDraft)
206
+ // The visibility visit renders the new order too: no second reload racing it.
207
+ if (orderDraft && orderChanged) await save(orderDraft, { reload: !columns })
207
208
 
208
209
  if (columns) visitTable(table.identifier, { columns }, ['page'])
209
210
  }
@@ -212,7 +213,8 @@ export function ColumnManager({
212
213
  const reset = async () => {
213
214
  if (reorderable) {
214
215
  setOrderDraft(null)
215
- await save([])
216
+ // The visibility visit below renders the defined order too.
217
+ await save([], { reload: !toggleable })
216
218
  }
217
219
 
218
220
  if (toggleable) {
@@ -539,14 +539,25 @@ export function Spreadsheet({ table, token, component, onClose }: SpreadsheetPro
539
539
  <DialogHeader>
540
540
  <DialogTitle>{t('table.spreadsheet.paste_title', {}, 'Paste cells')}</DialogTitle>
541
541
  <DialogDescription>
542
- {choice(
543
- 'table.spreadsheet.paste_summary',
544
- preview?.plan.changes.length ?? 0,
545
- {},
546
- ':count cell will change|:count cells will change',
542
+ {/* One line per count: translations can't join two sentences. */}
543
+ <span className="block">
544
+ {choice(
545
+ 'table.spreadsheet.paste_summary',
546
+ preview?.plan.changes.length ?? 0,
547
+ {},
548
+ ':count cell will change|:count cells will change',
549
+ )}
550
+ </span>
551
+ {(preview?.plan.skipped ?? 0) > 0 && (
552
+ <span className="block" data-slot="spreadsheet-paste-skipped">
553
+ {choice(
554
+ 'table.spreadsheet.paste_skipped',
555
+ preview?.plan.skipped ?? 0,
556
+ {},
557
+ ':count read-only cell is skipped|:count read-only cells are skipped',
558
+ )}
559
+ </span>
547
560
  )}
548
- {(preview?.plan.skipped ?? 0) > 0 &&
549
- ` ${choice('table.spreadsheet.paste_skipped', preview?.plan.skipped ?? 0, {}, ':count read-only cell is skipped|:count read-only cells are skipped')}`}
550
561
  </DialogDescription>
551
562
  </DialogHeader>
552
563
  <div data-slot="spreadsheet-paste" className="flex flex-col gap-3">
@@ -330,7 +330,7 @@ function TranslationManagerView({ schema }: { schema: TranslationManagerSchema }
330
330
  <tr>
331
331
  <th
332
332
  scope="col"
333
- className="bg-muted sticky start-0 z-10 min-w-56 border-b px-3 py-2 text-start font-medium"
333
+ className="bg-card sticky start-0 z-10 min-w-56 border-b [background-image:linear-gradient(var(--muted),var(--muted))] px-3 py-2 text-start font-medium"
334
334
  >
335
335
  {t('translation-manager.key', {}, 'Key')}
336
336
  </th>
@@ -71,7 +71,9 @@ function StatCard({ stat }: { stat: StatSchema }) {
71
71
  >
72
72
  <div className="flex items-start justify-between gap-3">
73
73
  <div className="flex flex-col gap-1">
74
- <span className="text-muted-foreground text-sm font-medium">{stat.label}</span>
74
+ <span className="text-muted-foreground text-sm font-medium">
75
+ <bdi>{stat.label}</bdi>
76
+ </span>
75
77
  {(stat.value === null || stat.value === '') && stat.placeholder ? (
76
78
  <span
77
79
  className="text-muted-foreground text-2xl font-semibold tracking-tight"
@@ -86,7 +88,7 @@ function StatCard({ stat }: { stat: StatSchema }) {
86
88
  stat.color && textColorClasses[stat.color],
87
89
  )}
88
90
  >
89
- {stat.value}
91
+ <bdi>{stat.value}</bdi>
90
92
  </span>
91
93
  )}
92
94
  {stat.description && (
@@ -101,7 +103,8 @@ function StatCard({ stat }: { stat: StatSchema }) {
101
103
  {stat.descriptionIcon && stat.descriptionIconPosition !== 'after' && (
102
104
  <Icon name={stat.descriptionIcon} className="size-3.5" />
103
105
  )}
104
- {stat.description}
106
+ {/* Mixed-direction text (a Latin "-3% this month" in Arabic) keeps its order. */}
107
+ <bdi>{stat.description}</bdi>
105
108
  {stat.descriptionIcon && stat.descriptionIconPosition === 'after' && (
106
109
  <Icon name={stat.descriptionIcon} className="size-3.5" />
107
110
  )}
@@ -11,6 +11,7 @@ import type {
11
11
  } from '@alfakit/core'
12
12
  import {
13
13
  applyBodyAttributes,
14
+ customPageProps,
14
15
  hasRelationManagerTabs,
15
16
  isLazyPlaceholder,
16
17
  openDefaultAction,
@@ -102,9 +103,12 @@ export function AlfakitPage() {
102
103
  return (
103
104
  <PageActionProvider value={{ token: page.token, component: 'page' }}>
104
105
  <Head title={title} />
105
- <PageShell page={page}>
106
- <Custom {...page.view.props} page={page} />
107
- </PageShell>
106
+ {/* The component brings its own PageShell (as in panel mode, where it is the Inertia
107
+ page itself) and takes the live props, so `reloadProps()` reaches it. */}
108
+ <Custom
109
+ {...customPageProps(page.view.props, props as Record<string, unknown>)}
110
+ page={page}
111
+ />
108
112
  </PageActionProvider>
109
113
  )
110
114
  }
@@ -8,7 +8,7 @@ import {
8
8
  } from '@alfakit/core'
9
9
  import { usePage } from '@inertiajs/react'
10
10
  import { Direction } from 'radix-ui'
11
- import { useEffect, useMemo, useState, type ReactNode } from 'react'
11
+ import { useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from 'react'
12
12
  import { PageActionProvider } from '../components/actions/action-context'
13
13
  import { ActionList } from '../components/actions/action-list'
14
14
  import { ActionModals } from '../components/actions/action-modals'
@@ -55,6 +55,9 @@ export interface AlfakitComponentProps {
55
55
  * <AlfakitComponent component={orders} className="max-w-4xl" />
56
56
  * ```
57
57
  */
58
+ // `useLayoutEffect` warns during a server render, where nothing registers anyway.
59
+ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
60
+
58
61
  export function AlfakitComponent({
59
62
  component,
60
63
  components,
@@ -138,7 +141,9 @@ export function AlfakitComponent({
138
141
  // Partial reloads (sorting, paging, inline edits) request this component's own prop.
139
142
  const propName = findStandalonePropName(props, component)
140
143
 
141
- useEffect(() => registerStandaloneProp(propName ?? null), [propName])
144
+ // A layout effect runs before the children's effects: a `deferLoading()` table's first
145
+ // reload must already ask for this component's prop, not the panel `page` prop.
146
+ useIsomorphicLayoutEffect(() => registerStandaloneProp(propName ?? null), [propName])
142
147
 
143
148
  useEffect(() => {
144
149
  void state.loadLazy(component)
@@ -3,6 +3,7 @@ import {
3
3
  ComponentRegistry,
4
4
  FlashNotificationTracker,
5
5
  Translator,
6
+ installPrefetchInvalidation,
6
7
  reloadOnExpiredPage,
7
8
  resolveRequestErrorNotification,
8
9
  type NotificationSchema,
@@ -27,6 +28,8 @@ export function ensureStandaloneRuntime(): void {
27
28
 
28
29
  browserReady = true
29
30
  reloadOnExpiredPage()
31
+ // Pages restored by Back after a change are reloaded; shared with a surrounding panel.
32
+ installPrefetchInvalidation()
30
33
  }
31
34
 
32
35
  /**
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  starterKitNavigation,
3
3
  toStarterKitNavGroups,
4
+ toStarterKitUserMenuItems,
4
5
  type StarterKitNavGroup,
5
6
  type StarterKitNavigationSchema,
7
+ type StarterKitUserMenuItem,
6
8
  } from '@alfakit/core'
7
9
  import { usePage } from '@inertiajs/react'
8
10
  import { useMemo } from 'react'
@@ -12,6 +14,11 @@ export interface AlfakitNavigation {
12
14
  navigation: StarterKitNavigationSchema | null
13
15
  /** Its groups as plain items (`title`, `href`, `icon`, `isActive`, `badge`, `children`). */
14
16
  groups: StarterKitNavGroup[]
17
+ /**
18
+ * The panel's user menu links (`userMenuItems()`, Billing's entry…) for the kit's user
19
+ * menu: `<AlfakitUserMenuItems />` renders them with the app's dropdown items.
20
+ */
21
+ userMenuItems: StarterKitUserMenuItem[]
15
22
  }
16
23
 
17
24
  /**
@@ -28,7 +35,11 @@ export function useAlfakitNavigation(panelId?: string): AlfakitNavigation {
28
35
  const navigation = starterKitNavigation(props, panelId)
29
36
 
30
37
  return useMemo(
31
- () => ({ navigation, groups: toStarterKitNavGroups(navigation?.groups) }),
38
+ () => ({
39
+ navigation,
40
+ groups: toStarterKitNavGroups(navigation?.groups),
41
+ userMenuItems: toStarterKitUserMenuItems(navigation?.userMenu),
42
+ }),
32
43
  [navigation],
33
44
  )
34
45
  }
@@ -185,8 +185,9 @@ export function AlfakitStarterKitRoot({
185
185
  <AlfakitRoot toasterId={STARTER_KIT_TOASTER_ID}>
186
186
  {children}
187
187
  {/* The debug panel's button lives in AlfaKit's top bar, which a starter kit doesn't
188
- render: float it in the corner instead (nothing without `alfakitDebug`). */}
189
- <div data-slot="starter-kit-debug-panel" className="fixed end-16 bottom-4 z-40">
188
+ render: float it in the corner instead (nothing without `alfakitDebug`). The corner is the
189
+ document's right: the kit's sidebar stays on the left in a right-to-left panel language. */}
190
+ <div data-slot="starter-kit-debug-panel" className="fixed right-16 bottom-16 z-40">
190
191
  <DebugPanelSlot />
191
192
  </div>
192
193
  </AlfakitRoot>
package/src/styles.css CHANGED
@@ -289,7 +289,8 @@
289
289
  }
290
290
  /* Rich editor mentions, merge tags and custom blocks (RichEditor extensions) */
291
291
  .alfakit-prose :where(.alfakit-mention):not(:where(.alfakit-not-prose *)) {
292
- @apply bg-primary/10 text-primary rounded px-1 font-medium;
292
+ /* Full-contrast text on the tint (WCAG AA); the ring marks it as a mention. */
293
+ @apply bg-primary/10 text-foreground ring-primary/30 rounded px-1 font-medium ring-1;
293
294
  }
294
295
  .alfakit-prose :where(.alfakit-merge-tag):not(:where(.alfakit-not-prose *)) {
295
296
  @apply bg-muted text-muted-foreground rounded px-1 font-mono text-xs;