From 1bfe71ce4f20b226d0ad7c4e98ea5564bacec819 Mon Sep 17 00:00:00 2001 From: leahwang Date: Fri, 10 Apr 2026 17:58:13 -0400 Subject: [PATCH 1/2] feat(blocks): add CreateResource wizard block Add a full-page, multi-step wizard block with animated card stack transitions for creating resources. Features: - Animated card stack with peek-back navigation - Sidebar step navigation with clickable completed steps - Focus management and focus trap for accessibility - Respects prefers-reduced-motion preference - KumoPortalProvider for nested overlays (Select, Dropdown, etc.) - Configurable size variants (base, lg) - Optional lockNavigation and hideStepNavigation modes Includes: - CreateResource and CreateResourceStep components - Three demo examples (Hero, Basic, Nested Creation) - Full documentation with API reference --- .../src/components/SidebarNav.tsx | 1 + .../components/demos/CreateResourceDemo.tsx | 820 ++++++++++++++++++ .../src/pages/blocks/create-resource.mdx | 411 +++++++++ .../create-resource/create-resource.tsx | 651 ++++++++++++++ .../kumo/src/blocks/create-resource/index.ts | 11 + packages/kumo/src/index.ts | 9 + 6 files changed, 1903 insertions(+) create mode 100644 packages/kumo-docs-astro/src/components/demos/CreateResourceDemo.tsx create mode 100644 packages/kumo-docs-astro/src/pages/blocks/create-resource.mdx create mode 100644 packages/kumo/src/blocks/create-resource/create-resource.tsx create mode 100644 packages/kumo/src/blocks/create-resource/index.ts diff --git a/packages/kumo-docs-astro/src/components/SidebarNav.tsx b/packages/kumo-docs-astro/src/components/SidebarNav.tsx index a16bcf6a9f..b791211fd3 100644 --- a/packages/kumo-docs-astro/src/components/SidebarNav.tsx +++ b/packages/kumo-docs-astro/src/components/SidebarNav.tsx @@ -91,6 +91,7 @@ const chartItems: NavItem[] = [ const blockItems: NavItem[] = [ { label: "Page Header", href: "/blocks/page-header" }, { label: "Resource List", href: "/blocks/resource-list" }, + { label: "Create Resource", href: "/blocks/create-resource" }, { label: "Delete Resource", href: "/blocks/delete-resource" }, ]; diff --git a/packages/kumo-docs-astro/src/components/demos/CreateResourceDemo.tsx b/packages/kumo-docs-astro/src/components/demos/CreateResourceDemo.tsx new file mode 100644 index 0000000000..f950b6cdeb --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/CreateResourceDemo.tsx @@ -0,0 +1,820 @@ +import { useState, useEffect } from "react"; +import { + CreateResource, + CreateResourceStep, + Breadcrumbs, + Button, + Input, + Select, + Combobox, + ClipboardText, + Text, + Label, + Grid, + GridItem, + Tooltip, + Table, + LayerCard, + Radio, +} from "@cloudflare/kumo"; +import { + SubwayIcon, + TrashIcon, + MagnifyingGlassIcon, + PlusIcon, +} from "@phosphor-icons/react"; +import { createPortal } from "react-dom"; + +// ============================================================================= +// Hero Demo - Full tunnel creation wizard example +// ============================================================================= + +export function CreateResourceHeroDemo() { + const [mounted, setMounted] = useState(false); + const [open, setOpen] = useState(false); + const [step, setStep] = useState(0); + const [tunnelName, setTunnelName] = useState(""); + const [selectedOS, setSelectedOS] = useState("macOS"); + const [selectedArch, setSelectedArch] = useState("arm64"); + + useEffect(() => { + setMounted(true); + }, []); + + const handleClose = () => { + setOpen(false); + // Reset state when closing + setTimeout(() => { + setStep(0); + setTunnelName(""); + setSelectedOS("macOS"); + setSelectedArch("arm64"); + }, 300); + }; + + const steps = [ + { + key: "name", + label: "Name your tunnel", + content: ( + +
+ + + } + > + setTunnelName(e.target.value)} + /> + + ), + }, + { + key: "environment", + label: "Configure", + content: ( + + + + + } + > + + + { + if (value) { + setSelectedArch(value); + } + }} + items={{ + arm64: "arm64", + amd64: "amd64", + }} + className="w-full" + /> + + + +
+
+ + + Run this command to install the connector. + +
+ +
+
+ ), + }, + { + key: "review", + label: "Review & create", + content: ( + + + + + } + > + + +
+ + Tunnel name + + + {tunnelName || "—"} + +
+
+ + Operating System + + + {selectedOS} + +
+
+ + Architecture + + + {selectedArch} + +
+
+
+
+ ), + }, + ]; + + return ( + <> + + {mounted && + open && + createPortal( + + + + + Tunnels + + + + Create + + } + onClose={handleClose} + onAskAI={() => alert("Wire this up to your AI sidebar context!")} + step={step} + onStepChange={setStep} + steps={steps} + />, + document.body, + )} + + ); +} + +// ============================================================================= +// Basic Demo - Minimal single-step wizard +// ============================================================================= + +export function CreateResourceBasicDemo() { + const [mounted, setMounted] = useState(false); + const [open, setOpen] = useState(false); + const [step, setStep] = useState(0); + const profileOptions = ["Profile A", "Profile B", "Profile C"]; + const [applianceType, setApplianceType] = useState("virtual"); + const [name, setName] = useState(""); + const [profile, setProfile] = useState(""); + const [physicalAppliances, setPhysicalAppliances] = useState([ + { id: 1, name: "", serialNumber: "", profile: "" }, + ]); + + useEffect(() => { + setMounted(true); + }, []); + + const handleClose = () => { + setOpen(false); + setTimeout(() => { + setStep(0); + setApplianceType("virtual"); + setName(""); + setProfile(""); + setPhysicalAppliances([ + { id: 1, name: "", serialNumber: "", profile: "" }, + ]); + }, 300); + }; + + const addPhysicalAppliance = () => { + const newId = Math.max(...physicalAppliances.map((a) => a.id)) + 1; + setPhysicalAppliances([ + ...physicalAppliances, + { id: newId, name: "", serialNumber: "", profile: "" }, + ]); + }; + + const removePhysicalAppliance = (id: number) => { + if (physicalAppliances.length > 1) { + setPhysicalAppliances( + physicalAppliances.filter((appliance) => appliance.id !== id), + ); + } + }; + + const updatePhysicalAppliance = ( + id: number, + field: string, + value: string, + ) => { + setPhysicalAppliances( + physicalAppliances.map((appliance) => + appliance.id === id ? { ...appliance, [field]: value } : appliance, + ), + ); + }; + + const steps = [ + { + key: "configure", + label: "Configure", + content: ( + +
+ + + } + > + + + + + + {applianceType === "virtual" ? ( +
+
+
+
+ +
+
+ + + setName(e.target.value)} + className="w-full" + /> + + + + setProfile((value as string) ?? "") + } + items={profileOptions} + > + + + + {(item) => ( + + {item as string} + + )} + + No profiles found + + + + +
+ ) : ( +
+
+
+
+ +
+
+
+ {physicalAppliances.map((appliance) => ( +
+ + + + updatePhysicalAppliance( + appliance.id, + "name", + e.target.value, + ) + } + className="w-full" + /> + + + + updatePhysicalAppliance( + appliance.id, + "serialNumber", + e.target.value, + ) + } + className="w-full" + /> + + + + updatePhysicalAppliance( + appliance.id, + "profile", + (value as string) ?? "", + ) + } + items={profileOptions} + > + + + + {(item) => ( + + {item as string} + + )} + + No profiles found + + + + +
+ + You have to register at least one appliance + + } + disabled={physicalAppliances.length > 1} + > + + +
+
+ ))} + +
+
+ )} + + ), + }, + ]; + + return ( + <> + + {mounted && + open && + createPortal( + + Home + + Create + + } + onClose={handleClose} + onAskAI={() => alert("Wire this up to your AI sidebar context!")} + step={step} + onStepChange={setStep} + steps={steps} + hideStepNavigation + size="lg" + />, + document.body, + )} + + ); +} + +// ============================================================================= +// Nested Creation Demo - Creating a sub-resource within a creation flow +// ============================================================================= + +export function CreateResourceNestedDemo() { + const [mounted, setMounted] = useState(false); + const [open, setOpen] = useState(false); + const [step, setStep] = useState(0); + const [selectedApps, setSelectedApps] = useState([]); + const [breakoutPort, setBreakoutPort] = useState("port-1"); + const [searchQuery, setSearchQuery] = useState(""); + const [showCustomForm, setShowCustomForm] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const handleClose = () => { + setOpen(false); + setTimeout(() => { + setStep(0); + setSelectedApps([]); + setBreakoutPort("port-1"); + setShowCustomForm(false); + }, 300); + }; + + const applications = [ + { id: "app-1", name: "Salesforce", category: "CRM", type: "Custom" }, + { + id: "app-2", + name: "Microsoft 365", + category: "Productivity", + type: "Managed", + }, + { id: "app-3", name: "Zoom", category: "Communication", type: "Managed" }, + { id: "app-4", name: "Slack", category: "Communication", type: "Managed" }, + { id: "app-5", name: "GitHub", category: "Development", type: "Managed" }, + { id: "app-6", name: "Dropbox", category: "Storage", type: "Managed" }, + { + id: "app-7", + name: "Google Workspace", + category: "Productivity", + type: "Managed", + }, + { + id: "app-8", + name: "Jira", + category: "Project Management", + type: "Managed", + }, + { + id: "app-9", + name: "Confluence", + category: "Documentation", + type: "Managed", + }, + { + id: "app-10", + name: "Asana", + category: "Project Management", + type: "Managed", + }, + { id: "app-11", name: "Notion", category: "Productivity", type: "Managed" }, + { id: "app-12", name: "Figma", category: "Design", type: "Managed" }, + { + id: "app-13", + name: "Adobe Creative Cloud", + category: "Design", + type: "Managed", + }, + { id: "app-14", name: "Zendesk", category: "Support", type: "Managed" }, + { id: "app-15", name: "HubSpot", category: "CRM", type: "Managed" }, + ]; + + const filteredApplications = applications.filter((app) => + app.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + const toggleApp = (appId: string) => { + setSelectedApps((prev) => + prev.includes(appId) + ? prev.filter((id) => id !== appId) + : [...prev, appId], + ); + }; + + const toggleAll = () => { + const allFilteredSelected = + filteredApplications.length > 0 && + filteredApplications.every((app) => selectedApps.includes(app.id)); + if (allFilteredSelected) { + setSelectedApps((prev) => + prev.filter((id) => !filteredApplications.some((app) => app.id === id)), + ); + } else { + const filteredIds = filteredApplications.map((app) => app.id); + setSelectedApps((prev) => Array.from(new Set([...prev, ...filteredIds]))); + } + }; + + const steps = [ + { + key: "assign", + label: "Assign applications", + content: ( + +
+ + + } + > + {!showCustomForm ? ( +
+
+
+ setSearchQuery(e.target.value)} + className="w-full pl-9" + size="sm" + /> + +
+ +
+ +
+ + + + 0 && + filteredApplications.every((app) => + selectedApps.includes(app.id), + ) + } + indeterminate={ + filteredApplications.some((app) => + selectedApps.includes(app.id), + ) && + !filteredApplications.every((app) => + selectedApps.includes(app.id), + ) + } + onValueChange={toggleAll} + label="Select all applications" + /> + Name + Category + Type + + + + {filteredApplications.map((app) => ( + + toggleApp(app.id)} + label={`Select ${app.name}`} + /> + {app.name} + {app.category} + {app.type} + + ))} + +
+
+
+
+ ) : ( +
+
+
+
+ +
+
+
+ + +
+ + + +
+ +
+
+ )} + + ), + }, + { + key: "configure", + label: "Configure port", + content: ( + + + + + } + > + setName(e.target.value)} + /> + + ), + }, + { + key: "confirm", + label: "Confirm", + content: ( + + + + + } + > +

Create tunnel "{name}"?

+
+ ), + }, + ]; + + return ( + + Tunnels + + Create + + } + onClose={() => navigate("/tunnels")} + step={step} + onStepChange={setStep} + steps={steps} + /> + ); +} +``` + + + +{/* Architecture */} + + + Architecture +

+ The CreateResource block follows a data-driven, controlled component + pattern: +

+
    +
  • + Controlled state: You own the step index and manage + navigation through `step` and `onStepChange` props. +
  • +
  • + Step definitions: Pass an array of step objects with + `key`, `label`, and `content`. Each step's content is typically a + `CreateResourceStep`. +
  • +
  • + Consumer-owned navigation: The block handles layout and + animation. You handle validation, async operations, and button rendering + in each step's `footer`. +
  • +
  • + Exit handling: Use the `onClose` callback to handle + routing when the user clicks the X button. +
  • +
+
+ +{/* Examples */} + + + Examples + +Basic +

+ A simplified single-step wizard with appliance type selection and dynamic + fields. +

+ + + + +Nested Creation +

A creation flow that includes inline sub-resource creation, such as adding a custom item within the main wizard.

+ + + +
+ +{/* Accessibility */} + + + Accessibility +
    +
  • + Focus management: Focus automatically moves to each step + after animation completes. +
  • +
  • + Focus trap: Tab navigation is constrained to the current + step's content, the previous step card (for back navigation), and the + close button. +
  • +
  • + Keyboard navigation: Previous step cards can be activated + with Enter or Space. +
  • +
  • + Reduced motion: Respects `prefers-reduced-motion` - + animations are disabled when the user prefers reduced motion. +
  • +
+
+ +{/* API Reference */} + + + API Reference + + CreateResource +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropTypeDefaultDescription
breadcrumbsReactNodeContent for the header breadcrumb area
onClose() => voidCalled when the X button is clicked
stepnumberCurrent active step index (controlled)
onStepChange(step: number) => voidCalled when navigating to a different step
stepsCreateResourceStepItem[]Array of step definitions
headerActionsReactNodeOptional slot between breadcrumbs and close button
size"base" | "lg""base"Width of the card stack
lockNavigationbooleanfalsePrevents clicking sidebar/previous steps to navigate back
hideStepNavigationbooleanfalseHides sidebar and stacked card peek-back interaction
closeButtonRefRefObject<HTMLButtonElement>Ref to the close button for external focus management
classNamestringAdditional class for the outer container
+
+ +CreateResourceStepItem +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyTypeDescription
keystringUnique identifier for the step
labelstringLabel shown in the sidebar navigation
contentReactNode + The step content (typically a CreateResourceStep) +
showErrorboolean + Whether to show an error indicator on this step +
hideFromNavigationbooleanHide this step from the sidebar navigation
+
+ + CreateResourceStep +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropTypeDescription
titlestringStep heading
descriptionstringStep description text
footerReactNodeFooter content (typically Back/Next buttons)
childrenReactNodeStep body content
classNamestringAdditional class for the card
+
+
diff --git a/packages/kumo/src/blocks/create-resource/create-resource.tsx b/packages/kumo/src/blocks/create-resource/create-resource.tsx new file mode 100644 index 0000000000..3645da11fb --- /dev/null +++ b/packages/kumo/src/blocks/create-resource/create-resource.tsx @@ -0,0 +1,651 @@ +import { + type MutableRefObject, + type ReactNode, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +import { motion } from "motion/react"; +import { SparkleIcon, XIcon } from "@phosphor-icons/react"; +import { LayerCard } from "../../components/layer-card"; +import { Button } from "../../components/button"; +import { Text } from "../../components/text"; +import { cn } from "../../utils/cn"; +import { KumoPortalProvider } from "../../utils/portal-provider"; + +// SSR-safe useLayoutEffect - uses useEffect on server, useLayoutEffect on client +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + +// ============================================================================= +// Variants +// ============================================================================= + +export const KUMO_CREATE_RESOURCE_VARIANTS = { + size: { + base: { + classes: "max-w-[38rem]", + description: "Default wizard width for most creation flows", + }, + lg: { + classes: "max-w-[48rem]", + description: "Wide wizard for steps with complex content", + }, + }, +} as const; + +export const KUMO_CREATE_RESOURCE_DEFAULT_VARIANTS = { + size: "base", +} as const; + +export type KumoCreateResourceSize = + keyof typeof KUMO_CREATE_RESOURCE_VARIANTS.size; + +export interface KumoCreateResourceVariantsProps { + /** Width of the card stack. @default "base" */ + size?: KumoCreateResourceSize; +} + +// ============================================================================= +// Animation Variants (for motion.dev) +// ============================================================================= + +const stepVariants = { + current: { + y: 0, + scale: 1, + opacity: 1, + zIndex: 30, + }, + previous: { + y: "-110%", + scale: 0.85, + opacity: 0.5, + zIndex: 0, + }, + beforePrevious: { + y: "-210%", + scale: 1, + opacity: 0, + zIndex: 0, + }, + after: { + y: "210%", + scale: 1, + opacity: 0, + zIndex: 0, + }, + hidden: { + y: "300%", + scale: 1, + opacity: 0, + zIndex: -10, + pointerEvents: "none" as const, + }, +}; + +const getStepVariant = (index: number, step: number) => { + if (index === step) return "current"; + if (index === step - 1) return "previous"; + if (index < step - 1) return "beforePrevious"; + return "after"; +}; + +// ============================================================================= +// Types +// ============================================================================= + +export interface CreateResourceStepItem { + /** Unique identifier for the step */ + key: string; + /** Label shown in the sidebar navigation */ + label?: string; + /** The step content (typically a CreateResourceStep) */ + content: ReactNode; + /** Whether to show an error indicator on this step */ + showError?: boolean; + /** Hide this step from the sidebar navigation */ + hideFromNavigation?: boolean; +} + +export interface CreateResourceProps extends KumoCreateResourceVariantsProps { + /** Content rendered in the left side of the header bar (e.g., Breadcrumbs) */ + breadcrumbs: ReactNode; + /** Called when the X button is clicked */ + onClose: () => void; + /** + * Called when the "Ask AI" button is clicked. + * When provided, renders a default "Ask AI" button in the header. + * For custom header actions, use `headerActions` instead. + */ + onAskAI?: () => void; + /** Optional slot between breadcrumbs and close button for custom actions */ + headerActions?: ReactNode; + /** Current active step index (controlled) */ + step: number; + /** Called when user navigates to a different step */ + onStepChange: (step: number) => void; + /** Step definitions */ + steps: CreateResourceStepItem[]; + /** Prevents clicking sidebar/previous steps to navigate back */ + lockNavigation?: boolean; + /** Hides sidebar + stacked card peek-back interaction */ + hideStepNavigation?: boolean; + /** Ref to the close button (for external focus management) */ + closeButtonRef?: RefObject; + /** Additional class for the outer container */ + className?: string; +} + +// ============================================================================= +// CreateResource Component +// ============================================================================= + +/** + * Full-page creation wizard with header bar, sidebar navigation, and animated card stack. + * + * @example + * ```tsx + * ...} + * onClose={handleClose} + * step={step} + * onStepChange={setStep} + * steps={[ + * { key: 'name', label: 'Name', content: }, + * { key: 'config', label: 'Configure', content: }, + * ]} + * /> + * ``` + */ +export function CreateResource({ + breadcrumbs, + onClose, + onAskAI, + headerActions, + step, + onStepChange, + steps, + size = KUMO_CREATE_RESOURCE_DEFAULT_VARIANTS.size, + lockNavigation = false, + hideStepNavigation = false, + closeButtonRef: externalCloseButtonRef, + className, +}: CreateResourceProps) { + const [isAnimating, setIsAnimating] = useState(false); + const [activeStepFocusable, setActiveStepFocusable] = useState(true); + const currentStepRef = useRef(null); + const containerRef = useRef(null); + const portalContainerRef = useRef(null); + const sidebarRef = useRef(null); + const internalCloseButtonRef = useRef(null); + const isInitialMount = useRef(true); + + // Check for reduced motion preference (SSR-safe) + const [shouldReduceMotion, setShouldReduceMotion] = useState(false); + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + setShouldReduceMotion(mediaQuery.matches); + const handler = (e: MediaQueryListEvent) => + setShouldReduceMotion(e.matches); + mediaQuery.addEventListener("change", handler); + return () => mediaQuery.removeEventListener("change", handler); + }, []); + + const mergeCloseButtonRef = useCallback( + (el: HTMLButtonElement | null) => { + internalCloseButtonRef.current = el; + if (externalCloseButtonRef) { + // Use type assertion since RefObject.current is readonly in types + // but we need to set it for ref forwarding + ( + externalCloseButtonRef as MutableRefObject + ).current = el; + } + }, + [externalCloseButtonRef], + ); + + // Map to store refs for all step elements + const stepElementsRef = useRef>(new Map()); + + // Stable ref callbacks — same function instance returned for the same index across renders + const stepRefCallbacksRef = useRef< + Map void> + >(new Map()); + + // Cleanup refs when component unmounts + useEffect(() => { + return () => { + stepElementsRef.current.clear(); + stepRefCallbacksRef.current.clear(); + }; + }, []); + + // Create a stable ref callback for each step + const getStepRef = useCallback((index: number) => { + if (!stepRefCallbacksRef.current.has(index)) { + stepRefCallbacksRef.current.set( + index, + (element: HTMLDivElement | null) => { + if (element) { + stepElementsRef.current.set(index, element); + } else { + stepElementsRef.current.delete(index); + stepRefCallbacksRef.current.delete(index); + } + }, + ); + } + return stepRefCallbacksRef.current.get(index)!; + }, []); + + // Sync refs when step changes + useIsomorphicLayoutEffect(() => { + const currentStepElement = stepElementsRef.current.get(step) || null; + currentStepRef.current = currentStepElement; + }, [step]); + + const focusStepContainer = useCallback(() => { + if (currentStepRef.current) { + currentStepRef.current.focus(); + setActiveStepFocusable(true); + } + }, []); + + // Handle initial mount focus + useEffect(() => { + if (isInitialMount.current) { + requestAnimationFrame(() => { + focusStepContainer(); + }); + } + }, [focusStepContainer]); + + // Set animating state and reset container focusability when step changes + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + setIsAnimating(true); + setActiveStepFocusable(true); + + // For reduced motion, immediately complete since there's no animation + if (shouldReduceMotion) { + setIsAnimating(false); + focusStepContainer(); + } + }, [step, shouldReduceMotion, focusStepContainer]); + + // Handle animation complete - focuses step container after animation completes + const handleAnimationComplete = useCallback( + (index: number) => { + if (index === step) { + setIsAnimating(false); + focusStepContainer(); + } + }, + [step, focusStepContainer], + ); + + // Focus trap: only allow focus on current step children, previous step div, and close button + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + + // Rebuild focusable elements list on every Tab press to handle dynamic content + const currentStepFocusableElements = + currentStepRef.current?.querySelectorAll( + 'button:not([disabled]):not([tabindex="-1"]), [href]:not([tabindex="-1"]), input:not([disabled]):not([tabindex="-1"]), select:not([disabled]):not([tabindex="-1"]), textarea:not([disabled]):not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])', + ); + + const allowedElements: HTMLElement[] = []; + + // Add active step container if it's still focusable + if (activeStepFocusable && currentStepRef.current) { + allowedElements.push(currentStepRef.current); + } + + // Add current step's focusable children FIRST (so they're focused before close button) + if (currentStepFocusableElements) { + allowedElements.push(...Array.from(currentStepFocusableElements)); + } + + // Add previous step div (not its children) for back navigation + const previousStepElement = stepElementsRef.current.get(step - 1); + if (previousStepElement && step > 0 && !hideStepNavigation) { + allowedElements.push(previousStepElement); + } + + // Add sidebar navigation items (completed clickable steps) + if (sidebarRef.current && !hideStepNavigation && !lockNavigation) { + const sidebarItems = + sidebarRef.current.querySelectorAll('[tabindex="0"]'); + allowedElements.push(...Array.from(sidebarItems)); + } + + // Add close button last + if (internalCloseButtonRef.current) { + allowedElements.push(internalCloseButtonRef.current); + } + + // If we have no step content elements, don't trap focus + const hasStepContent = + currentStepFocusableElements && currentStepFocusableElements.length > 0; + const minExpectedElements = step > 0 && !hideStepNavigation ? 2 : 1; + + if (!hasStepContent && allowedElements.length <= minExpectedElements) { + return; + } + + if (allowedElements.length === 0) return; + + e.preventDefault(); + + const activeElement = document.activeElement as HTMLElement; + const currentIndex = allowedElements.indexOf(activeElement); + + // If user is tabbing from the step container, remove it from future tab order + if (activeElement === currentStepRef.current && activeStepFocusable) { + setActiveStepFocusable(false); + } + + if (e.shiftKey) { + // Shift+Tab: go backwards + if (currentIndex <= 0) { + allowedElements[allowedElements.length - 1].focus(); + } else { + allowedElements[currentIndex - 1].focus(); + } + } else { + // Tab: go forwards + if (currentIndex === -1 || currentIndex >= allowedElements.length - 1) { + allowedElements[0].focus(); + } else { + allowedElements[currentIndex + 1].focus(); + } + } + }; + + container.addEventListener("keydown", handleKeyDown); + return () => container.removeEventListener("keydown", handleKeyDown); + }, [step, activeStepFocusable, hideStepNavigation, lockNavigation]); + + return ( + +
+ {/* Portal container for nested overlays (Select, Dropdown, etc.) */} +
+ + {/* Header bar */} +
+
{breadcrumbs}
+
+ {onAskAI && ( + + )} + {headerActions} + {(onAskAI || headerActions) && ( + +
+ + {/* Wizard body */} +
+
+ {/* Sidebar navigation */} + {!hideStepNavigation && ( + + )} + + {/* Animated step cards */} + {steps.map((page, index) => { + const isCurrentStep = index === step; + const isPreviousStep = index === step - 1; + const canNavigateBack = + isPreviousStep && !hideStepNavigation && !lockNavigation; + + return ( + handleAnimationComplete(index)} + className={cn( + "absolute top-0 w-full px-6 pb-8 outline-none", + // Hover state for previous step (peek-back interaction) + isPreviousStep && + !hideStepNavigation && + !lockNavigation && + "cursor-pointer hover:opacity-100 focus:opacity-100 [&_button,[href]]:pointer-events-none after:pointer-events-none after:absolute after:inset-x-0 after:bottom-8 after:top-0 after:rounded-xl after:ring-1 after:ring-transparent after:transition-all focus-visible:after:ring-kumo-hairline", + isAnimating && "animating", + // Hide non-active steps when hideStepNavigation is true + hideStepNavigation && + !isCurrentStep && + "pointer-events-none invisible", + )} + tabIndex={ + isCurrentStep + ? activeStepFocusable + ? 0 + : -1 + : canNavigateBack + ? 0 + : -1 + } + onClick={() => { + if (canNavigateBack) { + onStepChange(index); + } + }} + onKeyDown={(e) => { + if ( + canNavigateBack && + (e.key === "Enter" || e.key === " ") + ) { + e.preventDefault(); + onStepChange(index); + } + }} + aria-hidden={ + hideStepNavigation + ? !isCurrentStep + : !isCurrentStep && !isPreviousStep + } + aria-label={ + canNavigateBack + ? `Go back to ${page.label || "previous step"}` + : undefined + } + role={canNavigateBack ? "button" : undefined} + > + {page.content} + + ); + })} +
+
+
+ + ); +} + +CreateResource.displayName = "CreateResource"; + +// ============================================================================= +// CreateResourceStep Component +// ============================================================================= + +export interface CreateResourceStepProps { + /** Step heading */ + title: string; + /** Step description text */ + description?: string; + /** Footer content (typically Back/Next buttons) */ + footer: ReactNode; + /** Step body content */ + children: ReactNode; + /** Additional class for the card */ + className?: string; +} + +/** + * Step content card using LayerCard for consistent layout. + * + * @example + * ```tsx + * Next} + * > + * + * + * ``` + */ +export function CreateResourceStep({ + title, + description, + footer, + children, + className, +}: CreateResourceStepProps) { + return ( + + +
+ {title} + {description && ( + + {description} + + )} +
+
{children}
+
+ + + {footer} + +
+ ); +} + +CreateResourceStep.displayName = "CreateResourceStep"; diff --git a/packages/kumo/src/blocks/create-resource/index.ts b/packages/kumo/src/blocks/create-resource/index.ts new file mode 100644 index 0000000000..e0597999fb --- /dev/null +++ b/packages/kumo/src/blocks/create-resource/index.ts @@ -0,0 +1,11 @@ +export { + CreateResource, + CreateResourceStep, + KUMO_CREATE_RESOURCE_VARIANTS, + KUMO_CREATE_RESOURCE_DEFAULT_VARIANTS, + type CreateResourceProps, + type CreateResourceStepItem, + type CreateResourceStepProps, + type KumoCreateResourceSize, + type KumoCreateResourceVariantsProps, +} from "./create-resource"; diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index 872fe1fb28..b89bda0055 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -95,6 +95,15 @@ export { KUMO_DELETE_RESOURCE_DEFAULT_VARIANTS, type DeleteResourceProps, } from "./blocks/delete-resource"; +export { + CreateResource, + CreateResourceStep, + KUMO_CREATE_RESOURCE_VARIANTS, + KUMO_CREATE_RESOURCE_DEFAULT_VARIANTS, + type CreateResourceProps, + type CreateResourceStepProps, + type CreateResourceStepItem, +} from "./blocks/create-resource"; export { Loader, SkeletonLine } from "./components/loader"; export { MenuBar, useMenuNavigation } from "./components/menubar"; export { Meter } from "./components/meter"; From df3dbefe20f25114a426cd0d3c09b22c615b5129 Mon Sep 17 00:00:00 2001 From: leahwang Date: Wed, 15 Apr 2026 18:15:58 -0400 Subject: [PATCH 2/2] feat(CreateResource): enhance demo with edit/delete functionality and AI sidebar - Add edit, view, and delete functionality to NestedDemo (Assign Breakout Traffic) - Add simulated AI sidebar to all demos showing sidebarWidth integration - Use Link and DropdownMenu for app row actions - Support custom app CRUD operations in wizard flow - Fix Text variant (heading3 instead of non-existent heading4) --- .../components/demos/CreateResourceDemo.tsx | 553 ++++++++++++++---- .../create-resource/create-resource.tsx | 50 +- 2 files changed, 486 insertions(+), 117 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/CreateResourceDemo.tsx b/packages/kumo-docs-astro/src/components/demos/CreateResourceDemo.tsx index f950b6cdeb..2f80b5d7cf 100644 --- a/packages/kumo-docs-astro/src/components/demos/CreateResourceDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/CreateResourceDemo.tsx @@ -16,12 +16,15 @@ import { Table, LayerCard, Radio, + Link, + DropdownMenu, } from "@cloudflare/kumo"; import { SubwayIcon, TrashIcon, MagnifyingGlassIcon, PlusIcon, + DotsThreeIcon, } from "@phosphor-icons/react"; import { createPortal } from "react-dom"; @@ -36,6 +39,8 @@ export function CreateResourceHeroDemo() { const [tunnelName, setTunnelName] = useState(""); const [selectedOS, setSelectedOS] = useState("macOS"); const [selectedArch, setSelectedArch] = useState("arm64"); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const sidebarWidth = 450; useEffect(() => { setMounted(true); @@ -43,6 +48,7 @@ export function CreateResourceHeroDemo() { const handleClose = () => { setOpen(false); + setIsSidebarOpen(false); // Reset state when closing setTimeout(() => { setStep(0); @@ -52,6 +58,10 @@ export function CreateResourceHeroDemo() { }, 300); }; + const handleAskAI = () => { + setIsSidebarOpen(true); + }; + const steps = [ { key: "name", @@ -212,25 +222,53 @@ export function CreateResourceHeroDemo() { {mounted && open && createPortal( - - - - - Tunnels - - - - Create - - } - onClose={handleClose} - onAskAI={() => alert("Wire this up to your AI sidebar context!")} - step={step} - onStepChange={setStep} - steps={steps} - />, + <> + + + + + Tunnels + + + + Create + + } + onClose={handleClose} + onAskAI={handleAskAI} + step={step} + onStepChange={setStep} + steps={steps} + sidebarWidth={isSidebarOpen ? sidebarWidth : 0} + /> + {/* Simulated AI Sidebar */} +
+
+ Ask AI + +
+
+ + This is a simulated AI sidebar. In your app, wire up the + onAskAI callback to open your actual AI assistant. + +
+
+ , document.body, )} @@ -252,6 +290,8 @@ export function CreateResourceBasicDemo() { const [physicalAppliances, setPhysicalAppliances] = useState([ { id: 1, name: "", serialNumber: "", profile: "" }, ]); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const sidebarWidth = 450; useEffect(() => { setMounted(true); @@ -259,6 +299,7 @@ export function CreateResourceBasicDemo() { const handleClose = () => { setOpen(false); + setIsSidebarOpen(false); setTimeout(() => { setStep(0); setApplianceType("virtual"); @@ -270,6 +311,10 @@ export function CreateResourceBasicDemo() { }, 300); }; + const handleAskAI = () => { + setIsSidebarOpen(true); + }; + const addPhysicalAppliance = () => { const newId = Math.max(...physicalAppliances.map((a) => a.id)) + 1; setPhysicalAppliances([ @@ -493,22 +538,50 @@ export function CreateResourceBasicDemo() { {mounted && open && createPortal( - - Home - - Create - - } - onClose={handleClose} - onAskAI={() => alert("Wire this up to your AI sidebar context!")} - step={step} - onStepChange={setStep} - steps={steps} - hideStepNavigation - size="lg" - />, + <> + + Home + + Create + + } + onClose={handleClose} + onAskAI={handleAskAI} + step={step} + onStepChange={setStep} + steps={steps} + hideStepNavigation + size="lg" + sidebarWidth={isSidebarOpen ? sidebarWidth : 0} + /> + {/* Simulated AI Sidebar */} +
+
+ Ask AI + +
+
+ + This is a simulated AI sidebar. In your app, wire up the + onAskAI callback to open your actual AI assistant. + +
+
+ , document.body, )} @@ -519,6 +592,21 @@ export function CreateResourceBasicDemo() { // Nested Creation Demo - Creating a sub-resource within a creation flow // ============================================================================= +interface Application { + id: string; + name: string; + category: string; + type: "Custom" | "Managed"; +} + +interface CustomAppFormData { + name: string; + type: string; + hostnames: string; + ipSubnets: string; + sourceSubnets: string; +} + export function CreateResourceNestedDemo() { const [mounted, setMounted] = useState(false); const [open, setOpen] = useState(false); @@ -527,22 +615,13 @@ export function CreateResourceNestedDemo() { const [breakoutPort, setBreakoutPort] = useState("port-1"); const [searchQuery, setSearchQuery] = useState(""); const [showCustomForm, setShowCustomForm] = useState(false); + const [editingAppId, setEditingAppId] = useState(null); + const [viewingManagedApp, setViewingManagedApp] = useState(null); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const sidebarWidth = 450; - useEffect(() => { - setMounted(true); - }, []); - - const handleClose = () => { - setOpen(false); - setTimeout(() => { - setStep(0); - setSelectedApps([]); - setBreakoutPort("port-1"); - setShowCustomForm(false); - }, 300); - }; - - const applications = [ + // Temporary apps list that includes any custom apps added during this session + const [applications, setApplications] = useState([ { id: "app-1", name: "Salesforce", category: "CRM", type: "Custom" }, { id: "app-2", @@ -588,7 +667,43 @@ export function CreateResourceNestedDemo() { }, { id: "app-14", name: "Zendesk", category: "Support", type: "Managed" }, { id: "app-15", name: "HubSpot", category: "CRM", type: "Managed" }, - ]; + ]); + + const [customApp, setCustomApp] = useState({ + name: "", + type: "", + hostnames: "", + ipSubnets: "", + sourceSubnets: "", + }); + + useEffect(() => { + setMounted(true); + }, []); + + const handleClose = () => { + setOpen(false); + setIsSidebarOpen(false); + setTimeout(() => { + setStep(0); + setSelectedApps([]); + setBreakoutPort("port-1"); + setShowCustomForm(false); + setEditingAppId(null); + setViewingManagedApp(null); + setCustomApp({ + name: "", + type: "", + hostnames: "", + ipSubnets: "", + sourceSubnets: "", + }); + }, 300); + }; + + const handleAskAI = () => { + setIsSidebarOpen(true); + }; const filteredApplications = applications.filter((app) => app.name.toLowerCase().includes(searchQuery.toLowerCase()), @@ -616,6 +731,78 @@ export function CreateResourceNestedDemo() { } }; + const handleEditApp = (app: Application) => { + // Only allow editing custom apps + if (app.type !== "Custom") return; + + setEditingAppId(app.id); + setCustomApp({ + name: app.name, + type: app.category, + hostnames: "", + ipSubnets: "", + sourceSubnets: "", + }); + setShowCustomForm(true); + }; + + const handleViewApp = (app: Application) => { + // For managed apps, show read-only view + setEditingAppId(null); + setCustomApp({ + name: app.name, + type: app.category, + hostnames: "", + ipSubnets: "", + sourceSubnets: "", + }); + setShowCustomForm(true); + setViewingManagedApp(app); + }; + + const handleDeleteApp = (appId: string) => { + setApplications((prev) => prev.filter((app) => app.id !== appId)); + setSelectedApps((prev) => prev.filter((id) => id !== appId)); + }; + + const handleAddCustomApp = () => { + if (!customApp.name.trim()) return; + + if (editingAppId) { + // Update existing custom app + setApplications((prev) => + prev.map((app) => + app.id === editingAppId + ? { ...app, name: customApp.name, category: customApp.type || "Custom" } + : app, + ), + ); + setEditingAppId(null); + } else { + // Create new custom app + const tempId = `temp-custom-${Date.now()}`; + const newApp: Application = { + id: tempId, + name: customApp.name, + category: customApp.type || "Custom", + type: "Custom", + }; + + setApplications((prev) => [newApp, ...prev]); + setSelectedApps((prev) => [...prev, tempId]); + } + + setCustomApp({ + name: "", + type: "", + hostnames: "", + ipSubnets: "", + sourceSubnets: "", + }); + setShowCustomForm(false); + setViewingManagedApp(null); + }; + const steps = [ { key: "assign", @@ -625,17 +812,56 @@ export function CreateResourceNestedDemo() { title="Assign application breakout traffic" description="Select applications and configure the preferred breakout port." footer={ - <> -
- - + showCustomForm ? ( + viewingManagedApp ? ( + <> +
+ + + ) : ( + <> + + + + ) + ) : ( + <> +
+ + + ) } > {!showCustomForm ? ( @@ -657,7 +883,18 @@ export function CreateResourceNestedDemo() { + + + {app.type === "Custom" ? ( + <> + handleEditApp(app)} + > + Edit + + handleDeleteApp(app.id)} + > + Delete + + + ) : ( + handleViewApp(app)} + > + View details + + )} + + + ))} @@ -714,45 +1000,77 @@ export function CreateResourceNestedDemo() {
- +
+ setCustomApp((prev) => ({ ...prev, name: e.target.value })) + } className="w-full" + disabled={!!viewingManagedApp} /> + setCustomApp((prev) => ({ ...prev, type: e.target.value })) + } className="w-full" + disabled={!!viewingManagedApp} />
- - - -
- -
+ {!viewingManagedApp && ( + <> + + setCustomApp((prev) => ({ + ...prev, + hostnames: e.target.value, + })) + } + className="w-full" + /> + + setCustomApp((prev) => ({ + ...prev, + ipSubnets: e.target.value, + })) + } + className="w-full" + /> + + setCustomApp((prev) => ({ + ...prev, + sourceSubnets: e.target.value, + })) + } + className="w-full" + /> + + )}
)} @@ -800,19 +1118,48 @@ export function CreateResourceNestedDemo() { {mounted && open && createPortal( - - Network - - Breakout Traffic - - } - onClose={handleClose} - step={step} - onStepChange={setStep} - steps={steps} - />, + <> + + Network + + Breakout Traffic + + } + onClose={handleClose} + onAskAI={handleAskAI} + step={step} + onStepChange={setStep} + steps={steps} + sidebarWidth={isSidebarOpen ? sidebarWidth : 0} + /> + {/* Simulated AI Sidebar */} +
+
+ Ask AI + +
+
+ + This is a simulated AI sidebar. In your app, wire up the + onAskAI callback to open your actual AI assistant. + +
+
+ , document.body, )} diff --git a/packages/kumo/src/blocks/create-resource/create-resource.tsx b/packages/kumo/src/blocks/create-resource/create-resource.tsx index 3645da11fb..403678c3b1 100644 --- a/packages/kumo/src/blocks/create-resource/create-resource.tsx +++ b/packages/kumo/src/blocks/create-resource/create-resource.tsx @@ -115,7 +115,7 @@ export interface CreateResourceStepItem { export interface CreateResourceProps extends KumoCreateResourceVariantsProps { /** Content rendered in the left side of the header bar (e.g., Breadcrumbs) */ breadcrumbs: ReactNode; - /** Called when the X button is clicked */ + /** Called when the X button is clicked or when navigating back via breadcrumb */ onClose: () => void; /** * Called when the "Ask AI" button is clicked. @@ -139,6 +139,8 @@ export interface CreateResourceProps extends KumoCreateResourceVariantsProps { closeButtonRef?: RefObject; /** Additional class for the outer container */ className?: string; + /** Width of any sidebar that should push the content (e.g., AI sidebar) */ + sidebarWidth?: number | string; } // ============================================================================= @@ -175,6 +177,7 @@ export function CreateResource({ hideStepNavigation = false, closeButtonRef: externalCloseButtonRef, className, + sidebarWidth = 0, }: CreateResourceProps) { const [isAnimating, setIsAnimating] = useState(false); const [activeStepFocusable, setActiveStepFocusable] = useState(true); @@ -182,7 +185,7 @@ export function CreateResource({ const containerRef = useRef(null); const portalContainerRef = useRef(null); const sidebarRef = useRef(null); - const internalCloseButtonRef = useRef(null); + const internalCloseButtonRef = useRef(null); const isInitialMount = useRef(true); // Check for reduced motion preference (SSR-safe) @@ -379,38 +382,57 @@ export function CreateResource({ return () => container.removeEventListener("keydown", handleKeyDown); }, [step, activeStepFocusable, hideStepNavigation, lockNavigation]); + // Calculate the right offset based on sidebar width + const sidebarWidthValue = + typeof sidebarWidth === "number" ? `${sidebarWidth}px` : sidebarWidth; + return (
{/* Portal container for nested overlays (Select, Dropdown, etc.) */}
{/* Header bar */} -
-
{breadcrumbs}
+
+
{ + // Intercept clicks on breadcrumb links and close the wizard + const target = e.target as HTMLElement; + if (target.closest("a")) { + e.preventDefault(); + onClose(); + } + }} + > + {breadcrumbs} +
{onAskAI && ( )} {headerActions} @@ -421,7 +443,7 @@ export function CreateResource({ ref={mergeCloseButtonRef} variant="ghost" shape="square" - size="sm" + className="h-8" aria-label="Close" onClick={onClose} > @@ -443,7 +465,7 @@ export function CreateResource({