@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.
- package/CHANGELOG.md +6 -0
- package/dist/components/cms/blocks.d.ts.map +1 -1
- package/dist/components/cms/blocks.js +2 -2
- package/dist/components/cms/blocks.js.map +1 -1
- package/dist/components/cms/head.d.ts +6 -4
- package/dist/components/cms/head.d.ts.map +1 -1
- package/dist/components/cms/head.js +10 -4
- package/dist/components/cms/head.js.map +1 -1
- package/dist/components/cms/page.d.ts.map +1 -1
- package/dist/components/cms/page.js +16 -3
- package/dist/components/cms/page.js.map +1 -1
- package/dist/components/fields/file-upload.js +4 -4
- package/dist/components/fields/file-upload.js.map +1 -1
- package/dist/components/fields/rich-editor.js +4 -2
- package/dist/components/fields/rich-editor.js.map +1 -1
- package/dist/components/fields/slider.js +1 -1
- package/dist/components/fields/slider.js.map +1 -1
- package/dist/components/helpdesk/chat-widget.js +1 -1
- package/dist/components/layout/section.js +1 -1
- package/dist/components/layout/tabs.js +1 -1
- package/dist/components/panel/app-sidebar.d.ts.map +1 -1
- package/dist/components/panel/app-sidebar.js +27 -5
- package/dist/components/panel/app-sidebar.js.map +1 -1
- package/dist/components/panel/global-search.js +2 -2
- package/dist/components/panel/global-search.js.map +1 -1
- package/dist/components/panel/shortcut-keys.js +1 -1
- package/dist/components/panel/shortcut-keys.js.map +1 -1
- package/dist/components/table/list-tabs.js +1 -1
- package/dist/components/table/list-tabs.js.map +1 -1
- package/dist/components/table/table-views.js +1 -1
- package/dist/components/table/table-views.js.map +1 -1
- package/dist/components/versions/record-versions.js +1 -1
- package/dist/context.d.ts +8 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +27 -2
- package/dist/context.js.map +1 -1
- package/dist/create-alfakit.d.ts.map +1 -1
- package/dist/create-alfakit.js +3 -2
- package/dist/create-alfakit.js.map +1 -1
- package/dist/eject-manifest.json +2 -2
- package/dist/index.js +1 -1
- package/dist/layouts/app-layout.d.ts.map +1 -1
- package/dist/layouts/app-layout.js +13 -3
- package/dist/layouts/app-layout.js.map +1 -1
- package/dist/lib/discovery.d.ts +4 -0
- package/dist/lib/discovery.d.ts.map +1 -1
- package/dist/lib/discovery.js +17 -7
- package/dist/lib/discovery.js.map +1 -1
- package/dist/lib/icons.d.ts +2 -1
- package/dist/lib/icons.d.ts.map +1 -1
- package/dist/lib/icons.js +55 -19
- package/dist/lib/icons.js.map +1 -1
- package/dist/pages/page.d.ts +1 -1
- package/dist/pages/page.d.ts.map +1 -1
- package/dist/pages/page.js +47 -10
- package/dist/pages/page.js.map +1 -1
- package/dist/starter-kit/widgets-triggers.js +1 -1
- package/dist/starter-kit/widgets-triggers.js.map +1 -1
- package/dist/themes.css +92 -0
- package/package.json +2 -2
- package/src/components/cms/blocks.tsx +7 -1
- package/src/components/cms/head.tsx +19 -4
- package/src/components/cms/page.tsx +17 -2
- package/src/components/fields/file-upload.tsx +5 -4
- package/src/components/fields/rich-editor.tsx +7 -2
- package/src/components/fields/slider.tsx +3 -1
- package/src/components/panel/app-sidebar.tsx +28 -7
- package/src/components/panel/global-search.tsx +4 -1
- package/src/components/panel/shortcut-keys.tsx +1 -1
- package/src/components/table/list-tabs.tsx +3 -1
- package/src/components/table/table-views.tsx +3 -1
- package/src/context.tsx +46 -1
- package/src/create-alfakit.tsx +7 -2
- package/src/layouts/app-layout.tsx +16 -3
- package/src/lib/discovery.tsx +23 -8
- package/src/lib/icons.tsx +90 -24
- package/src/pages/page.tsx +85 -27
- 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 {
|
|
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.
|
|
24
|
+
* it loads with the first icon.
|
|
15
25
|
*/
|
|
16
|
-
let
|
|
17
|
-
let pendingDynamicIcon: Promise<DynamicIconComponent> | undefined
|
|
26
|
+
let pendingImports: Promise<IconImports> | undefined
|
|
18
27
|
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
(module) =>
|
|
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
|
-
|
|
32
|
+
pendingImports = undefined
|
|
24
33
|
|
|
25
34
|
throw error
|
|
26
35
|
},
|
|
27
36
|
)
|
|
28
37
|
|
|
29
|
-
return
|
|
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')
|
|
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
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
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 (
|
|
104
|
+
if (loadedIcons.has(name)) return
|
|
43
105
|
|
|
44
106
|
let active = true
|
|
45
107
|
|
|
46
|
-
|
|
47
|
-
(
|
|
48
|
-
|
|
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
|
-
}, [
|
|
118
|
+
}, [name])
|
|
55
119
|
|
|
56
|
-
|
|
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
|
|
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.
|
|
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
|
-
<
|
|
133
|
-
name={source.name
|
|
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
|
-
? () => <
|
|
205
|
+
? () => <LucideIcon name={fallback} className={classes} />
|
|
140
206
|
: undefined
|
|
141
207
|
}
|
|
142
208
|
{...props}
|
package/src/pages/page.tsx
CHANGED
|
@@ -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
|
-
|
|
159
|
-
const { registry } = useAlfakit()
|
|
161
|
+
type LayoutKind = 'app' | 'auth' | 'blank' | 'fullscreen'
|
|
160
162
|
|
|
161
|
-
|
|
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 (
|
|
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 (
|
|
173
|
-
return createElement(
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
194
|
+
return createElement(layout(registry, `layout:${kind}`), { page }, children)
|
|
195
|
+
}
|
|
179
196
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
)}
|