@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/src/lib/icons.tsx CHANGED
@@ -1,61 +1,126 @@
1
1
  import { iconAlias, iconAliasOverride, iconSource, isIconAlias } from '@alfakit/core'
2
2
  import type { LucideProps } from 'lucide-react'
3
3
  import type { DynamicIcon, IconName as LucideIconName } from 'lucide-react/dynamic.mjs'
4
- import { createElement, useEffect, useState, type ComponentProps, type ComponentType } from 'react'
4
+ import {
5
+ createElement,
6
+ useEffect,
7
+ useState,
8
+ type ComponentProps,
9
+ type ComponentType,
10
+ type ReactNode,
11
+ } from 'react'
5
12
  import { useOptionalRegistry } from '../context'
13
+ import { useHydrated } from '../hooks/use-hydrated'
6
14
  import { cn } from './utils'
7
15
 
8
16
  type DynamicIconComponent = typeof DynamicIcon
17
+ type LucideGlyph = ComponentType<LucideProps>
18
+ type IconImports = Record<string, () => Promise<{ default: LucideGlyph }>>
9
19
 
10
20
  /**
11
21
  * `lucide-react/dynamic` carries a lazy import for every Lucide icon (~2,000 entries), so it
12
22
  * is not part of any entry chunk: panels (`createAlfakit()`, starter kit pages) start loading
13
23
  * it while they boot, since every page draws named icons, and elsewhere (a host's sidebar)
14
- * it loads with the first icon. Named icons render asynchronously either way.
24
+ * it loads with the first icon.
15
25
  */
16
- let dynamicIcon: DynamicIconComponent | undefined
17
- let pendingDynamicIcon: Promise<DynamicIconComponent> | undefined
26
+ let pendingImports: Promise<IconImports> | undefined
18
27
 
