From 50cac7b7690f8c287415d166b5a0013032a51a9c Mon Sep 17 00:00:00 2001 From: Pranay Kothapalli Date: Sat, 3 Oct 2026 08:38:45 +0530 Subject: [PATCH 01/13] fix(dialog): prevent initial focus stealing --- .changeset/dialog-initial-focus.md | 5 + .../components/CompleteCoveragePlayground.js | 637 ++++++++++++++++++ docs/app/playground/page.tsx | 4 + .../ui/Dialog/tests/Dialog.lazyMount.test.tsx | 17 + .../fragments/DialogPrimitiveContent.tsx | 8 + 5 files changed, 671 insertions(+) create mode 100644 .changeset/dialog-initial-focus.md create mode 100644 docs/app/playground/components/CompleteCoveragePlayground.js diff --git a/.changeset/dialog-initial-focus.md b/.changeset/dialog-initial-focus.md new file mode 100644 index 000000000..d42f5557c --- /dev/null +++ b/.changeset/dialog-initial-focus.md @@ -0,0 +1,5 @@ +--- +"@radui/ui": patch +--- + +Prevent closed, force-mounted dialogs from moving focus to their trigger on initial mount. diff --git a/docs/app/playground/components/CompleteCoveragePlayground.js b/docs/app/playground/components/CompleteCoveragePlayground.js new file mode 100644 index 000000000..ac5184360 --- /dev/null +++ b/docs/app/playground/components/CompleteCoveragePlayground.js @@ -0,0 +1,637 @@ +'use client' + +import React from "react" +import { + Bell, + Calendar, + Check, + ChevronRight, + FileCode2, + FolderOpen, + Home, + Inbox, + Menu, + Search, + Settings, + SlidersHorizontal, + X +} from "lucide-react" +import Accordion from "@radui/ui/Accordion" +import Badge from "@radui/ui/Badge" +import Breadcrumb from "@radui/ui/Breadcrumb" +import Button from "@radui/ui/Button" +import Callout from "@radui/ui/Callout" +import Checkbox from "@radui/ui/Checkbox" +import CheckboxCards from "@radui/ui/CheckboxCards" +import CheckboxGroup from "@radui/ui/CheckboxGroup" +import Collapsible from "@radui/ui/Collapsible" +import Combobox from "@radui/ui/Combobox" +import Command from "@radui/ui/Command" +import ContextMenu from "@radui/ui/ContextMenu" +import DataList from "@radui/ui/DataList" +import Disclosure from "@radui/ui/Disclosure" +import Drawer from "@radui/ui/Drawer" +import DropdownMenu from "@radui/ui/DropdownMenu" +import Fieldset from "@radui/ui/Fieldset" +import Heading from "@radui/ui/Heading" +import HoverCard from "@radui/ui/HoverCard" +import Kbd from "@radui/ui/Kbd" +import Link from "@radui/ui/Link" +import LiveRegion from "@radui/ui/LiveRegion" +import Menubar from "@radui/ui/Menubar" +import Minimap from "@radui/ui/Minimap" +import NavigationMenu from "@radui/ui/NavigationMenu" +import NumberField from "@radui/ui/NumberField" +import Popover from "@radui/ui/Popover" +import Progress from "@radui/ui/Progress" +import Radio from "@radui/ui/Radio" +import RadioCards from "@radui/ui/RadioCards" +import RadioGroup from "@radui/ui/RadioGroup" +import ScrollArea from "@radui/ui/ScrollArea" +import Select from "@radui/ui/Select" +import Separator from "@radui/ui/Separator" +import Skeleton from "@radui/ui/Skeleton" +import Slider from "@radui/ui/Slider" +import Spinner from "@radui/ui/Spinner" +import Splitter from "@radui/ui/Splitter" +import Steps from "@radui/ui/Steps" +import Switch from "@radui/ui/Switch" +import TabNav from "@radui/ui/TabNav" +import Text from "@radui/ui/Text" +import TextArea from "@radui/ui/TextArea" +import TextField from "@radui/ui/TextField" +import Toast from "@radui/ui/Toast" +import Toggle from "@radui/ui/Toggle" +import ToggleGroup from "@radui/ui/ToggleGroup" +import Toolbar from "@radui/ui/Toolbar" +import Tooltip from "@radui/ui/Tooltip" +import Tree from "@radui/ui/Tree" +import ColorLooper from "../helpers/ColorLooper" + +const colors = ["gray", "blue", "green", "red", "plum", "gold"] +const badgeVariants = ["solid", "soft", "surface", "outline", "ghost"] +const buttonVariants = ["solid", "soft", "outline", "ghost"] +const sizes = ["small", "medium", "large", "x-large"] + +const SectionGrid = ({ children }) => ( +
+ {children} +
+) + +const Panel = ({ title, children }) => ( +
+ {title} + {children} +
+) + +const Label = ({ children, htmlFor }) => ( + +) + +const Row = ({ icon, label, description }) => ( + + {icon} + + {label} + {description ? {description} : null} + + +) + +const treeItems = [ + { + label: "Components", + expanded: true, + items: [ + { label: "Inputs", expanded: false }, + { label: "Overlays", expanded: false }, + { label: "Navigation", expanded: false } + ] + }, + { + label: "Tokens", + expanded: false, + items: [{ label: "Colors", expanded: false }, { label: "Spacing", expanded: false }] + } +] + +const ToastShelf = () => { + const manager = Toast.useToastManager() + + return ( + <> + + + {manager.toasts.map((toast) => ( + + + {toast.title} + {toast.description ? {toast.description} : null} + + + + ))} + + +
+ + + +
+ + ) +} + +const CompleteCoveragePlayground = () => { + const [liveMessage, setLiveMessage] = React.useState("Ready") + const [checkboxCards, setCheckboxCards] = React.useState(["security"]) + const [checkboxGroup, setCheckboxGroup] = React.useState(["email"]) + const [sliderValue, setSliderValue] = React.useState([24, 72]) + const [numberValue, setNumberValue] = React.useState(3) + + return ( +
+ +
+ +
+ {badgeVariants.map((variant) => ( +
+ {variant} +
+ {colors.map((color) => ( + + {color} + + ))} +
+
+ ))} +
+
+ +
+ {buttonVariants.map((variant) => ( +
+ {sizes.map((size) => ( + + ))} +
+ ))} +
+
+ +
+ {colors.slice(1).map((color) => ( + + + + {color} + Color prop coverage across alert-like content. + + + ))} +
+
+
+
+ + + + +
+ + + {["compact", "comfortable", "spacious"].map((value) => ( + + + + + {value} + + ))} + + +
+ Preview + + B + I + U + +
+
+
+ + + + Profile + Common input states in one form block. +
+ + } /> +
+
+ + + ); + + const root = container.firstElementChild as HTMLDivElement; + const input = screen.getByRole('textbox', { name: 'Message' }); + expect(input).toHaveAttribute('id', 'message'); + expect(input).toHaveAttribute('readonly'); + expect(root).not.toHaveAttribute('aria-label'); + expect(root).not.toHaveAttribute('id'); + expect(root).not.toHaveTextContent('DraftDraft'); + }); + it('renders without console warnings', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/src/components/ui/TextArea/TextArea.tsx b/src/components/ui/TextArea/TextArea.tsx index 3f0969e5e..adf5d5a3a 100644 --- a/src/components/ui/TextArea/TextArea.tsx +++ b/src/components/ui/TextArea/TextArea.tsx @@ -4,10 +4,15 @@ import clsx from 'clsx'; import TextAreaRoot, { TextAreaRootProps } from './fragments/TextAreaRoot'; import TextAreaInput, { TextAreaInputProps } from './fragments/TextAreaInput'; -export type TextAreaProps = React.ComponentPropsWithoutRef<'div'> & TextAreaRootProps & TextAreaInputProps & { - customRootClass?: string; - readonly ?: boolean; - disabled ?: boolean; +export type TextAreaProps = TextAreaInputProps & Pick & { + /** Classes are applied to the styled root for backwards compatibility. */ + className?: string; + /** Styles are applied to the styled root for backwards compatibility. */ + style?: React.CSSProperties; + readonly?: boolean; + disabled?: boolean; }; type TextAreaComponent = React.ForwardRefExoticComponent>> & { @@ -15,13 +20,42 @@ type TextAreaComponent = React.ForwardRefExoticComponent, TextAreaProps>(({ customRootClass = '', placeholder = '', className = '', disabled = false, readonly = false, children, ...props }, ref) => { +const TextArea = React.forwardRef, TextAreaProps>(({ + customRootClass = '', + placeholder = '', + className = '', + style, + disabled = false, + readonly, + readOnly, + children, + variant, + size, + resize, + color, + radius, + ...inputProps +}, ref) => { return ( - - + + {children} - {children} ); }) as TextAreaComponent; diff --git a/src/components/ui/TextArea/__snapshots__/TextArea.test.tsx.snap b/src/components/ui/TextArea/__snapshots__/TextArea.test.tsx.snap index 66ce9dfad..22e197247 100644 --- a/src/components/ui/TextArea/__snapshots__/TextArea.test.tsx.snap +++ b/src/components/ui/TextArea/__snapshots__/TextArea.test.tsx.snap @@ -10,6 +10,5 @@ exports[`TextArea matches snapshot 1`] = ` > content - content
`; diff --git a/src/components/ui/TextArea/tests/TextArea.a11y.test.tsx b/src/components/ui/TextArea/tests/TextArea.a11y.test.tsx index 830331007..d3687abe0 100644 --- a/src/components/ui/TextArea/tests/TextArea.a11y.test.tsx +++ b/src/components/ui/TextArea/tests/TextArea.a11y.test.tsx @@ -6,7 +6,7 @@ import TextArea from '../TextArea'; describe('TextArea accessibility', () => { test('axe: no violations', async() => { - const { container } = render(); + const { container } = render(); const results = await axe.run(container, { runOnly: { type: 'tag', values: ACCESSIBILITY_TEST_TAGS } }); expect(results.violations).toHaveLength(0); }); diff --git a/src/components/ui/Toast/fragments/ToastViewport.tsx b/src/components/ui/Toast/fragments/ToastViewport.tsx index c8e039aba..5c6cfe3b1 100644 --- a/src/components/ui/Toast/fragments/ToastViewport.tsx +++ b/src/components/ui/Toast/fragments/ToastViewport.tsx @@ -52,7 +52,6 @@ const ToastViewport: React.FC = ({ children, className, styl return (
    { expect(results.violations).toHaveLength(0); }); - // The viewport is the landmark that lets a screen reader user skip to - // notifications; the individual toasts are the live regions. - test('viewport is a named region', async() => { + // The viewport is a named list; the individual toasts are live regions. + test('viewport is a named list', async() => { renderToasts(); emit({ title: 'Saved' }); - await waitFor(() => expect(screen.getByRole('region', { name: 'Notifications' })).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('list', { name: 'Notifications' })).toBeInTheDocument()); }); test('each toast is a polite status region', async() => { diff --git a/src/components/ui/Toast/tests/Toast.toaster.test.tsx b/src/components/ui/Toast/tests/Toast.toaster.test.tsx index 426d2dcb8..602cfd4f4 100644 --- a/src/components/ui/Toast/tests/Toast.toaster.test.tsx +++ b/src/components/ui/Toast/tests/Toast.toaster.test.tsx @@ -38,7 +38,7 @@ describe('Toaster convenience wrapper', () => { /> ); - const viewport = screen.getByRole('region', { name: 'Alerts' }); + const viewport = screen.getByRole('list', { name: 'Alerts' }); expect(viewport).toHaveClass('viewport-class'); expect(viewport).toHaveStyle({ outlineOffset: '4px' diff --git a/src/core/primitives/Checkbox/fragments/CheckboxPrimitiveRoot.tsx b/src/core/primitives/Checkbox/fragments/CheckboxPrimitiveRoot.tsx index 3ea849b53..691f203ab 100644 --- a/src/core/primitives/Checkbox/fragments/CheckboxPrimitiveRoot.tsx +++ b/src/core/primitives/Checkbox/fragments/CheckboxPrimitiveRoot.tsx @@ -53,7 +53,16 @@ const CheckboxPrimitiveRoot = forwardRef + }} + name={name} + value={value} + checked={isChecked === true} + disabled={disabled} + required={required} + aria-hidden="true" + tabIndex={-1} + readOnly + /> ; } ); diff --git a/src/core/primitives/Checkbox/tests/CheckboxPrimitive.test.tsx b/src/core/primitives/Checkbox/tests/CheckboxPrimitive.test.tsx index 9637edb30..216568cdc 100644 --- a/src/core/primitives/Checkbox/tests/CheckboxPrimitive.test.tsx +++ b/src/core/primitives/Checkbox/tests/CheckboxPrimitive.test.tsx @@ -9,6 +9,19 @@ const TickIcon = () => ( ); describe('CheckboxPrimitive', () => { + it('keeps the form input out of the accessibility and tab order', () => { + const { container } = render( + + + + ); + + expect(container.querySelectorAll('[role="checkbox"]')).toHaveLength(1); + const input = container.querySelector('input[type="checkbox"]'); + expect(input).toHaveAttribute('aria-hidden', 'true'); + expect(input).toHaveAttribute('tabindex', '-1'); + }); + it('renders and toggles (uncontrolled)', () => { const { container, queryByText } = render( diff --git a/src/core/primitives/Radio/index.tsx b/src/core/primitives/Radio/index.tsx index ca1baffb8..134a8aebb 100644 --- a/src/core/primitives/Radio/index.tsx +++ b/src/core/primitives/Radio/index.tsx @@ -20,7 +20,6 @@ const RadioPrimitive = React.forwardRef { expect(radio).toHaveAttribute('name', 'test-radio'); expect(radio).toHaveAttribute('value', 'option1'); expect(radio).toHaveAttribute('id', 'radio1'); + expect((radio as HTMLInputElement).tabIndex).toBe(0); }); it('applies checked, required, and disabled props', () => { diff --git a/src/core/utils/RovingFocusGroup/fragments/RovingFocusItem.tsx b/src/core/utils/RovingFocusGroup/fragments/RovingFocusItem.tsx index d158f11fa..2685e27de 100644 --- a/src/core/utils/RovingFocusGroup/fragments/RovingFocusItem.tsx +++ b/src/core/utils/RovingFocusGroup/fragments/RovingFocusItem.tsx @@ -64,7 +64,7 @@ const RovingFocusItem = forwardRef(({ const isDisabled = child?.props?.disabled === true; const childElementType = typeof child?.type === 'string' ? child.type.toLowerCase() : ''; const isLinkLikeChild = childElementType === 'a' || child?.props?.href != null; - const resolvedRole = role ?? (isLinkLikeChild ? undefined : 'button'); + const resolvedRole = role ?? (isLinkLikeChild ? 'link' : 'button'); const resolvedType = type ?? (isLinkLikeChild ? undefined : 'button'); // Is this item currently selected @@ -278,7 +278,6 @@ const RovingFocusItem = forwardRef(({ data-child-disabled={isDisabled} role={resolvedRole} type={resolvedType} - aria-selected={isSelected} aria-disabled={isDisabled} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} diff --git a/src/core/utils/RovingFocusGroup/fragments/RovingFocusRoot.tsx b/src/core/utils/RovingFocusGroup/fragments/RovingFocusRoot.tsx index 92cd20b30..2733ad117 100644 --- a/src/core/utils/RovingFocusGroup/fragments/RovingFocusRoot.tsx +++ b/src/core/utils/RovingFocusGroup/fragments/RovingFocusRoot.tsx @@ -60,7 +60,6 @@ const RovingFocusRoot = React.forwardRef, RovingFocusRoo return { await user.tab(); expect(item1).toHaveFocus(); + expect(item1).toHaveAttribute('tabindex', '0'); + expect(item1).not.toHaveAttribute('aria-selected'); await user.keyboard('{ArrowRight}'); expect(item2).toHaveFocus(); + expect(item1).toHaveAttribute('tabindex', '-1'); + expect(item2).toHaveAttribute('tabindex', '0'); + expect(item2).not.toHaveAttribute('aria-selected'); await user.tab(); expect(outside).toHaveFocus();