diff --git a/PRPs/ai_docs/UI_STANDARDS.md b/PRPs/ai_docs/UI_STANDARDS.md index 8baca1be..a8f67077 100644 --- a/PRPs/ai_docs/UI_STANDARDS.md +++ b/PRPs/ai_docs/UI_STANDARDS.md @@ -163,6 +163,14 @@ grep -rn "bg-.*-[0-9]" [path] --include="*.tsx" | grep -v "dark:" - **Compose with asChild** - Don't wrap, attach behavior to your components - **Style via data attributes** - `[data-state="open"]`, `[data-disabled]` - **Use Portal** for overlays with proper z-index +- **Support both controlled and uncontrolled modes** - All form primitives must work in both modes + +### Controlled vs Uncontrolled Form Components + +**CRITICAL RULE**: Form primitives (Switch, Checkbox, Select, etc.) MUST support both controlled and uncontrolled modes. + +**Controlled Mode**: Parent manages state via `value`/`checked` prop + `onChange`/`onCheckedChange` handler +**Uncontrolled Mode**: Component manages own state via `defaultValue`/`defaultChecked` ### Anti-Patterns ```tsx @@ -172,6 +180,12 @@ grep -rn "bg-.*-[0-9]" [path] --include="*.tsx" | grep -v "dark:" // ❌ Wrong composition Open + +// ❌ Only supports controlled mode (breaks uncontrolled usage) +const Switch = ({ checked, ...props }) => { + const displayIcon = checked ? iconOn : iconOff; // No internal state! + return +}; ``` ### Good Examples @@ -184,6 +198,20 @@ grep -rn "bg-.*-[0-9]" [path] --include="*.tsx" | grep -v "dark:" // ✅ Radix primitives + +// ✅ Supports both controlled and uncontrolled modes +const Switch = ({ checked, defaultChecked, onCheckedChange, ...props }) => { + const isControlled = checked !== undefined; + const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false); + const actualChecked = isControlled ? checked : internalChecked; + + const handleChange = (newChecked: boolean) => { + if (!isControlled) setInternalChecked(newChecked); + onCheckedChange?.(newChecked); + }; + + return +}; ``` ### Automated Scans @@ -191,9 +219,18 @@ grep -rn "bg-.*-[0-9]" [path] --include="*.tsx" | grep -v "dark:" # Native HTML form elements grep -rn "\|" [path] --include="*.tsx" grep -rn "type=\"checkbox\"\|type=\"radio\"" [path] --include="*.tsx" + +# Form primitives that may only support controlled mode (manual check) +grep -rn "checked.*props\|value.*props" [path]/primitives --include="*.tsx" -A 20 +# Then verify internal state management exists ``` -**Fix Pattern**: Import from `@/features/ui/primitives/`, use Radix primitives +**Fix Pattern**: +- Detect controlled mode: `isControlled = checked !== undefined` +- Add internal state: `useState(defaultChecked ?? false)` +- Create handler that updates both internal state and calls parent +- Use actual state for rendering and pass to Radix primitive +- Import from `@/features/ui/primitives/`, use Radix primitives --- @@ -337,8 +374,12 @@ grep -rn "const blurClasses\|backdrop-blur-md" [path]/primitives --include="*.ts - **Keyboard support on all interactive elements** - `` needs `role="button"`, `tabIndex={0}`, `onKeyDown` - Handle Enter and Space keys -- **ARIA attributes** - `aria-selected`, `aria-current`, `aria-expanded` +- **ARIA attributes** - `aria-selected`, `aria-current`, `aria-expanded`, `aria-pressed` - **Never remove focus rings** - Must be color-specific and static +- **Icon-only buttons MUST have aria-label** - Required for screen readers +- **Toggle buttons MUST have aria-pressed** - Indicates current state +- **Collapsible controls MUST have aria-expanded** - Indicates expanded/collapsed state +- **Decorative icons MUST have aria-hidden="true"** - Prevents screen reader announcement ### Anti-Patterns ```tsx @@ -350,6 +391,26 @@ grep -rn "const blurClasses\|backdrop-blur-md" [path]/primitives --include="*.ts // ❌ Clickable icon without button wrapper + +// ❌ Icon-only button without aria-label + + // Screen reader has no idea what this does! + + +// ❌ Toggle button without aria-pressed + + // No indication of current state! + + +// ❌ Expandable control without aria-expanded + setExpanded(!expanded)}> + // Screen reader doesn't know if expanded or collapsed! + + +// ❌ Icon without aria-hidden + + // Screen reader announces both "Delete" AND icon details! + ``` ### Good Examples @@ -384,6 +445,29 @@ grep -rn "const blurClasses\|backdrop-blur-md" [path]/primitives --include="*.ts > + +// ✅ Icon-only button with proper aria-label and aria-hidden + + + + +// ✅ Toggle button with aria-pressed + setViewMode("grid")} + aria-label="Grid view" + aria-pressed={viewMode === "grid"} +> + + + +// ✅ Expandable control with aria-expanded + setSidebarExpanded(false)} + aria-label="Collapse sidebar" + aria-expanded={sidebarExpanded} +> + + ``` ### Automated Scans @@ -394,11 +478,31 @@ grep -rn "onClick.*role=\"button\"" [path] --include="*.tsx" # Icons with onClick (should be wrapped in button) grep -rn "<[A-Z].*onClick={" [path] --include="*.tsx" | grep -v "` with proper ARIA attributes +- Icon-only buttons: Add `aria-label="Descriptive action"` +- Toggle buttons: Add `aria-pressed={isActive}` +- Expandable controls: Add `aria-expanded={isExpanded}` +- Icons in labeled buttons: Add `aria-hidden="true"` --- @@ -573,6 +677,11 @@ Run ALL these scans during review: - Hardcoded glass: `grep -rn "backdrop-blur.*bg-white/.*border" [path]` - Missing min-w-0: `grep -rn "flex-1" [path] | grep -v "min-w-0"` - Duplicate styling: `grep -rn "const edgeColors = {\|const.*Variants = {" [path]/primitives` +- Controlled-only form components: `grep -rn "checked.*props\|value.*props" [path]/primitives --include="*.tsx" -A 20` (verify internal state) +- Icon-only buttons without aria-label: `grep -rn "&1 | grep "error TS"` diff --git a/archon-ui-main/src/features/style-guide/layouts/ProjectsLayoutExample.tsx b/archon-ui-main/src/features/style-guide/layouts/ProjectsLayoutExample.tsx index 6b0e4331..b2b5bcbd 100644 --- a/archon-ui-main/src/features/style-guide/layouts/ProjectsLayoutExample.tsx +++ b/archon-ui-main/src/features/style-guide/layouts/ProjectsLayoutExample.tsx @@ -135,16 +135,20 @@ export const ProjectsLayoutExample = () => { size="sm" onClick={() => setLayoutMode("horizontal")} className={cn("px-3", layoutMode === "horizontal" && "bg-purple-500/20 text-purple-400")} + aria-label="Switch to horizontal layout" + aria-pressed={layoutMode === "horizontal"} > - + setLayoutMode("sidebar")} className={cn("px-3", layoutMode === "sidebar" && "bg-purple-500/20 text-purple-400")} + aria-label="Switch to sidebar layout" + aria-pressed={layoutMode === "sidebar"} > - + @@ -189,16 +193,20 @@ export const ProjectsLayoutExample = () => { size="sm" onClick={() => setViewMode("board")} className={cn("px-3", viewMode === "board" && "bg-cyan-500/20 text-cyan-400")} + aria-label="Board view" + aria-pressed={viewMode === "board"} > - + setViewMode("table")} className={cn("px-3", viewMode === "table" && "bg-cyan-500/20 text-cyan-400")} + aria-label="Table view" + aria-pressed={viewMode === "table"} > - + )} @@ -219,8 +227,15 @@ export const ProjectsLayoutExample = () => { Projects - setSidebarExpanded(false)} className="px-2"> - + setSidebarExpanded(false)} + className="px-2" + aria-label="Collapse sidebar" + aria-expanded={sidebarExpanded} + > + @@ -274,16 +289,20 @@ export const ProjectsLayoutExample = () => { size="sm" onClick={() => setViewMode("board")} className={cn("px-3", viewMode === "board" && "bg-cyan-500/20 text-cyan-400")} + aria-label="Board view" + aria-pressed={viewMode === "board"} > - + setViewMode("table")} className={cn("px-3", viewMode === "table" && "bg-cyan-500/20 text-cyan-400")} + aria-label="Table view" + aria-pressed={viewMode === "table"} > - + )} @@ -702,8 +721,9 @@ const TaskCardExample = ({ task, index }: { task: (typeof MOCK_TASKS)[0]; index: type="button" onClick={(e) => e.stopPropagation()} className="p-1 rounded hover:bg-cyan-500/10 text-gray-500 hover:text-cyan-500 transition-colors" + aria-label="Edit task" > - + Edit task @@ -714,8 +734,9 @@ const TaskCardExample = ({ task, index }: { task: (typeof MOCK_TASKS)[0]; index: type="button" onClick={(e) => e.stopPropagation()} className="p-1 rounded hover:bg-red-500/10 text-gray-500 hover:text-red-500 transition-colors" + aria-label="Delete task" > - + Delete task diff --git a/archon-ui-main/src/features/ui/primitives/switch.tsx b/archon-ui-main/src/features/ui/primitives/switch.tsx index 2620129c..f621dd0a 100644 --- a/archon-ui-main/src/features/ui/primitives/switch.tsx +++ b/archon-ui-main/src/features/ui/primitives/switch.tsx @@ -99,20 +99,42 @@ const switchVariants = { * - iconOn: Displayed when checked * - iconOff: Displayed when unchecked * - icon: Same icon for both states + * + * 5. CONTROLLED/UNCONTROLLED MODE SUPPORT + * - Controlled: Pass checked prop + onCheckedChange handler + * - Uncontrolled: Pass defaultChecked, component manages own state */ const Switch = React.forwardRef, SwitchProps>( - ({ className, size = "md", color = "cyan", icon, iconOn, iconOff, checked, ...props }, ref) => { + ({ className, size = "md", color = "cyan", icon, iconOn, iconOff, checked, defaultChecked, onCheckedChange, ...props }, ref) => { const sizeStyles = switchVariants.size[size]; const colorStyles = switchVariants.color[color]; + // Detect controlled vs uncontrolled mode + const isControlled = checked !== undefined; + + // Internal state for uncontrolled mode + const [internalChecked, setInternalChecked] = React.useState(defaultChecked ?? false); + + // Get the actual checked state (controlled or uncontrolled) + const actualChecked = isControlled ? checked : internalChecked; + + // Handle state changes for both controlled and uncontrolled modes + const handleCheckedChange = React.useCallback( + (newChecked: boolean) => { + // Update internal state for uncontrolled mode + if (!isControlled) { + setInternalChecked(newChecked); + } + // Call parent's handler if provided + onCheckedChange?.(newChecked); + }, + [isControlled, onCheckedChange] + ); + const displayIcon = React.useMemo(() => { if (size === "sm") return null; - - if (checked !== undefined) { - return checked ? iconOn || icon : iconOff || icon; - } - return icon; - }, [size, checked, icon, iconOn, iconOff]); + return actualChecked ? iconOn || icon : iconOff || icon; + }, [size, actualChecked, icon, iconOn, iconOff]); return ( , glassmorphism.interactive.base, className, )} - checked={checked} + checked={actualChecked} + onCheckedChange={handleCheckedChange} {...props} ref={ref} >