19
- function loadDynamicIcon(): Promise<DynamicIconComponent> {
20
- pendingDynamicIcon ??= import('lucide-react/dynamic.mjs').then(
21
- (module) => (dynamicIcon = module.DynamicIcon),
28
+ function loadIconImports(): Promise<IconImports> {
29
+ pendingImports ??= import('lucide-react/dynamic.mjs').then(
30
+ (module) => module.dynamicIconImports as unknown as IconImports,
22
31
  (error: unknown) => {
23
- pendingDynamicIcon = undefined
32
+ pendingImports = undefined
24
33
 
25
34
  throw error
26
35
  },
27
36
  )
28
37
 
29
- return pendingDynamicIcon
38
+ return pendingImports
39
+ }
40
+
41
+ /**
42
+ * Loaded icons by name (`null`: not a Lucide icon), shared by every `<Icon>`: an icon that
43
+ * loaded once draws synchronously in every later mount (the page after a visit, new table
44
+ * rows), instead of rendering empty for a frame on each mount as `DynamicIcon` does.
45
+ */
46
+ const loadedIcons = new Map<string, LucideGlyph | null>()
47
+ const pendingIcons = new Map<string, Promise<LucideGlyph | null>>()
48
+
49
+ function loadIcon(name: string): Promise<LucideGlyph | null> {
50
+ const loaded = loadedIcons.get(name)
51
+
52
+ if (loaded !== undefined) return Promise.resolve(loaded)
53
+
54
+ let pending = pendingIcons.get(name)
55
+
56
+ if (!pending) {
57
+ pending = loadIconImports()
58
+ .then((imports) => {
59
+ const load = Object.prototype.hasOwnProperty.call(imports, name) ? imports[name] : undefined
60
+
61
+ return load ? load().then((module) => module.default) : null
62
+ })
63
+ .then(
64
+ (icon) => {
65
+ loadedIcons.set(name, icon)
66
+ pendingIcons.delete(name)
67
+
68
+ return icon
69
+ },
70
+ (error: unknown) => {
71
+ // A failed chunk is tried again by the next mount.
72
+ pendingIcons.delete(name)
73
+
74
+ throw error
75
+ },
76
+ )
77
+ pendingIcons.set(name, pending)
78
+ }
79
+
80
+ return pending
30
81
  }
31
82
 
32
83
  /** Start loading `lucide-react/dynamic` ahead of the first named icon (browser only). */
33
84
  export function preloadIcons(): void {
34
- if (typeof window !== 'undefined') loadDynamicIcon().catch(() => undefined)
85
+ if (typeof window !== 'undefined') loadIconImports().catch(() => undefined)
86
+ }
87
+
88
+ type LucideIconProps = Omit<ComponentProps<DynamicIconComponent>, 'name' | 'fallback'> & {
89
+ name: string
90
+ fallback?: () => ReactNode
35
91
  }
36
92
 
37
- /** `DynamicIcon`, rendering its `fallback` (or nothing) until `lucide-react/dynamic` loads. */
38
- function LazyDynamicIcon(props: ComponentProps<DynamicIconComponent>) {
39
- const [Dynamic, setDynamic] = useState(() => dynamicIcon)
93
+ /**
94
+ * A Lucide icon by name, rendering `fallback` (or nothing) while it loads and for unknown
95
+ * names. An icon that has loaded renders at once, except during the server render and
96
+ * hydration: the server draws no named icons, so the hydrated markup matches it.
97
+ */
98
+ function LucideIcon({ name, fallback, ...props }: LucideIconProps) {
99
+ const hydrated = useHydrated()
100
+ const [, setLoaded] = useState(0)
101
+ const Loaded = hydrated ? loadedIcons.get(name) : undefined
40
102
 
41
103
  useEffect(() => {
42
- if (Dynamic) return
104
+ if (loadedIcons.has(name)) return
43
105
 
44
106
  let active = true
45
107
 
46
- loadDynamicIcon().then(
47
- (component) => active && setDynamic(() => component),
48
- () => undefined,
108
+ loadIcon(name).then(
109
+ () => {
110
+ if (active) setLoaded((count) => count + 1)
111
+ },
112
+ (error: unknown) => console.error(error),
49
113
  )
50
114
 
51
115
  return () => {
52
116
  active = false
53
117
  }
54
- }, [Dynamic])
118
+ }, [name])
55
119
 
56
- if (Dynamic) return <Dynamic {...props} />
120
+ // A module-level cache entry (one component per icon name), not created during render.
121
+ if (Loaded) return createElement(Loaded, props as LucideProps)
57
122
 
58
- return props.fallback ? createElement(props.fallback) : null
123
+ return fallback ? fallback() : null
59
124
  }
60
125
 
61
126
  /** A statically imported glyph (`SearchIcon` from `lucide-react`). */
@@ -72,7 +137,8 @@ export type IconProps = Omit<ComponentProps<DynamicIconComponent>, 'name' | 'fal
72
137
  * app's replacement (`Icons::register()`) or `fallback`. Unknown Lucide names fall back to
73
138
  * `fallback`, if any. `fallback` may also be a statically imported glyph
74
139
  * (`fallback={SearchIcon}`): renderer chrome uses it so an unreplaced alias draws at once,
75
- * without loading anything. Register a `panel:icon` component
140
+ * without loading anything. Named icons load on demand; once loaded, later mounts draw them
141
+ * synchronously. Register a `panel:icon` component
76
142
  * (`createAlfakit({ components: { 'panel:icon': MyIcon } })`) to render every named icon
77
143
  * with another icon set; it receives `name`, `fallback` and `className`.
78
144
  */
@@ -129,14 +195,14 @@ export function Icon({ name, fallback: fallbackProp, className, ...props }: Icon
129
195
  }
130
196
 
131
197
  return (
132
- <LazyDynamicIcon
133
- name={source.name as LucideIconName}
198
+ <LucideIcon
199
+ name={source.name}
134
200
  className={classes}
135
201
  fallback={
136
202
  Glyph
137
203
  ? () => <Glyph className={classes} />
138
204
  : fallback
139
- ? () => <LazyDynamicIcon name={fallback} className={classes} />
205
+ ? () => <LucideIcon name={fallback} className={classes} />
140
206
  : undefined
141
207
  }
142
208
  {...props}
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AlfakitPageProps,
2
3
  ContentBlock,
3
4
  FormBlock as FormBlockSchema,
4
5
  InfolistBlock,
@@ -24,8 +25,10 @@ import {
24
25
  } from '@alfakit/core'
25
26
  import { Head, Link, usePage } from '@inertiajs/react'
26
27
  import {
28
+ createContext,
27
29
  createElement,
28
30
  Fragment,
31
+ useContext,
29
32
  useEffect,
30
33
  useId,
31
34
  useMemo,
@@ -155,13 +158,24 @@ function layout(registry: { resolve(key: string): AnyComponent | undefined }, ke
155
158
  return registry.resolve(key) ?? BlankLayout
156
159
  }
157
160
 
158
- export function PageShell({ page, children }: { page: PageSchema; children: ReactNode }) {
159
- const { registry } = useAlfakit()
161
+ type LayoutKind = 'app' | 'auth' | 'blank' | 'fullscreen'
160
162
 
161
- useBodyAttributes(page.extraBodyAttributes)
163
+ function layoutKind(page: PageSchema): LayoutKind {
164
+ return page.layout === 'auth' || page.layout === 'blank' || page.layout === 'fullscreen'
165
+ ? page.layout
166
+ : 'app'
167
+ }
168
+
169
+ /** The layout `PanelLayoutHost` already renders around the page, if any. */
170
+ const HostedLayoutContext = createContext<LayoutKind | null>(null)
171
+
172
+ /** The page's registered layout (`layout:app`, `layout:auth`…) around `children`. */
173
+ function PanelLayout({ page, children }: { page: PageSchema; children: ReactNode }) {
174
+ const { registry } = useAlfakit()
175
+ const kind = layoutKind(page)
162
176
 
163
177
  // Each layout can be replaced: createAlfakit({ components: { 'layout:app': MyLayout } }).
164
- if (page.layout === 'auth') {
178
+ if (kind === 'auth') {
165
179
  return createElement(
166
180
  layout(registry, 'layout:auth'),
167
181
  { heading: page.heading ?? page.title, subheading: page.subheading, page },
@@ -169,33 +183,77 @@ export function PageShell({ page, children }: { page: PageSchema; children: Reac
169
183
  )
170
184
  }
171
185
 
172
- if (page.layout === 'blank') {
173
- return createElement(layout(registry, 'layout:blank'), { page }, children)
186
+ if (kind === 'app') {
187
+ return createElement(
188
+ layout(registry, 'layout:app'),
189
+ { breadcrumbs: page.breadcrumbs, maxContentWidth: page.maxContentWidth, page },
190
+ children,
191
+ )
174
192
  }
175
193
 
176
- if (page.layout === 'fullscreen') {
177
- return createElement(layout(registry, 'layout:fullscreen'), { page }, children)
178
- }
194
+ return createElement(layout(registry, `layout:${kind}`), { page }, children)
195
+ }
179
196
 
180
- return createElement(
181
- layout(registry, 'layout:app'),
182
- { breadcrumbs: page.breadcrumbs, maxContentWidth: page.maxContentWidth, page },
183
- <>
184
- {/* `?` overlay, `/`, `g d` and panel command shortcuts (`keyboardShortcuts()`). */}
185
- <KeyboardShortcuts />
186
- <Slot name="page.start" />
187
- <PageHeader page={page} />
188
- {/* Edit pages: somebody else saved the record (`detectConflicts()`, `Panel::realtime()`). */}
189
- {page.conflicts &&
190
- createElement(registry.resolve('page:conflict-banner') ?? ConflictBanner, { page })}
191
- {page.subNavigation ? (
192
- <SubNavigationLayout navigation={page.subNavigation}>{children}</SubNavigationLayout>
193
- ) : (
194
- children
195
- )}
196
- <Slot name="page.end" />
197
- </>,
197
+ /**
198
+ * Keeps the panel layout (sidebar, top bar) mounted while the user moves between Alfakit
199
+ * pages. Inertia remounts the page component on every visit; this sits in the persistent
200
+ * Inertia layout (`AlfakitRoot`) and renders the open page's layout from its props, so only
201
+ * the page part below it (heading, actions, content) is replaced. The `PageShell` inside
202
+ * the page then renders just that part. Custom page components (their own Inertia page or
203
+ * `page.view`) and starter kit pages render their layout through `PageShell` as before.
204
+ */
205
+ export function PanelLayoutHost({ children }: { children: ReactNode }) {
206
+ const { component, props } = usePage<AlfakitPageProps>()
207
+ const page = useMemo(
208
+ () => (props.page ? resolveLazyComponents(props.page, props.alfakitLazy) : undefined),
209
+ [props.page, props.alfakitLazy],
198
210
  )
211
+ const hosted =
212
+ component === 'Alfakit/Page' && page && !page.view && props.alfakit?.starterKit !== true
213
+ ? page
214
+ : undefined
215
+
216
+ return (
217
+ <HostedLayoutContext.Provider value={hosted ? layoutKind(hosted) : null}>
218
+ {hosted ? <PanelLayout page={hosted}>{children}</PanelLayout> : children}
219
+ </HostedLayoutContext.Provider>
220
+ )
221
+ }
222
+
223
+ export function PageShell({ page, children }: { page: PageSchema; children: ReactNode }) {
224
+ const { registry } = useAlfakit()
225
+ const hosted = useContext(HostedLayoutContext)
226
+ const kind = layoutKind(page)
227
+
228
+ useBodyAttributes(page.extraBodyAttributes)
229
+
230
+ const content =
231
+ kind === 'app' ? (
232
+ <>
233
+ {/* `?` overlay, `/`, `g d` and panel command shortcuts (`keyboardShortcuts()`). */}
234
+ <KeyboardShortcuts />
235
+ <Slot name="page.start" />
236
+ <PageHeader page={page} />
237
+ {/* Edit pages: somebody else saved the record (`detectConflicts()`, `Panel::realtime()`). */}
238
+ {page.conflicts &&
239
+ createElement(registry.resolve('page:conflict-banner') ?? ConflictBanner, { page })}
240
+ {page.subNavigation ? (
241
+ <SubNavigationLayout navigation={page.subNavigation}>{children}</SubNavigationLayout>
242
+ ) : (
243
+ children
244
+ )}
245
+ <Slot name="page.end" />
246
+ </>
247
+ ) : (
248
+ children
249
+ )
250
+
251
+ // The persistent layout around the page already draws this layout.
252
+ if (hosted === kind) {
253
+ return content
254
+ }
255
+
256
+ return <PanelLayout page={page}>{content}</PanelLayout>
199
257
  }
200
258
 
201
259
  export function PageHeader({ page }: { page: PageSchema }) {
@@ -52,7 +52,10 @@ export function SearchTrigger({ source, onLoad }: WidgetProps & { onLoad?: () =>
52
52
  <SearchIcon className="size-4" aria-hidden="true" />
53
53
  <span className="sr-only sm:not-sr-only sm:flex-1 sm:text-start">{placeholder}</span>
54
54
  {hydrated && binding && (
55
- <kbd dir="ltr" className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
55
+ <kbd
56
+ dir="ltr"
57
+ className="bg-muted text-foreground/70 hidden rounded px-1 font-mono text-[10px] sm:inline"
58
+ >
56
59
  {formatKeyBinding(binding)}
57
60
  </kbd>
58
61
  )}