blocks-dusted 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blocks-dusted",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Manifest-driven CLI for installing reusable Payload CMS blocks.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,4 +37,4 @@
37
37
  },
38
38
  "homepage": "https://github.com/Hadizainal/blocks-dusted#readme",
39
39
  "license": "MIT"
40
- }
40
+ }
@@ -44,7 +44,7 @@ export async function addTemplate({
44
44
  const missingRequired = checks.missingRequirements ?? []
45
45
  if (missingRequired.length > 0) {
46
46
  throw new Error(
47
- `Required project files are missing:\n${missingRequired.map((check) => `- ${check.path}: ${check.reason}`).join('\n')}`,
47
+ `Required project files are missing:\n${missingRequired.map(formatMissingRequirement).join('\n')}`,
48
48
  )
49
49
  }
50
50
  }
@@ -353,6 +353,11 @@ export async function addTemplate({
353
353
  )
354
354
  }
355
355
 
356
+ function formatMissingRequirement(check) {
357
+ const resolution = check.resolution ? `\n Resolve: ${check.resolution}` : ''
358
+ return `- ${check.path}: ${check.reason}${resolution}`
359
+ }
360
+
356
361
  function collectionRegistrationResults({ isCollectionTemplate, manifest, action }) {
357
362
  if (!isCollectionTemplate) {
358
363
  return [{ status: 'skipped', reason: `${manifest.type} templates do not use Payload config collection registration` }]
@@ -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`.
@@ -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,10 +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
- },
78
86
  {
79
87
  "path": "src/components/RichText/index.tsx",
80
88
  "reason": "HeaderDD02 renders richTextContent and floatingContent through the target project's RichText renderer."
@@ -82,10 +90,6 @@
82
90
  {
83
91
  "path": "src/utilities/ui.ts",
84
92
  "reason": "HeaderDD02 uses cn from @/utilities/ui."
85
- },
86
- {
87
- "path": "src/components/animate-ui/components/animate/tooltip.tsx",
88
- "reason": "HeaderDD02 MobileBottomNav uses the target tooltip primitives."
89
93
  }
90
94
  ],
91
95
  "collectionRegistration": {
@@ -101,7 +105,8 @@
101
105
  "This component template does not yet patch an existing active Header global, replace Header/Component.tsx, or inspect database-stored Header content automatically. Review the existing Header global before activation and back up active files before wiring the variant into the target project.",
102
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.",
103
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.",
104
- "Wire `HeaderDD02` into the target Header global or render it directly after confirming the target project will not render two headers."
108
+ "Wire `HeaderDD02` into the target Header global or render it directly after confirming the target project will not render two headers.",
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."
105
110
  ],
106
111
  "manualSteps": [
107
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.",
@@ -110,14 +115,48 @@
110
115
  "This component template does not yet patch an existing active Header global, replace Header/Component.tsx, or inspect database-stored Header content automatically. Review the existing Header global before activation and back up active files before wiring the variant into the target project.",
111
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.",
112
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.",
113
- "Wire `HeaderDD02` into the target Header global or render it directly after confirming the target project will not render two headers."
118
+ "Wire `HeaderDD02` into the target Header global or render it directly after confirming the target project will not render two headers.",
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."
114
120
  ],
115
121
  "notes": [
116
122
  "Converted from DesignsDustedv3 Header02 after tracing Header/variants/Header02/index.tsx and its direct imports.",
117
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.",
118
- "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."
119
126
  ],
120
127
  "backupExistingDestination": true,
121
128
  "exportName": "HeaderDD02",
122
- "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
+ ]
123
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 };