@alfakit/react 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/components/cms/blocks.d.ts.map +1 -1
  3. package/dist/components/cms/blocks.js +2 -2
  4. package/dist/components/cms/blocks.js.map +1 -1
  5. package/dist/components/cms/head.d.ts +6 -4
  6. package/dist/components/cms/head.d.ts.map +1 -1
  7. package/dist/components/cms/head.js +10 -4
  8. package/dist/components/cms/head.js.map +1 -1
  9. package/dist/components/cms/page.d.ts.map +1 -1
  10. package/dist/components/cms/page.js +16 -3
  11. package/dist/components/cms/page.js.map +1 -1
  12. package/dist/components/fields/file-upload.js +4 -4
  13. package/dist/components/fields/file-upload.js.map +1 -1
  14. package/dist/components/fields/rich-editor.js +4 -2
  15. package/dist/components/fields/rich-editor.js.map +1 -1
  16. package/dist/components/fields/slider.js +1 -1
  17. package/dist/components/fields/slider.js.map +1 -1
  18. package/dist/components/helpdesk/chat-widget.js +1 -1
  19. package/dist/components/layout/section.js +1 -1
  20. package/dist/components/layout/tabs.js +1 -1
  21. package/dist/components/panel/app-sidebar.d.ts.map +1 -1
  22. package/dist/components/panel/app-sidebar.js +27 -5
  23. package/dist/components/panel/app-sidebar.js.map +1 -1
  24. package/dist/components/panel/global-search.js +2 -2
  25. package/dist/components/panel/global-search.js.map +1 -1
  26. package/dist/components/panel/shortcut-keys.js +1 -1
  27. package/dist/components/panel/shortcut-keys.js.map +1 -1
  28. package/dist/components/table/list-tabs.js +1 -1
  29. package/dist/components/table/list-tabs.js.map +1 -1
  30. package/dist/components/table/table-views.js +1 -1
  31. package/dist/components/table/table-views.js.map +1 -1
  32. package/dist/components/versions/record-versions.js +1 -1
  33. package/dist/context.d.ts +8 -0
  34. package/dist/context.d.ts.map +1 -1
  35. package/dist/context.js +27 -2
  36. package/dist/context.js.map +1 -1
  37. package/dist/create-alfakit.d.ts.map +1 -1
  38. package/dist/create-alfakit.js +3 -2
  39. package/dist/create-alfakit.js.map +1 -1
  40. package/dist/eject-manifest.json +2 -2
  41. package/dist/index.js +1 -1
  42. package/dist/layouts/app-layout.d.ts.map +1 -1
  43. package/dist/layouts/app-layout.js +13 -3
  44. package/dist/layouts/app-layout.js.map +1 -1
  45. package/dist/lib/discovery.d.ts +4 -0
  46. package/dist/lib/discovery.d.ts.map +1 -1
  47. package/dist/lib/discovery.js +17 -7
  48. package/dist/lib/discovery.js.map +1 -1
  49. package/dist/lib/icons.d.ts +2 -1
  50. package/dist/lib/icons.d.ts.map +1 -1
  51. package/dist/lib/icons.js +55 -19
  52. package/dist/lib/icons.js.map +1 -1
  53. package/dist/pages/page.d.ts +1 -1
  54. package/dist/pages/page.d.ts.map +1 -1
  55. package/dist/pages/page.js +47 -10
  56. package/dist/pages/page.js.map +1 -1
  57. package/dist/starter-kit/widgets-triggers.js +1 -1
  58. package/dist/starter-kit/widgets-triggers.js.map +1 -1
  59. package/dist/themes.css +92 -0
  60. package/package.json +2 -2
  61. package/src/components/cms/blocks.tsx +7 -1
  62. package/src/components/cms/head.tsx +19 -4
  63. package/src/components/cms/page.tsx +17 -2
  64. package/src/components/fields/file-upload.tsx +5 -4
  65. package/src/components/fields/rich-editor.tsx +7 -2
  66. package/src/components/fields/slider.tsx +3 -1
  67. package/src/components/panel/app-sidebar.tsx +28 -7
  68. package/src/components/panel/global-search.tsx +4 -1
  69. package/src/components/panel/shortcut-keys.tsx +1 -1
  70. package/src/components/table/list-tabs.tsx +3 -1
  71. package/src/components/table/table-views.tsx +3 -1
  72. package/src/context.tsx +46 -1
  73. package/src/create-alfakit.tsx +7 -2
  74. package/src/layouts/app-layout.tsx +16 -3
  75. package/src/lib/discovery.tsx +23 -8
  76. package/src/lib/icons.tsx +90 -24
  77. package/src/pages/page.tsx +85 -27
  78. package/src/starter-kit/widgets-triggers.tsx +4 -1
