From 84a667d7df072682afc27053ebb2abb3388ee9ba Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 5 Jun 2025 15:17:23 -0400 Subject: [PATCH] fix: fonts + classname ui --- README.md | 15 +++--- __tests__/page.test.tsx | 2 +- .../breakpoint-classname-control.tsx | 7 ++- .../ui/ui-builder/internal/iframe-wrapper.tsx | 46 ++++++++++++++++--- registry/block-registry.json | 4 +- 5 files changed, 56 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6f2a07e..2e9a7f8 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,11 @@ See the [docs site](https://uibuilder.app/) for more information. --- -## Tailwind 4, React 19 Support and latest Shadcn/ui +## Compatibility Notes -Migration will be coming soon. Some 3rd party shadcn component dependencies are not yet compatible with the latest versions of Tailwind, so we are waiting for the latest versions of these components to be stable. -If you are having issues with latest shadcn/ui cli you can try using older version in the command like `npx shadcn@2.1.8 add ...` +**Tailwind 4 + React 19**: Migration coming soon. Currently blocked by 3rd party component compatibility. If using latest shadcn/ui CLI fails, try: `npx shadcn@2.1.8 add ...` + +**Server Components**: Not supported. RSC can't be re-rendered client-side for live preview. A separate RSC renderer for final page rendering is possible — open an issue if you have a use case. # Quick Start @@ -496,7 +497,7 @@ Separate content from structure, allowing non-technical users to update dynamic --- -## Changelog +## Breaking Changes ### v1.0.0 - Removed _page_ layer type in favor of using any component type (like `div`, `main`, or custom containers) as the root page layer. This enhances flexibility, enabling use cases like building react-email templates directly. You should migrate any layers stored in the database to use a standard component type as the root. The [migrateV2ToV3](lib/ui-builder/store/layer-utils.ts) function in `layer-utils.ts` can assist with this migration. @@ -537,6 +538,9 @@ npm run test ## Roadmap +- [ ] Config options to make pages and variables immutable +- [ ] Add variable binding to layer children and not just props +- [ ] Improve DX. End to end type safety. - [ ] Documentation site for UI Builder with more hands-on examples - [ ] Configurable Tailwind Class subset for things like React-Email components - [ ] Drag and drop component in the editor panel and not just in the layers panel @@ -546,8 +550,7 @@ npm run test - [ ] Add Blocks. Reusable component blocks that can be used in multiple pages - [ ] Move component schemas to separate shadcn registry to keep main registry light - [ ] Move prop form field components (overrides) to separate shadcn registry to keep main registry light -- [ ] Add data sources (functions) to component layers (ex, getUser() binds prop user.name) - Connect variables to live data sources -- [ ] Add visual data model editor + code gen for backend code for CRUD operations +- [ ] Add visual data model editor + code gen for backend code for CRUD operations. (ex Zenstack schema editor) - [ ] Add event handlers to component layers (onClick, onSubmit, etc) - [ ] Update to new AutoForm when stable - [ ] Update to Zod v4 (when stable) for native json schema conversion to enforce safety in layer props diff --git a/__tests__/page.test.tsx b/__tests__/page.test.tsx index a6b8f2e..d88dac6 100644 --- a/__tests__/page.test.tsx +++ b/__tests__/page.test.tsx @@ -11,7 +11,7 @@ jest.mock("../components/ui/ui-builder/codeblock", () => { }; }); -it("App Router: Works with Server Components", async () => { +it("Main Component", async () => { render(); const mainPage = await screen.findByTestId("main-page"); expect(mainPage).toBeInTheDocument(); diff --git a/components/ui/ui-builder/internal/classname-control/breakpoint-classname-control.tsx b/components/ui/ui-builder/internal/classname-control/breakpoint-classname-control.tsx index ed99499..11cd5df 100644 --- a/components/ui/ui-builder/internal/classname-control/breakpoint-classname-control.tsx +++ b/components/ui/ui-builder/internal/classname-control/breakpoint-classname-control.tsx @@ -183,7 +183,7 @@ export const BreakpointClassNameControl = ({ > Edit Classes - + = React.memo( ); useLayoutEffect(() => { - if (resizable && iframeRef.current) { - setIframeSize({ - width: iframeRef.current.parentElement?.offsetWidth || 600, // Fallback to 600px or any default width - }); - } - }, [resizable]); + if (resizable && iframeRef.current) { + setIframeSize({ + width: iframeRef.current.parentElement?.offsetWidth || 600, // Fallback to 600px or any default width + }); + } + }, [resizable]); useLayoutEffect(() => { const iframe = iframeRef.current; @@ -86,6 +92,32 @@ export const IframeWrapper: React.FC = React.memo( iframeDoc.body.style.backgroundColor = "transparent"; + // Copy font variables and classes from parent to iframe + const parentBody = document.body; + const parentHtml = document.documentElement; + const parentComputedStyle = window.getComputedStyle(parentHtml); + + // Copy all CSS custom properties (variables) from parent html element + for (let i = 0; i < parentComputedStyle.length; i++) { + const property = parentComputedStyle[i]; + if (property.startsWith("--")) { + const value = parentComputedStyle.getPropertyValue(property); + iframeDoc.documentElement.style.setProperty(property, value); + iframeDoc.body.style.setProperty(property, value); + } + } + + // Copy font-related classes from parent body + parentBody.classList.forEach((className) => { + if ( + className.includes("font-") || + className === "antialiased" || + className.includes("__variable") + ) { + iframeDoc.body.classList.add(className); + } + }); + // Function to inject stylesheets into the iframe const injectStylesheets = () => { // Select all linked stylesheets in the parent document diff --git a/registry/block-registry.json b/registry/block-registry.json index f513839..94f5733 100644 --- a/registry/block-registry.json +++ b/registry/block-registry.json @@ -202,7 +202,7 @@ }, { "path": "components/ui/ui-builder/internal/iframe-wrapper.tsx", - "content": "import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { GripVertical } from \"lucide-react\";\nimport { DragConfig, useDrag } from \"@use-gesture/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst initialSize = { width: 0 };\n\ninterface IframeWrapperProps extends React.HTMLAttributes {\n children: React.ReactNode;\n frameId?: string;\n resizable: boolean;\n}\n\nexport const IframeWrapper: React.FC = React.memo(\n ({ children, frameId, resizable, style, ...props }) => {\n const iframeRef = useRef(null);\n const mountNodeRef = useRef(null); // Ref to store the mount node\n const [iframeSize, setIframeSize] = useState<{ width: number } | null>(\n null\n );\n const [isMounted, setIsMounted] = useState(false); // State to track mount node readiness\n\n\n\n // Ref to store the initial size at the start of dragging\n const initialSizeRef = useRef<{ width: number }>(initialSize);\n\n const updateIframeHeight = () => {\n if (!iframeRef.current || !mountNodeRef.current) return;\n const newHeight = mountNodeRef.current.scrollHeight;\n iframeRef.current.style.height = `${newHeight}px`;\n };\n\n const dragConfig = useMemo(() => {\n return {\n axis: \"x\",\n from: () => [0, 0],\n filterTaps: true,\n } as DragConfig\n }, []);\n\n // Handle resizing using useDrag\n const bindResizer = useDrag(\n ({ down, movement: [mx], first }) => {\n if (first) {\n // Capture the initial size when drag starts\n initialSizeRef.current = {\n width: iframeRef.current?.offsetWidth || 0,\n };\n }\n\n if (down) {\n // Calculate new size based on initial size and movement\n setIframeSize({\n width: initialSizeRef.current.width + mx,\n });\n }\n },\n dragConfig as any\n );\n\n useLayoutEffect(() => {\n if (resizable && iframeRef.current) {\n setIframeSize({\n width: iframeRef.current.parentElement?.offsetWidth || 600, // Fallback to 600px or any default width\n });\n }\n }, [resizable]);\n\n useLayoutEffect(() => {\n const iframe = iframeRef.current;\n if (!iframe) return;\n\n const handleLoad = () => {\n const iframeDoc =\n iframe.contentDocument || iframe.contentWindow?.document;\n if (!iframeDoc) return;\n\n if (!mountNodeRef.current) {\n // Create a div inside the iframe for mounting React components only once\n const mountNode = iframeDoc.createElement(\"div\");\n mountNode.id = \"iframe-mount-node\";\n iframeDoc.body.appendChild(mountNode);\n mountNodeRef.current = mountNode;\n\n iframeDoc.body.style.backgroundColor = \"transparent\";\n\n // Function to inject stylesheets into the iframe\n const injectStylesheets = () => {\n // Select all linked stylesheets in the parent document\n const links = Array.from(\n document.querySelectorAll('link[rel=\"stylesheet\"]')\n );\n // Select all style tags in the parent document\n const styles = Array.from(document.querySelectorAll(\"style\"));\n\n // Inject linked stylesheets\n links.forEach((link) => {\n const clonedLink = iframeDoc.createElement(\"link\");\n clonedLink.rel = \"stylesheet\";\n clonedLink.href = (link as HTMLLinkElement).href;\n iframeDoc.head.appendChild(clonedLink);\n });\n\n // Inject inline styles\n styles.forEach((style) => {\n const clonedStyle = iframeDoc.createElement(\"style\");\n clonedStyle.textContent = style.textContent || \"\";\n iframeDoc.head.appendChild(clonedStyle);\n });\n };\n\n // Inject styles on initial load\n injectStylesheets();\n\n const resizeObserver = new ResizeObserver(() => {\n updateIframeHeight();\n });\n\n // Observe the mount node for size changes\n resizeObserver.observe(mountNodeRef.current);\n\n const mutationObserver = new MutationObserver(() => {\n updateIframeHeight();\n });\n\n // Observe the mount node for changes\n mutationObserver.observe(mountNodeRef.current, {\n childList: true,\n subtree: true,\n });\n\n // Initial height update\n setTimeout(updateIframeHeight, 0);\n\n // Set the mount node as ready\n setIsMounted(true);\n\n // Cleanup observer on unmount\n return () => {\n resizeObserver.disconnect();\n mutationObserver.disconnect();\n };\n }\n };\n\n // Attach the load event listener\n iframe.addEventListener(\"load\", handleLoad);\n\n // If the iframe is already loaded, trigger the handler\n if (iframe.contentDocument?.readyState === \"complete\") {\n handleLoad();\n }\n\n // Cleanup event listener on unmount\n return () => {\n iframe.removeEventListener(\"load\", handleLoad);\n };\n }, [children]);\n\n\n const resizerStyle = useMemo(() => {\n return {\n left: iframeSize?.width ? `${iframeSize.width}px` : undefined\n }\n }, [iframeSize]);\n\n const iframeStyle = useMemo(() => {\n return {\n width: resizable\n ? iframeSize\n ? `${iframeSize.width}px`\n : \"100%\"\n : undefined,\n opacity: isMounted ? 1 : 0,\n transition: \"opacity 0.5s ease-in\",\n ...style,\n }\n }, [iframeSize, resizable, isMounted, style]);\n\n const bindResizerValues = useMemo(() => {\n return typeof bindResizer === 'function' ? bindResizer() : {};\n }, [bindResizer]);\n\n return (\n
\n {resizable && (\n \n \n \n )}\n \n {isMounted &&\n mountNodeRef.current &&\n ReactDOM.createPortal(children, mountNodeRef.current)}\n {resizable && (\n \n \n \n )}\n
\n );\n }\n);\n\nIframeWrapper.displayName = \"IframeWrapper\";\n\nconst Resizer = ({\n className,\n children,\n ...props\n}: React.HTMLAttributes) => {\n const handleMouseDown = useCallback((e: React.MouseEvent) => {\n e.stopPropagation();\n e.preventDefault();\n }, []);\n\n const handleTouchStart = useCallback((e: React.TouchEvent) => {\n e.stopPropagation();\n }, []);\n\n return (\n \n {children}\n \n );\n};\n", + "content": "import React, {\n useCallback,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { GripVertical } from \"lucide-react\";\nimport { DragConfig, useDrag } from \"@use-gesture/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst initialSize = { width: 0 };\n\ninterface IframeWrapperProps extends React.HTMLAttributes {\n children: React.ReactNode;\n frameId?: string;\n resizable: boolean;\n}\n\nexport const IframeWrapper: React.FC = React.memo(\n ({ children, frameId, resizable, style, ...props }) => {\n const iframeRef = useRef(null);\n const mountNodeRef = useRef(null); // Ref to store the mount node\n const [iframeSize, setIframeSize] = useState<{ width: number } | null>(\n null\n );\n const [isMounted, setIsMounted] = useState(false); // State to track mount node readiness\n\n\n\n // Ref to store the initial size at the start of dragging\n const initialSizeRef = useRef<{ width: number }>(initialSize);\n\n const updateIframeHeight = () => {\n if (!iframeRef.current || !mountNodeRef.current) return;\n const newHeight = mountNodeRef.current.scrollHeight;\n iframeRef.current.style.height = `${newHeight}px`;\n };\n\n const dragConfig = useMemo(() => {\n return {\n axis: \"x\",\n from: () => [0, 0],\n filterTaps: true,\n } as DragConfig\n }, []);\n\n // Handle resizing using useDrag\n const bindResizer = useDrag(\n ({ down, movement: [mx], first }) => {\n if (first) {\n // Capture the initial size when drag starts\n initialSizeRef.current = {\n width: iframeRef.current?.offsetWidth || 0,\n };\n }\n\n if (down) {\n // Calculate new size based on initial size and movement\n setIframeSize({\n width: initialSizeRef.current.width + mx,\n });\n }\n },\n dragConfig as any\n );\n\n useLayoutEffect(() => {\n if (resizable && iframeRef.current) {\n setIframeSize({\n width: iframeRef.current.parentElement?.offsetWidth || 600, // Fallback to 600px or any default width\n });\n }\n }, [resizable]);\n\n useLayoutEffect(() => {\n const iframe = iframeRef.current;\n if (!iframe) return;\n\n const handleLoad = () => {\n const iframeDoc =\n iframe.contentDocument || iframe.contentWindow?.document;\n if (!iframeDoc) return;\n\n if (!mountNodeRef.current) {\n // Create a div inside the iframe for mounting React components only once\n const mountNode = iframeDoc.createElement(\"div\");\n mountNode.id = \"iframe-mount-node\";\n iframeDoc.body.appendChild(mountNode);\n mountNodeRef.current = mountNode;\n\n iframeDoc.body.style.backgroundColor = \"transparent\";\n\n // Copy font variables and classes from parent to iframe\n const parentBody = document.body;\n const parentHtml = document.documentElement;\n const parentComputedStyle = window.getComputedStyle(parentHtml);\n\n // Copy all CSS custom properties (variables) from parent html element\n for (let i = 0; i < parentComputedStyle.length; i++) {\n const property = parentComputedStyle[i];\n if (property.startsWith(\"--\")) {\n const value = parentComputedStyle.getPropertyValue(property);\n iframeDoc.documentElement.style.setProperty(property, value);\n iframeDoc.body.style.setProperty(property, value);\n }\n }\n\n // Copy font-related classes from parent body\n parentBody.classList.forEach((className) => {\n if (\n className.includes(\"font-\") ||\n className === \"antialiased\" ||\n className.includes(\"__variable\")\n ) {\n iframeDoc.body.classList.add(className);\n }\n });\n\n // Function to inject stylesheets into the iframe\n const injectStylesheets = () => {\n // Select all linked stylesheets in the parent document\n const links = Array.from(\n document.querySelectorAll('link[rel=\"stylesheet\"]')\n );\n // Select all style tags in the parent document\n const styles = Array.from(document.querySelectorAll(\"style\"));\n\n // Inject linked stylesheets\n links.forEach((link) => {\n const clonedLink = iframeDoc.createElement(\"link\");\n clonedLink.rel = \"stylesheet\";\n clonedLink.href = (link as HTMLLinkElement).href;\n iframeDoc.head.appendChild(clonedLink);\n });\n\n // Inject inline styles\n styles.forEach((style) => {\n const clonedStyle = iframeDoc.createElement(\"style\");\n clonedStyle.textContent = style.textContent || \"\";\n iframeDoc.head.appendChild(clonedStyle);\n });\n };\n\n // Inject styles on initial load\n injectStylesheets();\n\n const resizeObserver = new ResizeObserver(() => {\n updateIframeHeight();\n });\n\n // Observe the mount node for size changes\n resizeObserver.observe(mountNodeRef.current);\n\n const mutationObserver = new MutationObserver(() => {\n updateIframeHeight();\n });\n\n // Observe the mount node for changes\n mutationObserver.observe(mountNodeRef.current, {\n childList: true,\n subtree: true,\n });\n\n // Initial height update\n setTimeout(updateIframeHeight, 0);\n\n // Set the mount node as ready\n setIsMounted(true);\n\n // Cleanup observer on unmount\n return () => {\n resizeObserver.disconnect();\n mutationObserver.disconnect();\n };\n }\n };\n\n // Attach the load event listener\n iframe.addEventListener(\"load\", handleLoad);\n\n // If the iframe is already loaded, trigger the handler\n if (iframe.contentDocument?.readyState === \"complete\") {\n handleLoad();\n }\n\n // Cleanup event listener on unmount\n return () => {\n iframe.removeEventListener(\"load\", handleLoad);\n };\n }, [children]);\n\n\n const resizerStyle = useMemo(() => {\n return {\n left: iframeSize?.width ? `${iframeSize.width}px` : undefined\n }\n }, [iframeSize]);\n\n const iframeStyle = useMemo(() => {\n return {\n width: resizable\n ? iframeSize\n ? `${iframeSize.width}px`\n : \"100%\"\n : undefined,\n opacity: isMounted ? 1 : 0,\n transition: \"opacity 0.5s ease-in\",\n ...style,\n }\n }, [iframeSize, resizable, isMounted, style]);\n\n const bindResizerValues = useMemo(() => {\n return typeof bindResizer === 'function' ? bindResizer() : {};\n }, [bindResizer]);\n\n return (\n
\n {resizable && (\n \n \n \n )}\n \n {isMounted &&\n mountNodeRef.current &&\n ReactDOM.createPortal(children, mountNodeRef.current)}\n {resizable && (\n \n \n \n )}\n
\n );\n }\n);\n\nIframeWrapper.displayName = \"IframeWrapper\";\n\nconst Resizer = ({\n className,\n children,\n ...props\n}: React.HTMLAttributes) => {\n const handleMouseDown = useCallback((e: React.MouseEvent) => {\n e.stopPropagation();\n e.preventDefault();\n }, []);\n\n const handleTouchStart = useCallback((e: React.TouchEvent) => {\n e.stopPropagation();\n }, []);\n\n return (\n \n {children}\n \n );\n};\n", "type": "registry:ui", "target": "components/ui/ui-builder/internal/iframe-wrapper.tsx" }, @@ -334,7 +334,7 @@ }, { "path": "components/ui/ui-builder/internal/classname-control/breakpoint-classname-control.tsx", - "content": "import { useState, useEffect, useCallback } from \"react\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport ClassNameMultiselect from \"@/components/ui/ui-builder/internal/classname-multiselect\";\nimport { ClassNameItemControl } from \"@/components/ui/ui-builder/internal/classname-control/classname-item-control\";\nimport {\n Accordion,\n AccordionItem,\n AccordionTrigger,\n AccordionContent,\n} from \"@/components/ui/accordion\";\nimport { Badge } from \"@/components/ui/badge\";\n\ninterface BreakpointClassNameControlProps {\n onChange?: (classes: string) => void;\n value?: string;\n}\nexport const BreakpointClassNameControl = ({\n onChange,\n value,\n}: BreakpointClassNameControlProps) => {\n // Helper to parse classString into base, md, rest\n const parseClassString = (str: string) => {\n const tokens = str.trim().split(/\\s+/);\n const base: string[] = [];\n const md: string[] = [];\n const rest: string[] = [];\n for (const token of tokens) {\n if (token.startsWith(\"md:\")) {\n md.push(token.slice(3));\n } else if (token.includes(\":\")) {\n rest.push(token);\n } else if (token) {\n base.push(token);\n }\n }\n return {\n base: base.join(\" \"),\n md: md.join(\" \"),\n rest: rest.join(\" \"),\n };\n };\n\n // State for the full class string\n const [classString, setClassString] = useState(value || \"\");\n // State for the tab\n const [tab, setTab] = useState<\"base\" | \"md\">(\"base\");\n\n // Sync classString with value prop (uncontrolled to controlled fix)\n useEffect(() => {\n if (typeof value === \"string\" && value !== classString) {\n setClassString(value);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value]);\n\n // Parse the class string for the tabs\n const { base, md, rest } = parseClassString(classString);\n\n // Handlers for each tab\n const handleBaseChange = useCallback(\n (newBase: string) => {\n const newClassString = [\n newBase,\n md &&\n md\n .split(\" \")\n .map((cls) => `md:${cls}`)\n .join(\" \"),\n rest,\n ]\n .filter(Boolean)\n .join(\" \")\n .replace(/\\s+/g, \" \")\n .trim();\n setClassString(newClassString);\n },\n [md, rest]\n );\n\n const handleMdChange = useCallback(\n (newMd: string) => {\n const mdClasses = newMd\n .split(\" \")\n .filter(Boolean)\n .map((cls) => `md:${cls}`)\n .join(\" \");\n const newClassString = [base, mdClasses, rest]\n .filter(Boolean)\n .join(\" \")\n .replace(/\\s+/g, \" \")\n .trim();\n setClassString(newClassString);\n },\n [base, rest]\n );\n\n // When classString changes, call parent onChange\n useEffect(() => {\n if (onChange) onChange(classString);\n }, [classString, onChange]);\n\n // When multiselect changes, update classString (and tabs will re-parse)\n const handleMultiselectChange = useCallback((newClassString: string) => {\n setClassString(newClassString);\n }, []);\n\n const handleTabChange = useCallback(\n (val: string) => setTab(val as \"base\" | \"md\"),\n []\n );\n\n return (\n \n \n \n \n \n \n
\n Base\n {base && (\n \n {base.split(\" \").filter(Boolean).length}\n \n )}\n
\n
\n Base styles for all screen sizes\n
\n
\n \n \n \n
\n \n Tablet & Desktop\n \n {md && (\n \n {md.split(\" \").filter(Boolean).length}\n \n )}\n
\n
\n \n Overrides for screens larger than 768px (md:*)\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n Edit Classes\n \n \n \n \n
\n \n \n );\n};\n", + "content": "import { useState, useEffect, useCallback } from \"react\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport ClassNameMultiselect from \"@/components/ui/ui-builder/internal/classname-multiselect\";\nimport { ClassNameItemControl } from \"@/components/ui/ui-builder/internal/classname-control/classname-item-control\";\nimport {\n Accordion,\n AccordionItem,\n AccordionTrigger,\n AccordionContent,\n} from \"@/components/ui/accordion\";\nimport { Badge } from \"@/components/ui/badge\";\n\ninterface BreakpointClassNameControlProps {\n onChange?: (classes: string) => void;\n value?: string;\n}\nexport const BreakpointClassNameControl = ({\n onChange,\n value,\n}: BreakpointClassNameControlProps) => {\n // Helper to parse classString into base, md, rest\n const parseClassString = (str: string) => {\n const tokens = str.trim().split(/\\s+/);\n const base: string[] = [];\n const md: string[] = [];\n const rest: string[] = [];\n for (const token of tokens) {\n if (token.startsWith(\"md:\")) {\n md.push(token.slice(3));\n } else if (token.includes(\":\")) {\n rest.push(token);\n } else if (token) {\n base.push(token);\n }\n }\n return {\n base: base.join(\" \"),\n md: md.join(\" \"),\n rest: rest.join(\" \"),\n };\n };\n\n // State for the full class string\n const [classString, setClassString] = useState(value || \"\");\n // State for the tab\n const [tab, setTab] = useState<\"base\" | \"md\">(\"base\");\n\n // Sync classString with value prop (uncontrolled to controlled fix)\n useEffect(() => {\n if (typeof value === \"string\" && value !== classString) {\n setClassString(value);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value]);\n\n // Parse the class string for the tabs\n const { base, md, rest } = parseClassString(classString);\n\n // Handlers for each tab\n const handleBaseChange = useCallback(\n (newBase: string) => {\n const newClassString = [\n newBase,\n md &&\n md\n .split(\" \")\n .map((cls) => `md:${cls}`)\n .join(\" \"),\n rest,\n ]\n .filter(Boolean)\n .join(\" \")\n .replace(/\\s+/g, \" \")\n .trim();\n setClassString(newClassString);\n },\n [md, rest]\n );\n\n const handleMdChange = useCallback(\n (newMd: string) => {\n const mdClasses = newMd\n .split(\" \")\n .filter(Boolean)\n .map((cls) => `md:${cls}`)\n .join(\" \");\n const newClassString = [base, mdClasses, rest]\n .filter(Boolean)\n .join(\" \")\n .replace(/\\s+/g, \" \")\n .trim();\n setClassString(newClassString);\n },\n [base, rest]\n );\n\n // When classString changes, call parent onChange\n useEffect(() => {\n if (onChange) onChange(classString);\n }, [classString, onChange]);\n\n // When multiselect changes, update classString (and tabs will re-parse)\n const handleMultiselectChange = useCallback((newClassString: string) => {\n setClassString(newClassString);\n }, []);\n\n const handleTabChange = useCallback(\n (val: string) => setTab(val as \"base\" | \"md\"),\n []\n );\n\n return (\n \n \n \n \n \n \n
\n Base\n {base && (\n \n {base.split(\" \").filter(Boolean).length}\n \n )}\n
\n
\n Base styles for all screen sizes\n
\n
\n \n \n \n
\n \n Tablet & Desktop\n \n {md && (\n \n {md.split(\" \").filter(Boolean).length}\n \n )}\n
\n
\n \n Overrides for screens larger than 768px (md:*)\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n Edit Classes\n \n \n \n \n \n \n \n );\n};\n", "type": "registry:ui", "target": "components/ui/ui-builder/internal/classname-control/breakpoint-classname-control.tsx" },