diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx index 647cd63eb78..9dbe56ebaac 100644 --- a/packages/ui/src/Components.tsx +++ b/packages/ui/src/Components.tsx @@ -30,6 +30,7 @@ import { CreateOrganizationModal, EnableOrganizationsPrompt, ImpersonationFab, + Inspector, KeylessPrompt, OrganizationProfileModal, preloadComponent, @@ -714,6 +715,12 @@ const Components = (props: ComponentsProps) => { )} + {__DEV__ && ( + + + + )} + {state.organizationSwitcherPrefetch && } diff --git a/packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx b/packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx new file mode 100644 index 00000000000..49227a2bc0c --- /dev/null +++ b/packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx @@ -0,0 +1,365 @@ +// eslint-disable-next-line no-restricted-imports +import { css } from '@emotion/react'; +import { flip, offset, shift, useFloating } from '@floating-ui/react'; +import copy from 'copy-to-clipboard'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { InspectedData } from './parseClerkElement'; + +const CSS_RESET = css` + margin: 0; + padding: 0; + box-sizing: border-box; + background: none; + border: none; + font-family: + -apple-system, + BlinkMacSystemFont, + avenir next, + avenir, + segoe ui, + helvetica neue, + helvetica, + Cantarell, + Ubuntu, + roboto, + noto, + arial, + sans-serif; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + text-decoration: none; + color: inherit; + appearance: none; +`; + +interface InspectorOverlayProps { + inspectedData: InspectedData; + isFrozen: boolean; + copiedValue: string | null; + onCopy: (value: string) => void; + onClose: () => void; + tooltipRef: (el: HTMLElement | null) => void; +} + +function CopyIcon() { + return ( + + + + ); +} + +function CheckIcon() { + return ( + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} + +function CopyRow({ value, copiedValue, onCopy }: { value: string; copiedValue: string | null; onCopy: () => void }) { + const isCopied = copiedValue === value; + return ( + + ); +} + +export function InspectorOverlay({ + inspectedData, + isFrozen, + copiedValue, + onCopy, + onClose, + tooltipRef, +}: InspectorOverlayProps) { + const [rect, setRect] = useState(null); + + useEffect(() => { + const el = inspectedData.element; + setRect(el.getBoundingClientRect()); + + if (!isFrozen) { + let raf: number; + const update = () => { + setRect(el.getBoundingClientRect()); + raf = requestAnimationFrame(update); + }; + raf = requestAnimationFrame(update); + return () => cancelAnimationFrame(raf); + } + }, [inspectedData.element, isFrozen]); + + // Virtual reference element for floating-ui + const virtualRef = useMemo(() => { + if (!rect) { + return null; + } + return { + getBoundingClientRect: () => rect, + }; + }, [rect]); + + const { floatingStyles, refs } = useFloating({ + placement: 'bottom-start', + middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })], + elements: { + reference: virtualRef, + }, + }); + + const mergedTooltipRef = useCallback( + (node: HTMLDivElement | null) => { + refs.setFloating(node); + tooltipRef(node); + }, + [refs.setFloating, tooltipRef], + ); + + if (!rect) { + return null; + } + + return ( + <> + {/* Highlight box */} +
+ + {/* Tooltip */} +
+ {/* Close button — only interactive when frozen */} + {isFrozen && ( + + )} + + {/* Classes section */} +
+
+ Classes +
+ {inspectedData.publicClasses.map(cls => ( + onCopy(cls)} + /> + ))} + {inspectedData.stateClasses.map(cls => ( + onCopy(cls)} + /> + ))} +
+ + {/* Localization key section */} + {inspectedData.localizationKey && ( +
+
+ Localization Key +
+ onCopy(inspectedData.localizationKey!)} + /> +
+ )} + + {/* Hint when not frozen */} + {!isFrozen && ( +
+ Click to pin +
+ )} +
+ + ); +} diff --git a/packages/ui/src/components/devPrompts/Inspector/index.tsx b/packages/ui/src/components/devPrompts/Inspector/index.tsx new file mode 100644 index 00000000000..be468180b09 --- /dev/null +++ b/packages/ui/src/components/devPrompts/Inspector/index.tsx @@ -0,0 +1,141 @@ +// eslint-disable-next-line no-restricted-imports +import { css } from '@emotion/react'; +import { createPortal } from 'react-dom'; + +import { InternalThemeProvider } from '../../../styledSystem'; +import { InspectorOverlay } from './InspectorOverlay'; +import { useInspectorState } from './useInspectorState'; + +const CSS_RESET = css` + margin: 0; + padding: 0; + box-sizing: border-box; + background: none; + border: none; + font-family: + -apple-system, + BlinkMacSystemFont, + avenir next, + avenir, + segoe ui, + helvetica neue, + helvetica, + Cantarell, + Ubuntu, + roboto, + noto, + arial, + sans-serif; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + text-decoration: none; + color: inherit; + appearance: none; +`; + +function InspectorIcon() { + return ( + + + + + ); +} + +function InspectorInternal() { + const { isActive, inspectedData, isFrozen, copiedValue, toggle, unfreeze, setCopiedValue, setTooltipRef } = + useInspectorState(); + + return ( + <> + {/* Toggle button */} + + + {/* Overlay portal */} + {isActive && + inspectedData && + createPortal( + , + document.body, + )} + + ); +} + +export function Inspector() { + return ( + + + + ); +} diff --git a/packages/ui/src/components/devPrompts/Inspector/parseClerkElement.ts b/packages/ui/src/components/devPrompts/Inspector/parseClerkElement.ts new file mode 100644 index 00000000000..394d9090505 --- /dev/null +++ b/packages/ui/src/components/devPrompts/Inspector/parseClerkElement.ts @@ -0,0 +1,88 @@ +const CLASS_PREFIX = 'cl-'; +const EMOJI_SEPARATOR = '\u{1F512}'; +const INTERNAL_CLASS_PREFIX = 'cl-internal-'; + +export interface InspectedData { + publicClasses: string[]; + stateClasses: string[]; + localizationKey: string | null; + element: HTMLElement; +} + +/** + * Parses a DOM element to extract Clerk public classnames and localization keys. + * Returns null if the element is not a Clerk-customizable element. + */ +export function parseClerkElement(target: HTMLElement): InspectedData | null { + const element = findClerkElement(target); + if (!element) { + return null; + } + + const className = element.className; + // Split on the emoji separator — everything before is public + const publicPart = className.includes(EMOJI_SEPARATOR) ? className.split(EMOJI_SEPARATOR)[0] : className; + + const tokens = publicPart.split(/\s+/).filter(Boolean); + const publicClasses: string[] = []; + const stateClasses: string[] = []; + + const stateNames = new Set(['cl-loading', 'cl-error', 'cl-open', 'cl-active', 'cl-required']); + + for (const token of tokens) { + if (!token.startsWith(CLASS_PREFIX) || token.startsWith(INTERNAL_CLASS_PREFIX)) { + continue; + } + if (stateNames.has(token)) { + stateClasses.push(token); + } else { + publicClasses.push(token); + } + } + + if (publicClasses.length === 0) { + return null; + } + + const localizationKey = findLocalizationKey(element); + + return { + publicClasses, + stateClasses, + localizationKey, + element, + }; +} + +/** + * Walks up from the target to find the nearest element whose className contains `cl-`. + */ +function findClerkElement(target: HTMLElement): HTMLElement | null { + let el: HTMLElement | null = target; + while (el) { + if (typeof el.className === 'string' && el.className.includes(CLASS_PREFIX)) { + return el; + } + el = el.parentElement; + } + return null; +} + +/** + * Checks the element and its children for a `data-localization-key` attribute. + */ +function findLocalizationKey(element: HTMLElement): string | null { + // Check the element itself first + const key = element.getAttribute('data-localization-key'); + if (key) { + return key; + } + + // Check direct children + const child = element.querySelector('[data-localization-key]'); + if (child) { + return child.getAttribute('data-localization-key'); + } + + return null; +} diff --git a/packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts b/packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts new file mode 100644 index 00000000000..12c023fe179 --- /dev/null +++ b/packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { InspectedData } from './parseClerkElement'; +import { parseClerkElement } from './parseClerkElement'; + +interface InspectorState { + isActive: boolean; + inspectedData: InspectedData | null; + isFrozen: boolean; + copiedValue: string | null; +} + +export function useInspectorState() { + const [state, setState] = useState({ + isActive: false, + inspectedData: null, + isFrozen: false, + copiedValue: null, + }); + + const copiedTimerRef = useRef(null); + const tooltipRef = useRef(null); + + const toggle = useCallback(() => { + setState(prev => ({ + ...prev, + isActive: !prev.isActive, + inspectedData: null, + isFrozen: false, + copiedValue: null, + })); + }, []); + + const deactivate = useCallback(() => { + setState(prev => ({ + ...prev, + isActive: false, + inspectedData: null, + isFrozen: false, + copiedValue: null, + })); + }, []); + + const unfreeze = useCallback(() => { + setState(prev => ({ ...prev, isFrozen: false, copiedValue: null })); + }, []); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (state.isFrozen) { + return; + } + const target = e.target as HTMLElement; + if (!target) { + return; + } + const data = parseClerkElement(target); + setState(prev => ({ ...prev, inspectedData: data })); + }, + [state.isFrozen], + ); + + const handleClick = useCallback( + (e: MouseEvent) => { + if (state.isFrozen) { + // If clicking inside the tooltip, let it through (for copy buttons) + if (tooltipRef.current?.contains(e.target as Node)) { + return; + } + // Clicking outside the tooltip unfreezes + unfreeze(); + return; + } + + if (!state.inspectedData) { + return; + } + + // Freeze the tooltip so user can interact with copy buttons + e.preventDefault(); + e.stopPropagation(); + setState(prev => ({ ...prev, isFrozen: true })); + }, + [state.inspectedData, state.isFrozen, unfreeze], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (state.isFrozen) { + unfreeze(); + } else { + deactivate(); + } + } + }, + [state.isFrozen, deactivate, unfreeze], + ); + + const setCopiedValue = useCallback((value: string) => { + if (copiedTimerRef.current) { + window.clearTimeout(copiedTimerRef.current); + } + setState(prev => ({ ...prev, copiedValue: value })); + copiedTimerRef.current = window.setTimeout(() => { + setState(prev => ({ ...prev, copiedValue: null })); + }, 1500); + }, []); + + useEffect(() => { + if (!state.isActive) { + return; + } + + document.addEventListener('mousemove', handleMouseMove, true); + document.addEventListener('click', handleClick, true); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousemove', handleMouseMove, true); + document.removeEventListener('click', handleClick, true); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [state.isActive, handleMouseMove, handleClick, handleKeyDown]); + + useEffect(() => { + return () => { + if (copiedTimerRef.current) { + window.clearTimeout(copiedTimerRef.current); + } + }; + }, []); + + const setTooltipRef = useCallback((el: HTMLElement | null) => { + tooltipRef.current = el; + }, []); + + return { + isActive: state.isActive, + inspectedData: state.inspectedData, + isFrozen: state.isFrozen, + copiedValue: state.copiedValue, + toggle, + deactivate, + unfreeze, + setCopiedValue, + setTooltipRef, + }; +} diff --git a/packages/ui/src/lazyModules/components.ts b/packages/ui/src/lazyModules/components.ts index 0cc57f424ca..7d6be4d6102 100644 --- a/packages/ui/src/lazyModules/components.ts +++ b/packages/ui/src/lazyModules/components.ts @@ -32,6 +32,7 @@ const componentImportPaths = { OAuthConsent: () => import(/* webpackChunkName: "oauthConsent" */ '../components/OAuthConsent/OAuthConsent'), EnableOrganizationsPrompt: () => import(/* webpackChunkName: "enableOrganizationsPrompt" */ '../components/devPrompts/EnableOrganizationsPrompt'), + Inspector: () => import(/* webpackChunkName: "inspector" */ '../components/devPrompts/Inspector'), } as const; export const SignIn = lazy(() => componentImportPaths.SignIn().then(module => ({ default: module.SignIn }))); @@ -150,6 +151,8 @@ export const SessionTasks = lazy(() => componentImportPaths.SessionTasks().then(module => ({ default: module.SessionTasks })), ); +export const Inspector = lazy(() => componentImportPaths.Inspector().then(module => ({ default: module.Inspector }))); + export const preloadComponent = async (component: unknown) => { return componentImportPaths[component as keyof typeof componentImportPaths]?.(); };