blocks-dusted 0.1.5 → 0.1.8
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/README.md +15 -0
- package/package.json +1 -1
- package/src/cli.js +14 -1
- package/src/commands/header.js +145 -0
- package/templates/components/HeaderDD02/MobileBottomNav/index.tsx +1 -1
- package/templates/components/HeaderDD02/README.md +6 -0
- package/templates/components/HeaderDD02/index.tsx +1 -1
- package/templates/components/HeaderDD02/manifest.json +49 -14
- package/templates/shared/components/animate-ui/components/animate/tooltip.tsx +74 -0
- package/templates/shared/components/animate-ui/primitives/animate/slot.tsx +96 -0
- package/templates/shared/components/animate-ui/primitives/animate/tooltip.tsx +547 -0
- package/templates/shared/components/ui/navigation-menu.tsx +161 -0
- package/templates/shared/lib/get-strict-context.tsx +36 -0
package/README.md
CHANGED
|
@@ -356,6 +356,8 @@ pnpm dlx blocks-dusted doctor
|
|
|
356
356
|
pnpm dlx blocks-dusted add DD-Hero --dry-run
|
|
357
357
|
pnpm dlx blocks-dusted add DD-Hero
|
|
358
358
|
pnpm dlx blocks-dusted add RichText
|
|
359
|
+
pnpm dlx blocks-dusted header add HeaderDD02 --dry-run
|
|
360
|
+
pnpm dlx blocks-dusted header add HeaderDD02
|
|
359
361
|
```
|
|
360
362
|
|
|
361
363
|
Use `blocks-dusted list` first to see the exact available block names in the package. `DD-Hero` is an existing block in the current manifest registry.
|
|
@@ -378,6 +380,8 @@ blocks-dusted add DD-Hero --dry-run
|
|
|
378
380
|
blocks-dusted add DD-Hero --no-register
|
|
379
381
|
blocks-dusted add DD-Hero --cwd C:\Projects\my-payload-site
|
|
380
382
|
blocks-dusted add RichText --cwd C:\Projects\my-payload-site
|
|
383
|
+
blocks-dusted header add HeaderDD02 --dry-run
|
|
384
|
+
blocks-dusted header add HeaderDD02
|
|
381
385
|
```
|
|
382
386
|
|
|
383
387
|
`--cwd` targets a Payload project directory without changing your shell directory. Relative `--cwd` values resolve from the directory where you launched the command.
|
|
@@ -403,6 +407,17 @@ pnpm payload generate:importmap
|
|
|
403
407
|
|
|
404
408
|
The CLI does not run migrations or build the target project.
|
|
405
409
|
|
|
410
|
+
### Header workflows
|
|
411
|
+
|
|
412
|
+
Headers are active Payload globals and layout wiring, not ordinary page blocks. Use the dedicated header workflow when you want to activate a reusable header implementation.
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
pnpm dlx blocks-dusted header add HeaderDD02 --dry-run
|
|
416
|
+
pnpm dlx blocks-dusted header add HeaderDD02
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The HeaderDD02 workflow backs up the active Header config, Nav renderer, and RowLabel before patching them. It installs the HeaderDD02 variant files, copies missing shared UI primitives, patches `src/Header/Nav/index.tsx` to render HeaderDD02, and patches `src/Header/config.ts` to expose HeaderDD02 fields while retaining legacy starter `navItems` as hidden data. Run Payload type and import-map generation after reviewing the changes.
|
|
420
|
+
|
|
406
421
|
## Payload Block Standard
|
|
407
422
|
|
|
408
423
|
### Purpose
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import { addTemplate } from './commands/add.js'
|
|
4
4
|
import { doctor } from './commands/doctor.js'
|
|
5
|
+
import { addHeader } from './commands/header.js'
|
|
5
6
|
import { list } from './commands/list.js'
|
|
6
7
|
import { error, info } from './utils/logger.js'
|
|
7
8
|
import { packageRoot } from './utils/paths.js'
|
|
@@ -12,7 +13,8 @@ function printHelp() {
|
|
|
12
13
|
info('Usage:')
|
|
13
14
|
info(' blocks-dusted list')
|
|
14
15
|
info(' blocks-dusted doctor [--cwd <path>]')
|
|
15
|
-
info(' blocks-dusted add <
|
|
16
|
+
info(' blocks-dusted add <TemplateName> [--dry-run] [--no-register] [--cwd <path>]')
|
|
17
|
+
info(' blocks-dusted header add <HeaderName> [--dry-run] [--cwd <path>]')
|
|
16
18
|
info(' blocks-dusted --version')
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -34,6 +36,17 @@ export async function runCli(args, options = {}) {
|
|
|
34
36
|
|
|
35
37
|
if (command === 'list') return list()
|
|
36
38
|
if (command === 'doctor') return doctor({ targetDirectory })
|
|
39
|
+
if (command === 'header') {
|
|
40
|
+
const [subcommand, ...headerRest] = rest
|
|
41
|
+
if (subcommand !== 'add') throw new Error('Usage: blocks-dusted header add <HeaderName> [--dry-run]')
|
|
42
|
+
const headerName = headerRest.find((argument) => !argument.startsWith('-'))
|
|
43
|
+
return addHeader({
|
|
44
|
+
headerName,
|
|
45
|
+
targetDirectory,
|
|
46
|
+
dryRun: headerRest.includes('--dry-run'),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
if (command === 'add') {
|
|
38
51
|
const templateName = rest.find((argument) => !argument.startsWith('-'))
|
|
39
52
|
return addTemplate({
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises'
|
|
2
|
+
import { copySharedFiles, copyTemplateFiles, backupExistingDestination, plannedBackupPath } from '../installers/copyTemplateFiles.js'
|
|
3
|
+
import { checkDependencies } from '../installers/installDependencies.js'
|
|
4
|
+
import { checkRequirements } from '../installers/checkRequirements.js'
|
|
5
|
+
import { loadTemplateManifest } from '../registry/loadTemplateManifest.js'
|
|
6
|
+
import { validateTemplateManifest } from '../registry/validateTemplateManifest.js'
|
|
7
|
+
import { heading, info, item, status } from '../utils/logger.js'
|
|
8
|
+
import { packageRoot } from '../utils/paths.js'
|
|
9
|
+
import { fileExists, validateTargetProject } from '../utils/project.js'
|
|
10
|
+
|
|
11
|
+
const SUPPORTED_HEADERS = new Set(['HeaderDD02'])
|
|
12
|
+
const ACTIVE_HEADER_FILES = [
|
|
13
|
+
'src/Header/config.ts',
|
|
14
|
+
'src/Header/Nav/index.tsx',
|
|
15
|
+
'src/Header/RowLabel.tsx',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export async function addHeader({ headerName, targetDirectory = process.cwd(), dryRun = false }) {
|
|
19
|
+
if (!headerName) throw new Error('A header name is required. Example: blocks-dusted header add HeaderDD02 --dry-run')
|
|
20
|
+
if (!SUPPORTED_HEADERS.has(headerName)) throw new Error(`Unsupported header workflow: ${headerName}`)
|
|
21
|
+
|
|
22
|
+
const { manifest, templateDirectory } = await loadTemplateManifest(headerName)
|
|
23
|
+
validateTemplateManifest(manifest)
|
|
24
|
+
if (manifest.type !== 'component') throw new Error(`${headerName} must be a component template.`)
|
|
25
|
+
|
|
26
|
+
const checks = await checkRequirements({ manifest, targetDirectory })
|
|
27
|
+
const dependencyChecks = await checkDependencies({ manifest, targetDirectory })
|
|
28
|
+
const project = await validateTargetProject(targetDirectory)
|
|
29
|
+
const missingRequired = checks.missingRequirements ?? []
|
|
30
|
+
if (missingRequired.length > 0) {
|
|
31
|
+
throw new Error(`Required project files are missing:\n${missingRequired.map(formatMissingRequirement).join('\n')}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const activeFileChecks = await Promise.all(
|
|
35
|
+
ACTIVE_HEADER_FILES.map(async (path) => ({ path, exists: await fileExists(`${targetDirectory}/${path}`) })),
|
|
36
|
+
)
|
|
37
|
+
const missingActive = activeFileChecks.filter((check) => !check.exists)
|
|
38
|
+
if (missingActive.length > 0) {
|
|
39
|
+
throw new Error(`Active Header files are missing:\n${missingActive.map((check) => `- ${check.path}`).join('\n')}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const destinationBackupPath = checks.collisions.destinationExists
|
|
43
|
+
? await plannedBackupPath({ destinationPath: checks.collisions.destinationPath, targetDirectory })
|
|
44
|
+
: null
|
|
45
|
+
const activeBackups = await Promise.all(
|
|
46
|
+
ACTIVE_HEADER_FILES.map(async (path) => ({
|
|
47
|
+
path,
|
|
48
|
+
backupPath: await plannedBackupPath({ destinationPath: path, targetDirectory }),
|
|
49
|
+
})),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
heading('Header workflow')
|
|
53
|
+
info(`Name: ${manifest.name}`)
|
|
54
|
+
info(`Target directory: ${targetDirectory}`)
|
|
55
|
+
info(`Payload config: ${project.payloadConfigPath}`)
|
|
56
|
+
info(`Source template path: ${templateDirectory}`)
|
|
57
|
+
info(`Target install path: ${checks.collisions.destinationPath}`)
|
|
58
|
+
|
|
59
|
+
heading(dryRun ? 'Files that would be installed' : 'Files to install')
|
|
60
|
+
for (const file of manifest.files) item(`${file.from} -> ${file.to}`)
|
|
61
|
+
|
|
62
|
+
heading('Required project files')
|
|
63
|
+
for (const check of checks.requiredFiles) status(check.exists, check.path, check.reason)
|
|
64
|
+
|
|
65
|
+
heading('Shared files')
|
|
66
|
+
if ((manifest.sharedFiles ?? []).length === 0) item('None')
|
|
67
|
+
for (const file of manifest.sharedFiles ?? []) item(`${file.from} -> ${file.to}`)
|
|
68
|
+
|
|
69
|
+
heading('Package dependencies')
|
|
70
|
+
for (const dependency of dependencyChecks.declared) {
|
|
71
|
+
const isMissing = dependencyChecks.missing.some((missing) => missing.name === dependency.name)
|
|
72
|
+
status(!isMissing, formatDependency(dependency), isMissing ? 'missing' : 'already listed')
|
|
73
|
+
}
|
|
74
|
+
if (dependencyChecks.missing.length > 0) item(`Install command: ${installCommandText(dependencyChecks.packageManager, dependencyChecks.missing)}`)
|
|
75
|
+
|
|
76
|
+
heading('Active Header backups')
|
|
77
|
+
for (const backup of activeBackups) {
|
|
78
|
+
item(dryRun ? `Would back up ${backup.path} to ${backup.backupPath}` : `Will back up ${backup.path} to ${backup.backupPath}`)
|
|
79
|
+
}
|
|
80
|
+
if (destinationBackupPath) {
|
|
81
|
+
item(dryRun ? `Would back up ${checks.collisions.destinationPath} to ${destinationBackupPath}` : `Will back up ${checks.collisions.destinationPath} to ${destinationBackupPath}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
heading('Activation patches')
|
|
85
|
+
item('Patch src/Header/Nav/index.tsx to render HeaderDD02')
|
|
86
|
+
item('Patch src/Header/config.ts to expose HeaderDD02 fields and preserve legacy navItems as hidden data')
|
|
87
|
+
|
|
88
|
+
if (dryRun) {
|
|
89
|
+
const sharedResults = await copySharedFiles({ manifest, packageRoot, targetDirectory, dryRun: true })
|
|
90
|
+
heading('Dry-run shared file results')
|
|
91
|
+
if (sharedResults.length === 0) item('None')
|
|
92
|
+
sharedResults.forEach((result) => item(`${result.status}: ${result.to}${result.reason ? ` - ${result.reason}` : ''}`))
|
|
93
|
+
info('Dry run complete: no files were changed.')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sharedResults = await copySharedFiles({ manifest, packageRoot, targetDirectory })
|
|
98
|
+
|
|
99
|
+
if (checks.collisions.destinationExists) {
|
|
100
|
+
await backupExistingDestination({ destinationPath: checks.collisions.destinationPath, targetDirectory })
|
|
101
|
+
}
|
|
102
|
+
for (const file of ACTIVE_HEADER_FILES) {
|
|
103
|
+
await backupExistingDestination({ destinationPath: file, targetDirectory })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const copiedFiles = await copyTemplateFiles({ manifest, templateDirectory, targetDirectory })
|
|
107
|
+
await writeFile(`${targetDirectory}/src/Header/Nav/index.tsx`, renderHeaderNav())
|
|
108
|
+
await writeFile(`${targetDirectory}/src/Header/config.ts`, renderHeaderConfig())
|
|
109
|
+
|
|
110
|
+
heading('Copied files')
|
|
111
|
+
copiedFiles.forEach((file) => item(file))
|
|
112
|
+
heading('Shared file results')
|
|
113
|
+
if (sharedResults.length === 0) item('None')
|
|
114
|
+
sharedResults.forEach((result) => item(`${result.status}: ${result.to}${result.reason ? ` - ${result.reason}` : ''}`))
|
|
115
|
+
heading('Dependency install')
|
|
116
|
+
item('Skipped: dependency installation is manual')
|
|
117
|
+
if (dependencyChecks.missing.length > 0) item(`Run manually: ${installCommandText(dependencyChecks.packageManager, dependencyChecks.missing)}`)
|
|
118
|
+
heading('Payload commands to run manually')
|
|
119
|
+
const payloadCommand = dependencyChecks.packageManager === 'npm' ? 'npx payload' : `${dependencyChecks.packageManager} payload`
|
|
120
|
+
item(`${payloadCommand} generate:types`)
|
|
121
|
+
item(`${payloadCommand} generate:importmap`)
|
|
122
|
+
info('Header workflow complete.')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderHeaderNav() {
|
|
126
|
+
return `import React from 'react'\n\nimport type { Header as HeaderType } from '@/payload-types'\n\nimport HeaderDD02 from '@/Header/variants/HeaderDD02'\n\nexport const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {\n return <HeaderDD02 data={data} />\n}\n`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderHeaderConfig() {
|
|
130
|
+
return `import type { GlobalConfig } from 'payload'\n\nimport { iconPicker } from '@/fields/lucide-icon-picker/field'\nimport { link } from '@/fields/link'\nimport { HeaderDD02 } from '@/Header/variants/HeaderDD02/config'\nimport { revalidateHeader } from './hooks/revalidateHeader'\n\nexport const Header: GlobalConfig = {\n slug: 'header',\n access: {\n read: () => true,\n update: () => true,\n },\n fields: [\n {\n name: 'variant',\n label: 'Variant',\n type: 'select',\n defaultValue: 'header02',\n options: [{ label: 'Header DD 02', value: 'header02' }],\n admin: {\n position: 'sidebar',\n },\n },\n {\n name: 'header02',\n type: 'group',\n fields: HeaderDD02,\n },\n {\n name: 'navItems',\n type: 'array',\n admin: {\n hidden: true,\n description: 'Legacy starter Header navItems retained so existing stored header data is not discarded.',\n },\n fields: [\n link({ appearances: false }),\n iconPicker({\n name: 'icon',\n required: false,\n description: 'Legacy optional icon to display with the navigation item',\n }),\n ],\n },\n ],\n hooks: {\n afterChange: [revalidateHeader],\n },\n}\n`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatMissingRequirement(check) {
|
|
134
|
+
const resolution = check.resolution ? `\n Resolve: ${check.resolution}` : ''
|
|
135
|
+
return `- ${check.path}: ${check.reason}${resolution}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatDependency(dependency) {
|
|
139
|
+
return `${dependency.name}${dependency.version ? `@${dependency.version}` : ''}`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function installCommandText(packageManager, dependencies) {
|
|
143
|
+
const command = packageManager === 'yarn' ? ['yarn', 'add'] : packageManager === 'bun' ? ['bun', 'add'] : packageManager === 'npm' ? ['npm', 'install'] : ['pnpm', 'add']
|
|
144
|
+
return [...command, ...dependencies.map(formatDependency)].join(' ')
|
|
145
|
+
}
|
|
@@ -133,7 +133,7 @@ export default function MobileBottomNav({ data }: MobileBottomNavProps) {
|
|
|
133
133
|
const pathname = usePathname()
|
|
134
134
|
const normalizedCurrentPathname = useMemo(() => normalizePath(pathname), [pathname])
|
|
135
135
|
|
|
136
|
-
const navItems = data?.header02?.navItems || []
|
|
136
|
+
const navItems = data?.header02?.navItems || (data as any)?.navItems || []
|
|
137
137
|
const showLabels = data?.header02?.showMobileLabels ?? true
|
|
138
138
|
|
|
139
139
|
useEffect(() => {
|
|
@@ -43,3 +43,9 @@ Do not modify database records automatically.
|
|
|
43
43
|
Do not run destructive migrations.
|
|
44
44
|
Do not remove the existing header until the replacement is verified.
|
|
45
45
|
```
|
|
46
|
+
|
|
47
|
+
## Bundled shared UI primitives
|
|
48
|
+
|
|
49
|
+
HeaderDD02 includes shared templates for `src/components/ui/navigation-menu.tsx`, `src/components/animate-ui/components/animate/tooltip.tsx`, `src/components/animate-ui/primitives/animate/tooltip.tsx`, `src/components/animate-ui/primitives/animate/slot.tsx`, and `src/lib/get-strict-context.tsx`. The installer copies them only when missing and preserves existing target files.
|
|
50
|
+
|
|
51
|
+
If package dependencies are missing, run the install command printed by the CLI, for example `pnpm add @radix-ui/react-navigation-menu class-variance-authority motion @floating-ui/react`.
|
|
@@ -251,7 +251,7 @@ export default function HeaderDD02({ data, variant = 'default', sticky = true }:
|
|
|
251
251
|
}, [enableHideOnScroll, scrollThreshold])
|
|
252
252
|
|
|
253
253
|
// Extract navItems from header02 data
|
|
254
|
-
const navItems = data?.header02?.navItems || []
|
|
254
|
+
const navItems = data?.header02?.navItems || (data as any)?.navItems || []
|
|
255
255
|
|
|
256
256
|
return (
|
|
257
257
|
<>
|
|
@@ -56,6 +56,18 @@
|
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"name": "lucide-react"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "@radix-ui/react-navigation-menu"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "class-variance-authority"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "motion"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "@floating-ui/react"
|
|
59
71
|
}
|
|
60
72
|
],
|
|
61
73
|
"requiredProjectFiles": [
|
|
@@ -71,11 +83,6 @@
|
|
|
71
83
|
"path": "src/fields/lucide-icon-picker/field.ts",
|
|
72
84
|
"reason": "HeaderDD02 dropdown trigger icons reuse the shared icon picker field."
|
|
73
85
|
},
|
|
74
|
-
{
|
|
75
|
-
"path": "src/components/ui/navigation-menu.tsx",
|
|
76
|
-
"reason": "HeaderDD02 desktop navigation renders the target project's NavigationMenu primitives.",
|
|
77
|
-
"resolution": "Install the shadcn/radix NavigationMenu primitive used by the target project, or copy the target project's existing navigation-menu.tsx to src/components/ui/navigation-menu.tsx before installing HeaderDD02."
|
|
78
|
-
},
|
|
79
86
|
{
|
|
80
87
|
"path": "src/components/RichText/index.tsx",
|
|
81
88
|
"reason": "HeaderDD02 renders richTextContent and floatingContent through the target project's RichText renderer."
|
|
@@ -83,11 +90,6 @@
|
|
|
83
90
|
{
|
|
84
91
|
"path": "src/utilities/ui.ts",
|
|
85
92
|
"reason": "HeaderDD02 uses cn from @/utilities/ui."
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
"path": "src/components/animate-ui/components/animate/tooltip.tsx",
|
|
89
|
-
"reason": "HeaderDD02 MobileBottomNav uses the target tooltip primitives.",
|
|
90
|
-
"resolution": "Install or copy the animate-ui tooltip primitive that exports TooltipProvider, Tooltip, TooltipTrigger, and TooltipContent at src/components/animate-ui/components/animate/tooltip.tsx before installing HeaderDD02."
|
|
91
93
|
}
|
|
92
94
|
],
|
|
93
95
|
"collectionRegistration": {
|
|
@@ -104,7 +106,7 @@
|
|
|
104
106
|
"Inspect any existing target Header richText data before replacing an active Header schema. Do not apply the empty nested blocks extension point to stored content that already depends on registered block node types.",
|
|
105
107
|
"Verify the target `src/fields/defaultLexical.ts` exports a compatible `defaultLexical` helper that accepts `defaultLexical({ features: [] })` and does not inject unrelated project-specific blocks.",
|
|
106
108
|
"Wire `HeaderDD02` into the target Header global or render it directly after confirming the target project will not render two headers.",
|
|
107
|
-
"
|
|
109
|
+
"HeaderDD02 can install its missing NavigationMenu and animate-ui tooltip primitives as shared files. Existing target files are preserved and skipped instead of overwritten."
|
|
108
110
|
],
|
|
109
111
|
"manualSteps": [
|
|
110
112
|
"This header uses the shared `defaultLexical` editor standard from `@/fields/defaultLexical`. Project-specific Lexical blocks are intentionally not bundled. The nested `BlocksFeature` uses an empty `blocks` array so each target project can register only the blocks it requires.",
|
|
@@ -114,14 +116,47 @@
|
|
|
114
116
|
"Inspect any existing target Header richText data before replacing an active Header schema. Do not apply the empty nested blocks extension point to stored content that already depends on registered block node types.",
|
|
115
117
|
"Verify the target `src/fields/defaultLexical.ts` exports a compatible `defaultLexical` helper that accepts `defaultLexical({ features: [] })` and does not inject unrelated project-specific blocks.",
|
|
116
118
|
"Wire `HeaderDD02` into the target Header global or render it directly after confirming the target project will not render two headers.",
|
|
117
|
-
"
|
|
119
|
+
"HeaderDD02 can install its missing NavigationMenu and animate-ui tooltip primitives as shared files. Existing target files are preserved and skipped instead of overwritten."
|
|
118
120
|
],
|
|
119
121
|
"notes": [
|
|
120
122
|
"Converted from DesignsDustedv3 Header02 after tracing Header/variants/Header02/index.tsx and its direct imports.",
|
|
121
123
|
"Header02 styling is inline Tailwind class usage plus style jsx global blocks in index.tsx and MobileBottomNav/index.tsx; no separate Header02 stylesheet was imported.",
|
|
122
|
-
"Parent Header files were audited but excluded because the reusable export targets Header02 only, not the multi-variant app header shell."
|
|
124
|
+
"Parent Header files were audited but excluded because the reusable export targets Header02 only, not the multi-variant app header shell.",
|
|
125
|
+
"NavigationMenu and animate-ui tooltip primitives are bundled as shared files because HeaderDD02 imports them directly."
|
|
123
126
|
],
|
|
124
127
|
"backupExistingDestination": true,
|
|
125
128
|
"exportName": "HeaderDD02",
|
|
126
|
-
"interfaceName": "HeaderDD02"
|
|
129
|
+
"interfaceName": "HeaderDD02",
|
|
130
|
+
"sharedFiles": [
|
|
131
|
+
{
|
|
132
|
+
"from": "templates/shared/components/ui/navigation-menu.tsx",
|
|
133
|
+
"to": "src/components/ui/navigation-menu.tsx",
|
|
134
|
+
"required": true,
|
|
135
|
+
"safeToCopy": true
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"from": "templates/shared/components/animate-ui/components/animate/tooltip.tsx",
|
|
139
|
+
"to": "src/components/animate-ui/components/animate/tooltip.tsx",
|
|
140
|
+
"required": true,
|
|
141
|
+
"safeToCopy": true
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"from": "templates/shared/components/animate-ui/primitives/animate/tooltip.tsx",
|
|
145
|
+
"to": "src/components/animate-ui/primitives/animate/tooltip.tsx",
|
|
146
|
+
"required": true,
|
|
147
|
+
"safeToCopy": true
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"from": "templates/shared/components/animate-ui/primitives/animate/slot.tsx",
|
|
151
|
+
"to": "src/components/animate-ui/primitives/animate/slot.tsx",
|
|
152
|
+
"required": true,
|
|
153
|
+
"safeToCopy": true
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"from": "templates/shared/lib/get-strict-context.tsx",
|
|
157
|
+
"to": "src/lib/get-strict-context.tsx",
|
|
158
|
+
"required": true,
|
|
159
|
+
"safeToCopy": true
|
|
160
|
+
}
|
|
161
|
+
]
|
|
127
162
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as motion from 'motion/react-client';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
TooltipProvider as TooltipProviderPrimitive,
|
|
6
|
+
Tooltip as TooltipPrimitive,
|
|
7
|
+
TooltipTrigger as TooltipTriggerPrimitive,
|
|
8
|
+
TooltipContent as TooltipContentPrimitive,
|
|
9
|
+
TooltipArrow as TooltipArrowPrimitive,
|
|
10
|
+
type TooltipProviderProps as TooltipProviderPrimitiveProps,
|
|
11
|
+
type TooltipProps as TooltipPrimitiveProps,
|
|
12
|
+
type TooltipTriggerProps as TooltipTriggerPrimitiveProps,
|
|
13
|
+
type TooltipContentProps as TooltipContentPrimitiveProps,
|
|
14
|
+
} from '@/components/animate-ui/primitives/animate/tooltip';
|
|
15
|
+
import { cn } from '@/utilities/ui';
|
|
16
|
+
|
|
17
|
+
type TooltipProviderProps = TooltipProviderPrimitiveProps;
|
|
18
|
+
|
|
19
|
+
function TooltipProvider({ openDelay = 0, ...props }: TooltipProviderProps) {
|
|
20
|
+
return <TooltipProviderPrimitive openDelay={openDelay} {...props} />;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type TooltipProps = TooltipPrimitiveProps;
|
|
24
|
+
|
|
25
|
+
function Tooltip({ sideOffset = 10, ...props }: TooltipProps) {
|
|
26
|
+
return <TooltipPrimitive sideOffset={sideOffset} {...props} />;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type TooltipTriggerProps = TooltipTriggerPrimitiveProps;
|
|
30
|
+
|
|
31
|
+
function TooltipTrigger({ ...props }: TooltipTriggerProps) {
|
|
32
|
+
return <TooltipTriggerPrimitive {...props} />;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type TooltipContentProps = Omit<TooltipContentPrimitiveProps, 'asChild'> & {
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
layout?: boolean | 'position' | 'size' | 'preserve-aspect';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function TooltipContent({
|
|
41
|
+
className,
|
|
42
|
+
children,
|
|
43
|
+
layout = 'preserve-aspect',
|
|
44
|
+
...props
|
|
45
|
+
}: TooltipContentProps) {
|
|
46
|
+
return (
|
|
47
|
+
<TooltipContentPrimitive
|
|
48
|
+
className={cn(
|
|
49
|
+
'z-50 w-fit bg-primary text-primary-foreground rounded-md',
|
|
50
|
+
className,
|
|
51
|
+
)}
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
<motion.div className="overflow-hidden px-3 py-1.5 text-xs text-balance">
|
|
55
|
+
<motion.div layout={layout}>{children}</motion.div>
|
|
56
|
+
</motion.div>
|
|
57
|
+
<TooltipArrowPrimitive
|
|
58
|
+
className="fill-primary size-3 data-[side='bottom']:translate-y-[1px] data-[side='right']:translate-x-[1px] data-[side='left']:translate-x-[-1px] data-[side='top']:translate-y-[-1px]"
|
|
59
|
+
tipRadius={2}
|
|
60
|
+
/>
|
|
61
|
+
</TooltipContentPrimitive>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export {
|
|
66
|
+
TooltipProvider,
|
|
67
|
+
Tooltip,
|
|
68
|
+
TooltipTrigger,
|
|
69
|
+
TooltipContent,
|
|
70
|
+
type TooltipProviderProps,
|
|
71
|
+
type TooltipProps,
|
|
72
|
+
type TooltipTriggerProps,
|
|
73
|
+
type TooltipContentProps,
|
|
74
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { motion, isMotionComponent, type HTMLMotionProps } from 'motion/react';
|
|
5
|
+
import { cn } from '@/utilities/ui';
|
|
6
|
+
|
|
7
|
+
type AnyProps = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
|
|
10
|
+
HTMLMotionProps<keyof HTMLElementTagNameMap>,
|
|
11
|
+
'ref'
|
|
12
|
+
> & { ref?: React.Ref<T> };
|
|
13
|
+
|
|
14
|
+
type WithAsChild<Base extends object> =
|
|
15
|
+
| (Base & { asChild: true; children: React.ReactElement })
|
|
16
|
+
| (Base & { asChild?: false | undefined });
|
|
17
|
+
|
|
18
|
+
type SlotProps<T extends HTMLElement = HTMLElement> = {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
children?: any;
|
|
21
|
+
} & DOMMotionProps<T>;
|
|
22
|
+
|
|
23
|
+
function mergeRefs<T>(
|
|
24
|
+
...refs: (React.Ref<T> | undefined)[]
|
|
25
|
+
): React.RefCallback<T> {
|
|
26
|
+
return (node) => {
|
|
27
|
+
refs.forEach((ref) => {
|
|
28
|
+
if (!ref) return;
|
|
29
|
+
if (typeof ref === 'function') {
|
|
30
|
+
ref(node);
|
|
31
|
+
} else {
|
|
32
|
+
(ref as React.RefObject<T | null>).current = node;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mergeProps<T extends HTMLElement>(
|
|
39
|
+
childProps: AnyProps,
|
|
40
|
+
slotProps: DOMMotionProps<T>,
|
|
41
|
+
): AnyProps {
|
|
42
|
+
const merged: AnyProps = { ...childProps, ...slotProps };
|
|
43
|
+
|
|
44
|
+
if (childProps.className || slotProps.className) {
|
|
45
|
+
merged.className = cn(
|
|
46
|
+
childProps.className as string,
|
|
47
|
+
slotProps.className as string,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (childProps.style || slotProps.style) {
|
|
52
|
+
merged.style = {
|
|
53
|
+
...(childProps.style as React.CSSProperties),
|
|
54
|
+
...(slotProps.style as React.CSSProperties),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return merged;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Slot<T extends HTMLElement = HTMLElement>({
|
|
62
|
+
children,
|
|
63
|
+
ref,
|
|
64
|
+
...props
|
|
65
|
+
}: SlotProps<T>) {
|
|
66
|
+
const isAlreadyMotion =
|
|
67
|
+
typeof children.type === 'object' &&
|
|
68
|
+
children.type !== null &&
|
|
69
|
+
isMotionComponent(children.type);
|
|
70
|
+
|
|
71
|
+
const Base = React.useMemo(
|
|
72
|
+
() =>
|
|
73
|
+
isAlreadyMotion
|
|
74
|
+
? (children.type as React.ElementType)
|
|
75
|
+
: motion.create(children.type as React.ElementType),
|
|
76
|
+
[isAlreadyMotion, children.type],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (!React.isValidElement(children)) return null;
|
|
80
|
+
|
|
81
|
+
const { ref: childRef, ...childProps } = children.props as AnyProps;
|
|
82
|
+
|
|
83
|
+
const mergedProps = mergeProps(childProps, props);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export {
|
|
91
|
+
Slot,
|
|
92
|
+
type SlotProps,
|
|
93
|
+
type WithAsChild,
|
|
94
|
+
type DOMMotionProps,
|
|
95
|
+
type AnyProps,
|
|
96
|
+
};
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
motion,
|
|
6
|
+
AnimatePresence,
|
|
7
|
+
LayoutGroup,
|
|
8
|
+
type Transition,
|
|
9
|
+
type HTMLMotionProps
|
|
10
|
+
} from 'motion/react'
|
|
11
|
+
import {
|
|
12
|
+
useFloating,
|
|
13
|
+
autoUpdate,
|
|
14
|
+
offset as floatingOffset,
|
|
15
|
+
flip,
|
|
16
|
+
shift,
|
|
17
|
+
arrow as floatingArrow,
|
|
18
|
+
FloatingPortal,
|
|
19
|
+
FloatingArrow,
|
|
20
|
+
type UseFloatingReturn
|
|
21
|
+
} from '@floating-ui/react'
|
|
22
|
+
|
|
23
|
+
import { getStrictContext } from '@/lib/get-strict-context'
|
|
24
|
+
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot'
|
|
25
|
+
|
|
26
|
+
type Side = 'top' | 'bottom' | 'left' | 'right'
|
|
27
|
+
type Align = 'start' | 'center' | 'end'
|
|
28
|
+
|
|
29
|
+
type TooltipData = {
|
|
30
|
+
contentProps: HTMLMotionProps<'div'>
|
|
31
|
+
contentAsChild: boolean
|
|
32
|
+
rect: DOMRect
|
|
33
|
+
side: Side
|
|
34
|
+
sideOffset: number
|
|
35
|
+
align: Align
|
|
36
|
+
alignOffset: number
|
|
37
|
+
id: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type GlobalTooltipContextType = {
|
|
41
|
+
showTooltip: (data: TooltipData) => void
|
|
42
|
+
hideTooltip: () => void
|
|
43
|
+
hideImmediate: () => void
|
|
44
|
+
currentTooltip: TooltipData | null
|
|
45
|
+
transition: Transition
|
|
46
|
+
globalId: string
|
|
47
|
+
setReferenceEl: (el: HTMLElement | null) => void
|
|
48
|
+
referenceElRef: React.RefObject<HTMLElement | null>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [GlobalTooltipProvider, useGlobalTooltip] =
|
|
52
|
+
getStrictContext<GlobalTooltipContextType>('GlobalTooltipProvider')
|
|
53
|
+
|
|
54
|
+
type TooltipContextType = {
|
|
55
|
+
props: HTMLMotionProps<'div'>
|
|
56
|
+
setProps: React.Dispatch<React.SetStateAction<HTMLMotionProps<'div'>>>
|
|
57
|
+
asChild: boolean
|
|
58
|
+
setAsChild: React.Dispatch<React.SetStateAction<boolean>>
|
|
59
|
+
side: Side
|
|
60
|
+
sideOffset: number
|
|
61
|
+
align: Align
|
|
62
|
+
alignOffset: number
|
|
63
|
+
id: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const [LocalTooltipProvider, useTooltip] =
|
|
67
|
+
getStrictContext<TooltipContextType>('LocalTooltipProvider')
|
|
68
|
+
|
|
69
|
+
type TooltipPosition = { x: number; y: number }
|
|
70
|
+
|
|
71
|
+
function getResolvedSide(placement: Side | `${Side}-${Align}`) {
|
|
72
|
+
if (placement.includes('-')) {
|
|
73
|
+
return placement.split('-')[0] as Side
|
|
74
|
+
}
|
|
75
|
+
return placement as Side
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function initialFromSide(side: Side): Partial<Record<'x' | 'y', number>> {
|
|
79
|
+
if (side === 'top') return { y: 15 }
|
|
80
|
+
if (side === 'bottom') return { y: -15 }
|
|
81
|
+
if (side === 'left') return { x: 15 }
|
|
82
|
+
return { x: -15 }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type TooltipProviderProps = {
|
|
86
|
+
children: React.ReactNode
|
|
87
|
+
id?: string
|
|
88
|
+
openDelay?: number
|
|
89
|
+
closeDelay?: number
|
|
90
|
+
transition?: Transition
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function TooltipProvider({
|
|
94
|
+
children,
|
|
95
|
+
id,
|
|
96
|
+
openDelay = 700,
|
|
97
|
+
closeDelay = 300,
|
|
98
|
+
transition = { type: 'spring', stiffness: 300, damping: 35 }
|
|
99
|
+
}: TooltipProviderProps) {
|
|
100
|
+
const globalId = React.useId()
|
|
101
|
+
const [currentTooltip, setCurrentTooltip] = React.useState<TooltipData | null>(null)
|
|
102
|
+
const timeoutRef = React.useRef<number | null>(null)
|
|
103
|
+
const lastCloseTimeRef = React.useRef<number>(0)
|
|
104
|
+
const referenceElRef = React.useRef<HTMLElement | null>(null)
|
|
105
|
+
|
|
106
|
+
const showTooltip = React.useCallback(
|
|
107
|
+
(data: TooltipData) => {
|
|
108
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
109
|
+
if (currentTooltip !== null) {
|
|
110
|
+
setCurrentTooltip(data)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const now = Date.now()
|
|
114
|
+
const delay = now - lastCloseTimeRef.current < closeDelay ? 0 : openDelay
|
|
115
|
+
timeoutRef.current = window.setTimeout(() => setCurrentTooltip(data), delay)
|
|
116
|
+
},
|
|
117
|
+
[openDelay, closeDelay, currentTooltip]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const hideTooltip = React.useCallback(() => {
|
|
121
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
122
|
+
timeoutRef.current = window.setTimeout(() => {
|
|
123
|
+
setCurrentTooltip(null)
|
|
124
|
+
lastCloseTimeRef.current = Date.now()
|
|
125
|
+
}, closeDelay)
|
|
126
|
+
}, [closeDelay])
|
|
127
|
+
|
|
128
|
+
const hideImmediate = React.useCallback(() => {
|
|
129
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
130
|
+
setCurrentTooltip(null)
|
|
131
|
+
lastCloseTimeRef.current = Date.now()
|
|
132
|
+
}, [])
|
|
133
|
+
|
|
134
|
+
const setReferenceEl = React.useCallback((el: HTMLElement | null) => {
|
|
135
|
+
referenceElRef.current = el
|
|
136
|
+
}, [])
|
|
137
|
+
|
|
138
|
+
React.useEffect(() => {
|
|
139
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
140
|
+
if (e.key === 'Escape') hideImmediate()
|
|
141
|
+
}
|
|
142
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
143
|
+
window.addEventListener('scroll', hideImmediate, true)
|
|
144
|
+
window.addEventListener('resize', hideImmediate, true)
|
|
145
|
+
return () => {
|
|
146
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
147
|
+
window.removeEventListener('scroll', hideImmediate, true)
|
|
148
|
+
window.removeEventListener('resize', hideImmediate, true)
|
|
149
|
+
}
|
|
150
|
+
}, [hideImmediate])
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<GlobalTooltipProvider
|
|
154
|
+
value={{
|
|
155
|
+
showTooltip,
|
|
156
|
+
hideTooltip,
|
|
157
|
+
hideImmediate,
|
|
158
|
+
currentTooltip,
|
|
159
|
+
transition,
|
|
160
|
+
globalId: id ?? globalId,
|
|
161
|
+
setReferenceEl,
|
|
162
|
+
referenceElRef
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<LayoutGroup>{children}</LayoutGroup>
|
|
166
|
+
<TooltipOverlay />
|
|
167
|
+
</GlobalTooltipProvider>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type RenderedTooltipContextType = {
|
|
172
|
+
side: Side
|
|
173
|
+
align: Align
|
|
174
|
+
open: boolean
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const [RenderedTooltipProvider, useRenderedTooltip] =
|
|
178
|
+
getStrictContext<RenderedTooltipContextType>('RenderedTooltipContext')
|
|
179
|
+
|
|
180
|
+
type FloatingContextType = {
|
|
181
|
+
context: UseFloatingReturn['context']
|
|
182
|
+
arrowRef: React.RefObject<SVGSVGElement | null>
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const [FloatingProvider, useFloatingContext] =
|
|
186
|
+
getStrictContext<FloatingContextType>('FloatingContext')
|
|
187
|
+
|
|
188
|
+
const MotionTooltipArrow = motion.create(FloatingArrow)
|
|
189
|
+
|
|
190
|
+
type TooltipArrowProps = Omit<React.ComponentProps<typeof MotionTooltipArrow>, 'context'> & {
|
|
191
|
+
withTransition?: boolean
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function TooltipArrow({ ref, withTransition = true, ...props }: TooltipArrowProps) {
|
|
195
|
+
const { side, align, open } = useRenderedTooltip()
|
|
196
|
+
const { context, arrowRef } = useFloatingContext()
|
|
197
|
+
const { transition, globalId } = useGlobalTooltip()
|
|
198
|
+
React.useImperativeHandle(ref, () => arrowRef.current as SVGSVGElement)
|
|
199
|
+
|
|
200
|
+
const deg = { top: 0, right: 90, bottom: 180, left: -90 }[side]
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<MotionTooltipArrow
|
|
204
|
+
ref={arrowRef}
|
|
205
|
+
context={context}
|
|
206
|
+
data-state={open ? 'open' : 'closed'}
|
|
207
|
+
data-side={side}
|
|
208
|
+
data-align={align}
|
|
209
|
+
data-slot="tooltip-arrow"
|
|
210
|
+
style={{ rotate: deg }}
|
|
211
|
+
layoutId={withTransition ? `tooltip-arrow-${globalId}` : undefined}
|
|
212
|
+
transition={withTransition ? transition : undefined}
|
|
213
|
+
{...props}
|
|
214
|
+
/>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type TooltipPortalProps = React.ComponentProps<typeof FloatingPortal>
|
|
219
|
+
|
|
220
|
+
function TooltipPortal(props: TooltipPortalProps) {
|
|
221
|
+
return <FloatingPortal {...props} />
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function TooltipOverlay() {
|
|
225
|
+
const { currentTooltip, transition, globalId, referenceElRef } = useGlobalTooltip()
|
|
226
|
+
|
|
227
|
+
const [rendered, setRendered] = React.useState<{
|
|
228
|
+
data: TooltipData | null
|
|
229
|
+
open: boolean
|
|
230
|
+
}>({ data: null, open: false })
|
|
231
|
+
|
|
232
|
+
const arrowRef = React.useRef<SVGSVGElement | null>(null)
|
|
233
|
+
|
|
234
|
+
const side = rendered.data?.side ?? 'top'
|
|
235
|
+
const align = rendered.data?.align ?? 'center'
|
|
236
|
+
|
|
237
|
+
const { refs, x, y, strategy, context, update } = useFloating({
|
|
238
|
+
placement: align === 'center' ? side : `${side}-${align}`,
|
|
239
|
+
whileElementsMounted: autoUpdate,
|
|
240
|
+
middleware: [
|
|
241
|
+
floatingOffset({
|
|
242
|
+
mainAxis: rendered.data?.sideOffset ?? 0,
|
|
243
|
+
crossAxis: rendered.data?.alignOffset ?? 0
|
|
244
|
+
}),
|
|
245
|
+
flip(),
|
|
246
|
+
shift({ padding: 8 }),
|
|
247
|
+
floatingArrow({ element: arrowRef })
|
|
248
|
+
]
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
React.useEffect(() => {
|
|
252
|
+
if (currentTooltip) {
|
|
253
|
+
setRendered({ data: currentTooltip, open: true })
|
|
254
|
+
} else {
|
|
255
|
+
setRendered((p) => (p.data ? { ...p, open: false } : p))
|
|
256
|
+
}
|
|
257
|
+
}, [currentTooltip])
|
|
258
|
+
|
|
259
|
+
React.useLayoutEffect(() => {
|
|
260
|
+
if (referenceElRef.current) {
|
|
261
|
+
refs.setReference(referenceElRef.current)
|
|
262
|
+
update()
|
|
263
|
+
}
|
|
264
|
+
}, [referenceElRef, refs, update, rendered.data])
|
|
265
|
+
|
|
266
|
+
const ready = x != null && y != null
|
|
267
|
+
const Component = rendered.data?.contentAsChild ? Slot : motion.div
|
|
268
|
+
const resolvedSide = getResolvedSide(context.placement)
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<AnimatePresence mode="wait">
|
|
272
|
+
{rendered.data && ready && (
|
|
273
|
+
<TooltipPortal>
|
|
274
|
+
<div
|
|
275
|
+
ref={refs.setFloating}
|
|
276
|
+
data-slot="tooltip-overlay"
|
|
277
|
+
data-side={resolvedSide}
|
|
278
|
+
data-align={rendered.data.align}
|
|
279
|
+
data-state={rendered.open ? 'open' : 'closed'}
|
|
280
|
+
style={{
|
|
281
|
+
position: strategy,
|
|
282
|
+
top: 0,
|
|
283
|
+
left: 0,
|
|
284
|
+
zIndex: 50,
|
|
285
|
+
transform: `translate3d(${x!}px, ${y!}px, 0)`
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
<FloatingProvider value={{ context, arrowRef }}>
|
|
289
|
+
<RenderedTooltipProvider
|
|
290
|
+
value={{
|
|
291
|
+
side: resolvedSide,
|
|
292
|
+
align: rendered.data.align,
|
|
293
|
+
open: rendered.open
|
|
294
|
+
}}
|
|
295
|
+
>
|
|
296
|
+
<Component
|
|
297
|
+
data-slot="tooltip-content"
|
|
298
|
+
data-side={resolvedSide}
|
|
299
|
+
data-align={rendered.data.align}
|
|
300
|
+
data-state={rendered.open ? 'open' : 'closed'}
|
|
301
|
+
layoutId={`tooltip-content-${globalId}`}
|
|
302
|
+
initial={{
|
|
303
|
+
opacity: 0,
|
|
304
|
+
scale: 0,
|
|
305
|
+
...initialFromSide(rendered.data.side)
|
|
306
|
+
}}
|
|
307
|
+
animate={
|
|
308
|
+
rendered.open
|
|
309
|
+
? { opacity: 1, scale: 1, x: 0, y: 0 }
|
|
310
|
+
: {
|
|
311
|
+
opacity: 0,
|
|
312
|
+
scale: 0,
|
|
313
|
+
...initialFromSide(rendered.data.side)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
exit={{
|
|
317
|
+
opacity: 0,
|
|
318
|
+
scale: 0,
|
|
319
|
+
...initialFromSide(rendered.data.side)
|
|
320
|
+
}}
|
|
321
|
+
onAnimationComplete={() => {
|
|
322
|
+
if (!rendered.open) setRendered({ data: null, open: false })
|
|
323
|
+
}}
|
|
324
|
+
transition={transition}
|
|
325
|
+
{...rendered.data.contentProps}
|
|
326
|
+
style={{
|
|
327
|
+
position: 'relative',
|
|
328
|
+
...(rendered.data.contentProps?.style || {})
|
|
329
|
+
}}
|
|
330
|
+
/>
|
|
331
|
+
</RenderedTooltipProvider>
|
|
332
|
+
</FloatingProvider>
|
|
333
|
+
</div>
|
|
334
|
+
</TooltipPortal>
|
|
335
|
+
)}
|
|
336
|
+
</AnimatePresence>
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
type TooltipProps = {
|
|
341
|
+
children: React.ReactNode
|
|
342
|
+
side?: Side
|
|
343
|
+
sideOffset?: number
|
|
344
|
+
align?: Align
|
|
345
|
+
alignOffset?: number
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function Tooltip({
|
|
349
|
+
children,
|
|
350
|
+
side = 'top',
|
|
351
|
+
sideOffset = 0,
|
|
352
|
+
align = 'center',
|
|
353
|
+
alignOffset = 0
|
|
354
|
+
}: TooltipProps) {
|
|
355
|
+
const id = React.useId()
|
|
356
|
+
const [props, setProps] = React.useState<HTMLMotionProps<'div'>>({})
|
|
357
|
+
const [asChild, setAsChild] = React.useState(false)
|
|
358
|
+
|
|
359
|
+
return (
|
|
360
|
+
<LocalTooltipProvider
|
|
361
|
+
value={{
|
|
362
|
+
props,
|
|
363
|
+
setProps,
|
|
364
|
+
asChild,
|
|
365
|
+
setAsChild,
|
|
366
|
+
side,
|
|
367
|
+
sideOffset,
|
|
368
|
+
align,
|
|
369
|
+
alignOffset,
|
|
370
|
+
id
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
{children}
|
|
374
|
+
</LocalTooltipProvider>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
type TooltipContentProps = WithAsChild<HTMLMotionProps<'div'>>
|
|
379
|
+
|
|
380
|
+
function shallowEqualWithoutChildren(a?: HTMLMotionProps<'div'>, b?: HTMLMotionProps<'div'>) {
|
|
381
|
+
if (a === b) return true
|
|
382
|
+
if (!a || !b) return false
|
|
383
|
+
const keysA = Object.keys(a).filter((k) => k !== 'children')
|
|
384
|
+
const keysB = Object.keys(b).filter((k) => k !== 'children')
|
|
385
|
+
if (keysA.length !== keysB.length) return false
|
|
386
|
+
for (const k of keysA) {
|
|
387
|
+
if ((a as Record<string, unknown>)[k] !== (b as Record<string, unknown>)[k]) return false
|
|
388
|
+
}
|
|
389
|
+
return true
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function TooltipContent({ asChild = false, ...props }: TooltipContentProps) {
|
|
393
|
+
const { setProps, setAsChild } = useTooltip()
|
|
394
|
+
const lastPropsRef = React.useRef<HTMLMotionProps<'div'> | undefined>(undefined)
|
|
395
|
+
|
|
396
|
+
React.useEffect(() => {
|
|
397
|
+
if (!shallowEqualWithoutChildren(lastPropsRef.current, props)) {
|
|
398
|
+
lastPropsRef.current = props
|
|
399
|
+
setProps(props)
|
|
400
|
+
}
|
|
401
|
+
}, [props, setProps])
|
|
402
|
+
|
|
403
|
+
React.useEffect(() => {
|
|
404
|
+
setAsChild(asChild)
|
|
405
|
+
}, [asChild, setAsChild])
|
|
406
|
+
|
|
407
|
+
return null
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
type TooltipTriggerProps = WithAsChild<HTMLMotionProps<'div'>>
|
|
411
|
+
|
|
412
|
+
function TooltipTrigger({
|
|
413
|
+
ref,
|
|
414
|
+
onMouseEnter,
|
|
415
|
+
onMouseLeave,
|
|
416
|
+
onFocus,
|
|
417
|
+
onBlur,
|
|
418
|
+
onPointerDown,
|
|
419
|
+
asChild = false,
|
|
420
|
+
...props
|
|
421
|
+
}: TooltipTriggerProps) {
|
|
422
|
+
const {
|
|
423
|
+
props: contentProps,
|
|
424
|
+
asChild: contentAsChild,
|
|
425
|
+
side,
|
|
426
|
+
sideOffset,
|
|
427
|
+
align,
|
|
428
|
+
alignOffset,
|
|
429
|
+
id
|
|
430
|
+
} = useTooltip()
|
|
431
|
+
const { showTooltip, hideTooltip, hideImmediate, currentTooltip, setReferenceEl } =
|
|
432
|
+
useGlobalTooltip()
|
|
433
|
+
|
|
434
|
+
const triggerRef = React.useRef<HTMLDivElement>(null)
|
|
435
|
+
React.useImperativeHandle(ref, () => triggerRef.current as HTMLDivElement)
|
|
436
|
+
|
|
437
|
+
const suppressNextFocusRef = React.useRef(false)
|
|
438
|
+
|
|
439
|
+
const handleOpen = React.useCallback(() => {
|
|
440
|
+
if (!triggerRef.current) return
|
|
441
|
+
setReferenceEl(triggerRef.current)
|
|
442
|
+
const rect = triggerRef.current.getBoundingClientRect()
|
|
443
|
+
showTooltip({
|
|
444
|
+
contentProps,
|
|
445
|
+
contentAsChild,
|
|
446
|
+
rect,
|
|
447
|
+
side,
|
|
448
|
+
sideOffset,
|
|
449
|
+
align,
|
|
450
|
+
alignOffset,
|
|
451
|
+
id
|
|
452
|
+
})
|
|
453
|
+
}, [
|
|
454
|
+
showTooltip,
|
|
455
|
+
setReferenceEl,
|
|
456
|
+
contentProps,
|
|
457
|
+
contentAsChild,
|
|
458
|
+
side,
|
|
459
|
+
sideOffset,
|
|
460
|
+
align,
|
|
461
|
+
alignOffset,
|
|
462
|
+
id
|
|
463
|
+
])
|
|
464
|
+
|
|
465
|
+
const handlePointerDown = React.useCallback(
|
|
466
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
467
|
+
onPointerDown?.(e)
|
|
468
|
+
if (currentTooltip?.id === id) {
|
|
469
|
+
suppressNextFocusRef.current = true
|
|
470
|
+
hideImmediate()
|
|
471
|
+
Promise.resolve().then(() => {
|
|
472
|
+
suppressNextFocusRef.current = false
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
[onPointerDown, currentTooltip?.id, id, hideImmediate]
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
const handleMouseEnter = React.useCallback(
|
|
480
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
481
|
+
onMouseEnter?.(e)
|
|
482
|
+
handleOpen()
|
|
483
|
+
},
|
|
484
|
+
[handleOpen, onMouseEnter]
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
const handleMouseLeave = React.useCallback(
|
|
488
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
489
|
+
onMouseLeave?.(e)
|
|
490
|
+
hideTooltip()
|
|
491
|
+
},
|
|
492
|
+
[hideTooltip, onMouseLeave]
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
const handleFocus = React.useCallback(
|
|
496
|
+
(e: React.FocusEvent<HTMLDivElement>) => {
|
|
497
|
+
onFocus?.(e)
|
|
498
|
+
if (suppressNextFocusRef.current) return
|
|
499
|
+
handleOpen()
|
|
500
|
+
},
|
|
501
|
+
[handleOpen, onFocus]
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
const handleBlur = React.useCallback(
|
|
505
|
+
(e: React.FocusEvent<HTMLDivElement>) => {
|
|
506
|
+
onBlur?.(e)
|
|
507
|
+
hideTooltip()
|
|
508
|
+
},
|
|
509
|
+
[hideTooltip, onBlur]
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
const Component = asChild ? Slot : motion.div
|
|
513
|
+
|
|
514
|
+
return (
|
|
515
|
+
<Component
|
|
516
|
+
ref={triggerRef}
|
|
517
|
+
onPointerDown={handlePointerDown}
|
|
518
|
+
onMouseEnter={handleMouseEnter}
|
|
519
|
+
onMouseLeave={handleMouseLeave}
|
|
520
|
+
onFocus={handleFocus}
|
|
521
|
+
onBlur={handleBlur}
|
|
522
|
+
data-slot="tooltip-trigger"
|
|
523
|
+
data-side={side}
|
|
524
|
+
data-align={align}
|
|
525
|
+
data-state={currentTooltip?.id === id ? 'open' : 'closed'}
|
|
526
|
+
{...props}
|
|
527
|
+
/>
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export {
|
|
532
|
+
TooltipProvider,
|
|
533
|
+
Tooltip,
|
|
534
|
+
TooltipContent,
|
|
535
|
+
TooltipTrigger,
|
|
536
|
+
TooltipArrow,
|
|
537
|
+
useGlobalTooltip,
|
|
538
|
+
useTooltip,
|
|
539
|
+
type TooltipProviderProps,
|
|
540
|
+
type TooltipProps,
|
|
541
|
+
type TooltipContentProps,
|
|
542
|
+
type TooltipTriggerProps,
|
|
543
|
+
type TooltipArrowProps,
|
|
544
|
+
type TooltipPosition,
|
|
545
|
+
type GlobalTooltipContextType,
|
|
546
|
+
type TooltipContextType
|
|
547
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Todo: Fix dropdown motion issue on first open (looks like a flash) - https://reui.io/docs/navigation-menu
|
|
2
|
+
'use client'
|
|
3
|
+
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu'
|
|
6
|
+
import { cn } from '@/utilities/ui'
|
|
7
|
+
import { cva } from 'class-variance-authority'
|
|
8
|
+
import { ChevronDownIcon } from 'lucide-react'
|
|
9
|
+
|
|
10
|
+
function NavigationMenu({
|
|
11
|
+
className,
|
|
12
|
+
children,
|
|
13
|
+
viewport = true,
|
|
14
|
+
...props
|
|
15
|
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
|
16
|
+
viewport?: boolean
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<NavigationMenuPrimitive.Root
|
|
20
|
+
data-slot="navigation-menu"
|
|
21
|
+
data-viewport={viewport}
|
|
22
|
+
className={cn(
|
|
23
|
+
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center z-10',
|
|
24
|
+
className
|
|
25
|
+
)}
|
|
26
|
+
{...props}
|
|
27
|
+
>
|
|
28
|
+
{children}
|
|
29
|
+
{viewport && <NavigationMenuViewport />}
|
|
30
|
+
</NavigationMenuPrimitive.Root>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function NavigationMenuList({
|
|
35
|
+
className,
|
|
36
|
+
...props
|
|
37
|
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
|
38
|
+
return (
|
|
39
|
+
<NavigationMenuPrimitive.List
|
|
40
|
+
data-slot="navigation-menu-list"
|
|
41
|
+
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function NavigationMenuItem({
|
|
48
|
+
className,
|
|
49
|
+
...props
|
|
50
|
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
|
51
|
+
return (
|
|
52
|
+
<NavigationMenuPrimitive.Item
|
|
53
|
+
data-slot="navigation-menu-item"
|
|
54
|
+
className={cn('relative', className)}
|
|
55
|
+
{...props}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const navigationMenuTriggerStyle = cva(
|
|
61
|
+
'cursor-pointer group inline-flex h-9 w-max items-center justify-center px-4 py-2 text-sm font-medium disabled:pointer-events-none disabled:opacity-50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 text-stone-200 dark:text-stone-200'
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
function NavigationMenuTrigger({
|
|
65
|
+
className,
|
|
66
|
+
children,
|
|
67
|
+
...props
|
|
68
|
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
|
69
|
+
return (
|
|
70
|
+
<NavigationMenuPrimitive.Trigger
|
|
71
|
+
data-slot="navigation-menu-trigger"
|
|
72
|
+
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
|
73
|
+
{...props}
|
|
74
|
+
>
|
|
75
|
+
{children}{' '}
|
|
76
|
+
<ChevronDownIcon
|
|
77
|
+
className="relative top-[1px] ms-1 size-3.5 opacity-60 transition duration-300 group-data-[state=open]:rotate-180"
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
/>
|
|
80
|
+
</NavigationMenuPrimitive.Trigger>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function NavigationMenuContent({
|
|
85
|
+
className,
|
|
86
|
+
...props
|
|
87
|
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
|
88
|
+
return (
|
|
89
|
+
<NavigationMenuPrimitive.Content
|
|
90
|
+
data-slot="navigation-menu-content"
|
|
91
|
+
className={cn(
|
|
92
|
+
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto',
|
|
93
|
+
'!bg-[linear-gradient(90deg,_rgba(26,_26,_30,_.8),_rgba(25,_22,_38,_.85))] backdrop-blur-md',
|
|
94
|
+
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:left-0 group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu: group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200',
|
|
95
|
+
className
|
|
96
|
+
)}
|
|
97
|
+
{...props}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function NavigationMenuViewport({
|
|
103
|
+
className,
|
|
104
|
+
...props
|
|
105
|
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
|
106
|
+
return (
|
|
107
|
+
<div className={cn('absolute top-full left-0 isolate z-50 flex justify-center')}>
|
|
108
|
+
<NavigationMenuPrimitive.Viewport
|
|
109
|
+
data-slot="navigation-menu-viewport"
|
|
110
|
+
className={cn(
|
|
111
|
+
'shadow-md shadow-black/5 rounded-md border border-border bg-popover text-popover-foreground p-1.5 origin-top-center data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden md:w-[var(--radix-navigation-menu-viewport-width)]',
|
|
112
|
+
className
|
|
113
|
+
)}
|
|
114
|
+
{...props}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function NavigationMenuLink({
|
|
121
|
+
className,
|
|
122
|
+
...props
|
|
123
|
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
|
124
|
+
return (
|
|
125
|
+
<NavigationMenuPrimitive.Link
|
|
126
|
+
data-slot="navigation-menu-link"
|
|
127
|
+
className={cn(
|
|
128
|
+
'NavigationMenuPrimitive flex flex-col gap-1 text-sm transition-colors focus:outline-none',
|
|
129
|
+
className
|
|
130
|
+
)}
|
|
131
|
+
{...props}
|
|
132
|
+
/>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function NavigationMenuIndicator({
|
|
137
|
+
className,
|
|
138
|
+
...props
|
|
139
|
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
|
140
|
+
return (
|
|
141
|
+
<NavigationMenuPrimitive.Indicator
|
|
142
|
+
data-slot="navigation-menu-indicator"
|
|
143
|
+
className={cn('z-[1] flex h-1.5 items-end justify-center overflow-hidden', className)}
|
|
144
|
+
{...props}
|
|
145
|
+
>
|
|
146
|
+
<div className="relative top-[60%] h-2 w-2 rotate-45 shadow-md" />
|
|
147
|
+
</NavigationMenuPrimitive.Indicator>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export {
|
|
152
|
+
NavigationMenu,
|
|
153
|
+
NavigationMenuList,
|
|
154
|
+
NavigationMenuItem,
|
|
155
|
+
NavigationMenuContent,
|
|
156
|
+
NavigationMenuTrigger,
|
|
157
|
+
NavigationMenuLink,
|
|
158
|
+
NavigationMenuIndicator,
|
|
159
|
+
NavigationMenuViewport,
|
|
160
|
+
navigationMenuTriggerStyle
|
|
161
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
function getStrictContext<T>(
|
|
4
|
+
name?: string,
|
|
5
|
+
): readonly [
|
|
6
|
+
({
|
|
7
|
+
value,
|
|
8
|
+
children,
|
|
9
|
+
}: {
|
|
10
|
+
value: T;
|
|
11
|
+
children?: React.ReactNode;
|
|
12
|
+
}) => React.JSX.Element,
|
|
13
|
+
() => T,
|
|
14
|
+
] {
|
|
15
|
+
const Context = React.createContext<T | undefined>(undefined);
|
|
16
|
+
|
|
17
|
+
const Provider = ({
|
|
18
|
+
value,
|
|
19
|
+
children,
|
|
20
|
+
}: {
|
|
21
|
+
value: T;
|
|
22
|
+
children?: React.ReactNode;
|
|
23
|
+
}) => <Context.Provider value={value}>{children}</Context.Provider>;
|
|
24
|
+
|
|
25
|
+
const useSafeContext = () => {
|
|
26
|
+
const ctx = React.useContext(Context);
|
|
27
|
+
if (ctx === undefined) {
|
|
28
|
+
throw new Error(`useContext must be used within ${name ?? 'a Provider'}`);
|
|
29
|
+
}
|
|
30
|
+
return ctx;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return [Provider, useSafeContext] as const;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { getStrictContext };
|