@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/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.
|
|
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.
|
|
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
|
|
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,
|
|
6
|
-
* `<Head>` (server-rendered with SSR,
|
|
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({
|
|
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.
|
|
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
|
|
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.
|
|
600
|
+
(component.acceptedFileTypesLabel || component.maxSize || component.minFiles) && (
|
|
600
601
|
<span className="text-muted-foreground text-xs">
|
|
601
602
|
{[
|
|
602
|
-
component.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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)}
|
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})`
|