package/dist/themes.css CHANGED
@@ -1015,3 +1015,95 @@
1015
1015
  box-shadow: none;
1016
1016
  outline: none;
1017
1017
  }
1018
+
1019
+ /* ------------------------------------------------------------------ CMS site design */
1020
+
1021
+ /*
1022
+ * Public CMS pages (alfasix/alfakit-cms). **Site design** sets the theme variables on the
1023
+ * document root; these rules apply its fonts and each block's **Style** section
1024
+ * (`data-cms-background`, `data-cms-spacing`, `data-cms-visibility` on
1025
+ * `data-slot="cms-block"`). The page keeps copies of its colours so a coloured block can
1026
+ * swap them for its content.
1027
+ */
1028
+ [data-slot='cms-page'] {
1029
+ --cms-primary: var(--primary);
1030
+ --cms-on-primary: var(--primary-foreground);
1031
+ --cms-secondary: var(--secondary);
1032
+ --cms-on-secondary: var(--secondary-foreground);
1033
+ font-family: var(--cms-font-body);
1034
+ }
1035
+
1036
+ /* In-page links (`#pricing`) stop below the sticky site header. */
1037
+ [data-slot='cms-page'] [data-slot='cms-block'][id] {
1038
+ scroll-margin-top: 5rem;
1039
+ }
1040
+
1041
+ [data-slot='cms-page'] :is(h1, h2, h3, h4, h5, h6) {
1042
+ font-family: var(--cms-font-heading, var(--cms-font-body));
1043
+ }
1044
+
1045
+ [data-slot='cms-block'][data-cms-background] {
1046
+ background-color: var(--background);
1047
+ color: var(--foreground);
1048
+ }
1049
+
1050
+ [data-slot='cms-block'][data-cms-background='muted'] {
1051
+ --background: var(--muted);
1052
+ }
1053
+
1054
+ [data-slot='cms-block'][data-cms-background='primary'],
1055
+ [data-slot='cms-block'][data-cms-background='secondary'],
1056
+ [data-slot='cms-block'][data-cms-background='dark'] {
1057
+ --muted: color-mix(in oklab, var(--foreground) 10%, var(--background));
1058
+ --muted-foreground: color-mix(in oklab, var(--foreground) 80%, var(--background));
1059
+ --border: color-mix(in oklab, var(--foreground) 22%, var(--background));
1060
+ --card: color-mix(in oklab, var(--foreground) 7%, var(--background));
1061
+ --card-foreground: var(--foreground);
1062
+ }
1063
+
1064
+ [data-slot='cms-block'][data-cms-background='primary'] {
1065
+ --background: var(--cms-primary);
1066
+ --foreground: var(--cms-on-primary);
1067
+ --primary: var(--cms-on-primary);
1068
+ --primary-foreground: var(--cms-primary);
1069
+ }
1070
+
1071
+ [data-slot='cms-block'][data-cms-background='secondary'] {
1072
+ --background: var(--cms-secondary);
1073
+ --foreground: var(--cms-on-secondary);
1074
+ }
1075
+
1076
+ [data-slot='cms-block'][data-cms-background='dark'] {
1077
+ --background: oklch(0.205 0 0);
1078
+ --foreground: oklch(0.985 0 0);
1079
+ }
1080
+
1081
+ [data-slot='cms-block'][data-cms-spacing='none'] > * {
1082
+ padding-block: 0;
1083
+ }
1084
+
1085
+ [data-slot='cms-block'][data-cms-spacing='small'] > * {
1086
+ padding-block: 1.5rem;
1087
+ }
1088
+
1089
+ [data-slot='cms-block'][data-cms-spacing='large'] > * {
1090
+ padding-block: 6rem;
1091
+ }
1092
+
1093
+ @media (min-width: 640px) {
1094
+ [data-slot='cms-block'][data-cms-spacing='large'] > * {
1095
+ padding-block: 8rem;
1096
+ }
1097
+ }
1098
+
1099
+ @media (max-width: 767.98px) {
1100
+ [data-slot='cms-block'][data-cms-visibility='desktop'] {
1101
+ display: none;
1102
+ }
1103
+ }
1104
+
1105
+ @media (min-width: 768px) {
1106
+ [data-slot='cms-block'][data-cms-visibility='mobile'] {
1107
+ display: none;
1108
+ }
1109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfakit/react",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "description": "React + shadcn/ui renderer for Alfakit, the Laravel admin panel builder by Alfa Six.",
5
5
  "license": "MIT",
6
6
  "author": "Alfa Six (https://alfasixbh.com)",
@@ -96,7 +96,7 @@
96
96
  "react-day-picker": "^10.0.2",
97
97
  "sonner": "^2.0.8",
98
98
  "tailwind-merge": "^3.7.0",
99
- "@alfakit/core": "0.1.13"
99
+ "@alfakit/core": "0.2.0"
100
100
  },
