@alfakit/react 0.1.12 → 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 +12 -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 +6 -3
- 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/standalone/alfakit-component.js +2 -1
- package/dist/standalone/alfakit-component.js.map +1 -1
- package/dist/standalone/alfakit-toaster.js +2 -1
- package/dist/standalone/alfakit-toaster.js.map +1 -1
- package/dist/standalone/runtime.js +1 -11
- package/dist/standalone/runtime.js.map +1 -1
- package/dist/standalone/toaster-claims.js +16 -0
- package/dist/standalone/toaster-claims.js.map +1 -0
- package/dist/starter-kit/widgets-loaded.js +63 -6
- package/dist/starter-kit/widgets-loaded.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 +13 -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/standalone/runtime.ts +2 -14
- package/src/standalone/toaster-claims.ts +16 -0
- package/src/starter-kit/widgets-loaded.tsx +84 -1
- package/src/starter-kit/widgets-triggers.tsx +4 -1
|
@@ -101,7 +101,9 @@ export function SliderField({ component }: { component: SliderSchema }) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
return (
|
|
104
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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 {
|
|
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.
|
package/src/create-alfakit.tsx
CHANGED
|
@@ -32,6 +32,7 @@ import { Direction } from 'radix-ui'
|
|
|
32
32
|
import {
|
|
33
33
|
StrictMode,
|
|
34
34
|
createElement,
|
|
35
|
+
useEffect,
|
|
35
36
|
useLayoutEffect,
|
|
36
37
|
type ReactElement,
|
|
37
38
|
type ReactNode,
|
|
@@ -49,8 +50,9 @@ import { AlfakitProvider, useAlfakit, type AlfakitContextValue, type AnyComponen
|
|
|
49
50
|
import { panelLayouts } from './layouts/panel-layouts'
|
|
50
51
|
import { discoverComponents } from './lib/discovery'
|
|
51
52
|
import { preloadIcons } from './lib/icons'
|
|
52
|
-
import { AlfakitPage } from './pages/page'
|
|
53
|
+
import { AlfakitPage, PanelLayoutHost } from './pages/page'
|
|
53
54
|
import { builtInComponents, createDefaultRegistry } from './registry'
|
|
55
|
+
import { claimToaster } from './standalone/toaster-claims'
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
58
|
* Rendered inside Inertia's page tree, so it follows the current page: the reading
|
|
@@ -91,10 +93,17 @@ export function AlfakitRoot({
|
|
|
91
93
|
applyPanelTheme(panel)
|
|
92
94
|
}, [panel])
|
|
93
95
|
|
|
96
|
+
// This root renders the toaster: starter kit header components and standalone components
|
|
97
|
+
// on the same page don't mount a second one.
|
|
98
|
+
useEffect(() => claimToaster(), [])
|
|
99
|
+
|
|
94
100
|
return (
|
|
95
101
|
<Direction.Provider dir={direction}>
|
|
96
102
|
<TooltipProvider delayDuration={0}>
|
|
97
|
-
|
|
103
|
+
{/* The panel layout stays mounted across visits between Alfakit pages. */}
|
|
104
|
+
<SchemaContractGuard panel={panel}>
|
|
105
|
+
<PanelLayoutHost>{children}</PanelLayoutHost>
|
|
106
|
+
</SchemaContractGuard>
|
|
98
107
|
<ActionModals />
|
|
99
108
|
<FlashToasts />
|
|
100
109
|
<RealtimeNotifications />
|
|
@@ -181,6 +190,8 @@ export function bootAlfakit(
|
|
|
181
190
|
registry,
|
|
182
191
|
pages,
|
|
183
192
|
translator,
|
|
193
|
+
// `resolve` below loads the page's split built-ins and discovered files first.
|
|
194
|
+
preloadsPageComponents: true,
|
|
184
195
|
// The runner's endpoints are refreshed on every navigation from the shared props.
|
|
185
196
|
actions: new ActionRunner({
|
|
186
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)}
|
package/src/lib/discovery.tsx
CHANGED
|
@@ -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
|
|
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 (
|
|
38
|
+
if (component) return
|
|
24
39
|
|
|
25
40
|
let active = true
|
|
26
41
|
|
|
27
42
|
entry.load().then(
|
|
28
|
-
(
|
|
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
|
-
}, [
|
|
54
|
+
}, [component])
|
|
40
55
|
|
|
41
56
|
if (state.error !== undefined) throw state.error
|
|
42
57
|
|
|
43
|
-
return
|
|
58
|
+
return component ? createElement(component, props) : null
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
Discovered.displayName = `Discovered(${entry.key})`
|
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}
|