101
101
  "peerDependencies": {
102
102
  "@inertiajs/react": "^3.0.0",
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  CMS_FORM_KEY,
3
3
  cmsAspectClass,
4
+ cmsBlockAttributes,
4
5
  cmsBlockKey,
5
6
  cmsBlocks,
6
7
  cmsColumnsClass,
@@ -464,7 +465,12 @@ export function CmsBlocks({
464
465
  if (!Component) return null
465
466
 
466
467
  return (
467
- <div key={block.id} id={block.id} data-slot="cms-block" data-cms-block={block.type}>
468
+ <div
469
+ key={block.id}
470
+ {...cmsBlockAttributes(block)}
471
+ data-slot="cms-block"
472
+ data-cms-block={block.type}
473
+ >
468
474
  <Component block={block} data={block.data} />
469
475
  </div>
470
476
  )
@@ -1,11 +1,18 @@
1
- import { cmsHeadTags, type CmsSeoSchema } from '@alfakit/core'
1
+ import { cmsDesignLinks, cmsHeadTags, type CmsSeoSchema, type CmsSiteDesign } from '@alfakit/core'
2
2
  import { Head } from '@inertiajs/react'
3
3
 
4
4
  /**
5
- * The page's `<title>`, meta, canonical, `hreflang` and JSON-LD tags, through Inertia's
6
- * `<Head>` (server-rendered with SSR, updated on every visit).
5
+ * The page's `<title>`, meta, canonical, `hreflang` and JSON-LD tags, plus the site
6
+ * design's favicon and fonts, through Inertia's `<Head>` (server-rendered with SSR,
7
+ * updated on every visit).
7
8
  */
8
- export function CmsHead({ seo }: { seo: CmsSeoSchema | null | undefined }) {
9
+ export function CmsHead({
10
+ seo,
11
+ design,
12
+ }: {
13
+ seo: CmsSeoSchema | null | undefined
14
+ design?: CmsSiteDesign | null
15
+ }) {
9
16
  const tags = cmsHeadTags(seo)
10
17
 
11
18
  return (
@@ -36,6 +43,14 @@ export function CmsHead({ seo }: { seo: CmsSeoSchema | null | undefined }) {
36
43
  hrefLang={link.hreflang}
37
44
  />
38
45
  ))}
46
+ {cmsDesignLinks(design).map((link) => (
47
+ <link
48
+ key={`design:${link.rel}`}
49
+ head-key={`cms:design:${link.rel}`}
50
+ rel={link.rel}
51
+ href={link.href}
52
+ />
53
+ ))}
39
54
  {/* Encoded on the server with `<` escaped, so it can't close the element. */}
40
55
  {tags.jsonLd && (
41
56
  <script head-key="cms:jsonld" type="application/ld+json">
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  CMS_FOOTER_KEY,
3
3
  CMS_HEADER_KEY,
4
+ cmsDesignCss,
4
5
  cmsPayload,
5
6
  type CmsPageProps,
6
7
  type CmsPayload,
@@ -42,7 +43,16 @@ export function CmsHeader({ cms }: { cms: CmsPayload }) {
42
43
  href={cms.site.url}
43
44
  className="focus-visible:ring-ring/50 rounded-md text-lg font-semibold tracking-tight outline-none focus-visible:ring-[3px]"
44
45
  >
45
- {cms.site.name}
46
+ {cms.site.design?.logo ? (
47
+ <img
48
+ src={cms.site.design.logo.url}
49
+ alt={cms.site.design.logo.alt}
50
+ data-slot="cms-logo"
51
+ className="h-9 w-auto max-w-48 object-contain"
52
+ />
53
+ ) : (
54
+ cms.site.name
55
+ )}
46
56
  </a>
47
57
  <div className="flex items-center gap-2">
48
58
  <CmsNav menu={cms.menus.main}>
@@ -130,6 +140,7 @@ function CmsPageLayout({ cms, children }: { cms: CmsPayload; children?: ReactNod
130
140
  const { t } = useCms()
131
141
  const header: AnyComponent = registry?.resolve(CMS_HEADER_KEY) ?? CmsHeader
132
142
  const footer: AnyComponent = registry?.resolve(CMS_FOOTER_KEY) ?? CmsFooter
143
+ const designCss = cmsDesignCss(cms.site.design)
133
144
 
134
145
  return (
135
146
  <div
@@ -145,6 +156,10 @@ function CmsPageLayout({ cms, children }: { cms: CmsPayload; children?: ReactNod
145
156
  >
146
157
  {t('cms.skip_to_content', {}, 'Skip to content')}
147
158
  </a>
159
+ {/* Site design: built on the server from checked values, never contains `<`. */}
160
+ {designCss && (
161
+ <style data-slot="cms-design" dangerouslySetInnerHTML={{ __html: designCss }} />
162
+ )}
148
163
  {cms.preview && <PreviewBanner />}
149
164
  {createElement(header, { cms })}
150
165
  <main
@@ -204,7 +219,7 @@ export function CmsPage({
204
219
 
205
220
  return (
206
221
  <CmsProvider payload={cms} components={components}>
207
- <CmsHead seo={cms.page.seo} />
222
+ <CmsHead seo={cms.page.seo} design={cms.site.design} />
208
223
  <CmsPageLayout cms={cms} />
209
224
  </CmsProvider>
210
225
  )
@@ -147,7 +147,8 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
147
147
  const local = pendingFile(file, position)
148
148
  setUploading((state) => [...state, local])
149
149
 
150
- queued.push(limit(() => send(endpoint, file, local)))
150
+ // Submits wait for queued and running uploads, so saving now doesn't drop the file.
151
+ queued.push(store.trackUpload(limit(() => send(endpoint, file, local))))
151
152
  }
152
153
 
153
154
  await Promise.all(queued)
@@ -254,7 +255,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
254
255
 
255
256
  const local = pendingFile(file, `edit:${existing.path}`)
256
257
  setUploading((state) => [...state, local])
257
- await send(endpoint, file, local, existing.path)
258
+ await store.trackUpload(send(endpoint, file, local, existing.path))
258
259
  }
259
260
 
260
261
  const send = async (endpoint: string, file: File, local: LocalFile, replacing?: string) => {
@@ -596,10 +597,10 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
596
597
  </span>
597
598
  )}
598
599
  {!circle &&
599
- (component.acceptedFileTypes || component.maxSize || component.minFiles) && (
600
+ (component.acceptedFileTypesLabel || component.maxSize || component.minFiles) && (
600
601
  <span className="text-muted-foreground text-xs">
601
602
  {[
602
- component.acceptedFileTypes?.join(', '),
603
+ component.acceptedFileTypesLabel,
603
604
  component.maxSize
604
605
  ? `≤ ${fileManagerSize(component.maxSize * 1024, formatter)}`
605
606
  : null,
@@ -110,7 +110,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
110
110
  import { useActionRunner, usePanel, useT } from '../../context'
111
111
  import { Icon } from '../../lib/icons'
112
112
  import { cn } from '../../lib/utils'
113
- import { useField } from '../form/form-context'
113
+ import { useField, useOptionalFormStore } from '../form/form-context'
114
114
  import { FieldWrapper } from '../schema/field-wrapper'
115
115
  import { Button } from '../ui/button'
116
116
  import { Separator } from '../ui/separator'
@@ -284,6 +284,7 @@ function RichEditorInput({
284
284
  }) {
285
285
  const t = useT()
286
286
  const panel = usePanel()
287
+ const formStore = useOptionalFormStore()
287
288
  const runner = useActionRunner()
288
289
  const { value, setValue, blur, error, id, disabled, readOnly, aria } =
289
290
  useField<unknown>(component)
@@ -516,7 +517,11 @@ function RichEditorInput({
516
517
 
517
518
  const pickImage = () => fileInput.current?.click()
518
519
 
519
- const uploadImage = async (file: File) => {
520
+ // Submits wait for the attachment's upload, so saving now doesn't drop the image.
521
+ const uploadImage = (file: File) =>
522
+ formStore ? formStore.trackUpload(sendImage(file)) : sendImage(file)
523
+
524
+ const sendImage = async (file: File) => {
520
525
  if (!panel.endpoints.upload || !editor) return
521
526
 
522
527
  // The server checks again when the form is saved.
@@ -101,7 +101,9 @@ export function SliderField({ component }: { component: SliderSchema }) {
101
101
  }
102
102
 
103
103
  return (
104
- <FieldWrapper field={component} htmlFor={`${id}-thumb-0`} error={error}>
104
+ // The wrapper's label, hint and error ids derive from `id`, as `aria` does: a thumb is not
105
+ // labelable, so one thumb names itself through `aria-labelledby`.
106
+ <FieldWrapper field={component} htmlFor={id} error={error}>
105
107
  <div
106
108
  data-slot="slider"
107
109
  className={cn(
@@ -8,7 +8,7 @@ import {
8
8
  } from '@alfakit/core'
9
9
  import { Link } from '@inertiajs/react'
10
10
  import { ChevronRightIcon } from 'lucide-react'
11
- import type { ComponentProps, ReactNode } from 'react'
11
+ import { useState, type ComponentProps, type ReactNode } from 'react'
12
12
  import { useDirection, usePanel, useT } from '../../context'
13
13
  import { extraProps } from '../../lib/attributes'
14
14
  import { badgeColorClasses, colorClass } from '../../lib/colors'
@@ -114,12 +114,32 @@ function NavLink({
114
114
  )
115
115
  }
116
116
 
117
+ /**
118
+ * Open state of a collapsible menu entry: `defaultOpen` at first, and opened again when the
119
+ * entry becomes active. The sidebar stays mounted across visits, so it keeps what the user
120
+ * opened or closed, while a page reached from elsewhere still shows its menu entry.
121
+ */
122
+ function useOpenWhenActive(defaultOpen: boolean, active: boolean) {
123
+ const [open, setOpen] = useState(defaultOpen)
124
+ const [wasActive, setWasActive] = useState(active)
125
+
126
+ if (active !== wasActive) {
127
+ setWasActive(active)
128
+
129
+ if (active) setOpen(true)
130
+ }
131
+
132
+ return { open, onOpenChange: setOpen }
133
+ }
134
+
117
135
  /** Child items (any depth) with their own badges, new-tab links and nested children. */
118
136
  function NavSubItem({ item }: { item: NavigationItemSchema }) {
137
+ const collapsible = useOpenWhenActive(!!item.active, !!item.active)
138
+
119
139
  if (item.children && item.children.length > 0) {
120
140
  return (
121
141
  <SidebarMenuSubItem>
122
- <Collapsible defaultOpen={item.active} className="group/nav-sub">
142
+ <Collapsible {...collapsible} className="group/nav-sub">
123
143
  <CollapsibleTrigger asChild>
124
144
  <SidebarMenuSubButton asChild isActive={item.active}>
125
145
  <button type="button" {...extraProps(item.extraAttributes, 'w-full')}>
@@ -156,9 +176,11 @@ function NavSubItem({ item }: { item: NavigationItemSchema }) {
156
176
  }
157
177
 
158
178
  function NavItem({ item }: { item: NavigationItemSchema }) {
179
+ const collapsible = useOpenWhenActive(!!item.active, !!item.active)
180
+
159
181
  if (item.children && item.children.length > 0) {
160
182
  return (
161
- <Collapsible asChild defaultOpen={item.active} className="group/collapsible">
183
+ <Collapsible asChild {...collapsible} className="group/collapsible">
162
184
  <SidebarMenuItem>
163
185
  <CollapsibleTrigger asChild>
164
186
  <SidebarMenuButton
@@ -259,6 +281,8 @@ function NavGroup({ group }: { group: NavigationGroupSchema }) {
259
281
  const attributes = extraProps(group.extraSidebarAttributes)
260
282
  // A labelled group with an icon collapses into a flyout in the icon-only sidebar.
261
283
  const flyout = Boolean(group.label && group.icon)
284
+ const active = group.items.some((item) => item.active)
285
+ const collapsible = useOpenWhenActive(!group.collapsed || active, active)
262
286
  const content = (
263
287
  <SidebarGroupContent className={cn(flyout && 'group-data-[collapsible=icon]:hidden')}>
264
288
  <SidebarMenu>
@@ -288,10 +312,7 @@ function NavGroup({ group }: { group: NavigationGroupSchema }) {
288
312
  }
289
313
 
290
314
  return (
291
- <Collapsible
292
- defaultOpen={!group.collapsed || group.items.some((item) => item.active)}
293
- className="group/nav-group"
294
- >
315
+ <Collapsible {...collapsible} className="group/nav-group">
295
316
  <SidebarGroup {...attributes}>
296
317
  <CollapsibleTrigger asChild>
297
318
  <SidebarGroupLabel
@@ -133,7 +133,10 @@ export function GlobalSearch({ variant = 'topbar' }: { variant?: 'topbar' | 'sid
133
133
  </span>
134
134
  )}
135
135
  {hydrated && suffix?.type === 'keyBinding' && (
136
- <kbd dir="ltr" className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
136
+ <kbd
137
+ dir="ltr"
138
+ className="bg-muted text-foreground/70 hidden rounded px-1 font-mono text-[10px] sm:inline"
139
+ >
137
140
  {formatKeyBinding(suffix.keyBinding)}
138
141
  </kbd>
139
142
  )}
@@ -26,7 +26,7 @@ export function ShortcutKeys({ binding, className }: { binding: string; classNam
26
26
  )}
27
27
  <kbd
28
28
  dir="ltr"
29
- className="bg-muted text-muted-foreground rounded border px-1.5 font-mono text-[10px] leading-5"
29
+ className="bg-muted text-foreground/70 rounded border px-1.5 font-mono text-[10px] leading-5"
30
30
  >
31
31
  {step}
32
32
  </kbd>
@@ -69,7 +69,9 @@ export function ListTabs({
69
69
  cn(
70
70
  'inline-flex h-[calc(100%-1px)] shrink-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-2.5 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none',
71
71
  'focus-visible:border-ring focus-visible:ring-ring/50',
72
- active ? 'bg-background text-foreground shadow-sm' : 'hover:text-foreground',
72
+ active
73
+ ? 'bg-background text-foreground shadow-sm'
74
+ : 'text-foreground/70 hover:text-foreground',
73
75
  ),
74
76
  )}
75
77
  ref={(element) => {
@@ -240,7 +240,9 @@ export function TableViews({
240
240
  className={cn(
241
241
  'inline-flex h-[calc(100%-1px)] shrink-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-2.5 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none',
242
242
  'focus-visible:border-ring focus-visible:ring-ring/50',
243
- selected ? 'bg-background text-foreground shadow-sm' : 'hover:text-foreground',
243
+ selected
244
+ ? 'bg-background text-foreground shadow-sm'
245
+ : 'text-foreground/70 hover:text-foreground',
244
246
  )}
245
247
  onClick={() => !selected && visitTableView(table, item?.key ?? null)}
246
248
  onKeyDown={(event) => onTabKeyDown(event, index)}
package/src/context.tsx CHANGED
@@ -12,7 +12,15 @@ import {
12
12
  type Translator,
13
13
  } from '@alfakit/core'
14
14
  import { usePage } from '@inertiajs/react'
15
- import { createContext, useContext, useMemo, type ComponentType, type ReactNode } from 'react'
15
+ import {
16
+ createContext,
17
+ useContext,
18
+ useEffect,
19
+ useMemo,
20
+ useRef,
21
+ type ComponentType,
22
+ type ReactNode,
23
+ } from 'react'
16
24
 
17
25
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
26
  export type AnyComponent = ComponentType<any>
@@ -33,6 +41,14 @@ export interface AlfakitContextValue {
33
41
  * change). Panels leave it out (a partial reload); components outside panels fetch theirs.
34
42
  */
35
43
  refresh?: (targets: string[]) => Promise<void>
44
+ /**
45
+ * Whether every page is rendered only after the split built-ins and discovered components
46
+ * it names have loaded, on the server and before hydration (`createAlfakit()` resolves
47
+ * pages that way). Without it (starter kits, standalone components) those components show
48
+ * after hydration: a long-lived SSR server keeps code earlier requests loaded, a fresh
49
+ * browser has none of it, so rendering them early would make the markups differ.
50
+ */
51
+ preloadsPageComponents?: boolean
36
52
  }
37
53
 
38
54
  const AlfakitContext = createContext<AlfakitContextValue | null>(null)
@@ -52,6 +68,35 @@ function useOptionalPageProps(): AlfakitPageProps {
52
68
  }
53
69
  }
54
70
 
71
+ /**
72
+ * The Inertia page URL, or undefined outside an Inertia app. Persistent chrome (the panel
73
+ * layout stays mounted across visits) closes its phone sheets when it changes.
74
+ */
75
+ export function useOptionalPageUrl(): string | undefined {
76
+ try {
77
+ return usePage().url
78
+ } catch {
79
+ return undefined
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Calls `close` when the user moves to another page. The panel layout persists across
85
+ * visits, so menus and sheets that used to be reset by remounting close here instead.
86
+ * Partial reloads (deferred props, polling) keep the URL and leave them open.
87
+ */
88
+ export function useCloseOnVisit(close: () => void): void {
89
+ const url = useOptionalPageUrl()
90
+ const previous = useRef(url)
91
+
92
+ useEffect(() => {
93
+ if (previous.current === url) return
94
+
95
+ previous.current = url
96
+ close()
97
+ }, [url, close])
98
+ }
99
+
55
100
  /**
56
101
  * Outside panels there is no shared `alfakit` prop and no `page` prop: `<AlfakitComponent>`
57
102
  * provides both from its payload, and the hooks below read them first.
@@ -50,7 +50,7 @@ import { AlfakitProvider, useAlfakit, type AlfakitContextValue, type AnyComponen
50
50
  import { panelLayouts } from './layouts/panel-layouts'
51
51
  import { discoverComponents } from './lib/discovery'
52
52
  import { preloadIcons } from './lib/icons'
53
- import { AlfakitPage } from './pages/page'
53
+ import { AlfakitPage, PanelLayoutHost } from './pages/page'
54
54
  import { builtInComponents, createDefaultRegistry } from './registry'
55
55
  import { claimToaster } from './standalone/toaster-claims'
56
56
 
@@ -100,7 +100,10 @@ export function AlfakitRoot({
100
100
  return (
101
101
  <Direction.Provider dir={direction}>
102
102
  <TooltipProvider delayDuration={0}>
103
- <SchemaContractGuard panel={panel}>{children}</SchemaContractGuard>
103
+ {/* The panel layout stays mounted across visits between Alfakit pages. */}
104
+ <SchemaContractGuard panel={panel}>
105
+ <PanelLayoutHost>{children}</PanelLayoutHost>
106
+ </SchemaContractGuard>
104
107
  <ActionModals />
105
108
  <FlashToasts />
106
109
  <RealtimeNotifications />
@@ -187,6 +190,8 @@ export function bootAlfakit(
187
190
  registry,
188
191
  pages,
189
192
  translator,
193
+ // `resolve` below loads the page's split built-ins and discovered files first.
194
+ preloadsPageComponents: true,
190
195
  // The runner's endpoints are refreshed on every navigation from the shared props.
191
196
  actions: new ActionRunner({
192
197
  endpoints: { action: '', modal: '' },
@@ -6,14 +6,14 @@ import {
6
6
  type BreadcrumbSchema,
7
7
  type Width,
8
8
  } from '@alfakit/core'
9
- import { createElement, type CSSProperties, type ReactNode } from 'react'
9
+ import { createElement, useCallback, type CSSProperties, type ReactNode } from 'react'
10
10
  import { AppSidebar } from '../components/panel/app-sidebar'
11
11
  import { BottomNav } from '../components/panel/bottom-nav'
12
12
  import { TopbarLocaleSwitcher } from '../components/panel/locale-switcher'
13
13
  import { Slot } from '../components/panel/slot'
14
14
  import { Topbar } from '../components/panel/topbar'
15
- import { SidebarInset, SidebarProvider, SidebarTrigger } from '../components/ui/sidebar'
16
- import { useAlfakitProps, useRegistry } from '../context'
15
+ import { SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../components/ui/sidebar'
16
+ import { useAlfakitProps, useCloseOnVisit, useRegistry } from '../context'
17
17
  import { cn } from '../lib/utils'
18
18
  import { MAX_WIDTHS } from './max-widths'
19
19
  import { TopNavLayout } from './top-nav-layout'
@@ -48,6 +48,18 @@ export function MobileTopbar() {
48
48
  )
49
49
  }
50
50
 
51
+ /**
52
+ * The layout stays mounted across visits: the phone sidebar sheet closes when the user
53
+ * moves to another page.
54
+ */
55
+ function CloseMobileSidebarOnVisit() {
56
+ const { setOpenMobile } = useSidebar()
57
+
58
+ useCloseOnVisit(useCallback(() => setOpenMobile(false), [setOpenMobile]))
59
+
60
+ return null
61
+ }
62
+
51
63
  export function AppLayout({ children, breadcrumbs, maxContentWidth }: AppLayoutProps) {
52
64
  const props = useAlfakitProps()
53
65
  const registry = useRegistry()
@@ -73,6 +85,7 @@ export function AppLayout({ children, breadcrumbs, maxContentWidth }: AppLayoutP
73
85
  defaultOpen={sidebarDefaultOpen(props.alfakitSidebarOpen, layout)}
74
86
  style={sidebarWidthStyle(props.alfakit) as CSSProperties | undefined}
75
87
  >
88
+ <CloseMobileSidebarOnVisit />
76
89
  <Slot name="body.start" />
77
90
  <Slot name="layout.start" />
78
91
  {createElement(Sidebar)}
@@ -4,29 +4,44 @@ import {
4
4
  type DiscoveryModules,
5
5
  type DiscoveryOptions,
6
6
  } from '@alfakit/core'
7
- import { createElement, useEffect, useState } from 'react'
8
- import type { AnyComponent } from '../context'
7
+ import { createElement, useEffect, useState, useSyncExternalStore } from 'react'
8
+ import { useOptionalAlfakit, type AnyComponent } from '../context'
9
+
10
+ const subscribe = () => () => {}
9
11
 
10
12
  /**
11
13
  * A discovered component: rendered straight away once its code has loaded (the pages that
12
14
  * use it preload it, see `ComponentDiscovery.preloadFor()`), else as soon as it arrives
13
15
  * (through state, not `<Suspense>`, whose reveal React throttles by up to 300 ms).
14
16
  * Each mount keeps the path it started with, so loading never remounts a field.
17
+ *
18
+ * Where pages are not preloaded (starter kits, standalone components), the server render
19
+ * and hydration leave it out even when its code is loaded: a long-lived SSR server keeps
20
+ * what earlier requests loaded, a fresh browser has none of it (OM-B06).
15
21
  */
16
22
  export function discoveredComponent(entry: DiscoveredEntry<AnyComponent>): AnyComponent {
17
23
  function Discovered(props: Record<string, unknown>) {
24
+ const preloaded = useOptionalAlfakit()?.preloadsPageComponents === true
25
+ // False on the server and while hydrating, true for every later render and mount.
26
+ const hydrated = useSyncExternalStore(
27
+ subscribe,
28
+ () => true,
29
+ () => false,
30
+ )
31
+ const ready = preloaded || hydrated
18
32
  const [state, setState] = useState<{ component?: AnyComponent; error?: unknown }>(() => ({
19
- component: entry.current,
33
+ component: ready ? entry.current : undefined,
20
34
  }))
35
+ const component = state.component ?? (ready ? entry.current : undefined)
21
36
 
22
37
  useEffect(() => {
23
- if (state.component) return
38
+ if (component) return
24
39
 
25
40
  let active = true
26
41
 
27
42
  entry.load().then(
28
- (component) => {
29
- if (active) setState({ component })
43
+ (loaded) => {
44
+ if (active) setState({ component: loaded })
30
45
  },
31
46
  (error: unknown) => {
32
47
  if (active) setState({ error: error ?? new Error(`Failed to load ${entry.key}`) })
@@ -36,11 +51,11 @@ export function discoveredComponent(entry: DiscoveredEntry<AnyComponent>): AnyCo
36
51
  return () => {
37
52
  active = false
38
53
  }
39
- }, [state.component])
54
+ }, [component])
40
55
 
41
56
  if (state.error !== undefined) throw state.error
42
57
 
43
- return state.component ? createElement(state.component, props) : null
58
+ return component ? createElement(component, props) : null
44
59
  }
45
60
 
46
61
  Discovered.displayName = `Discovered(${entry.key})`