Skip to content

Conversation

@kotAPI
Copy link
Collaborator

@kotAPI kotAPI commented Sep 7, 2025

Summary

  • ensure dialog root exposes data-state
  • support asChild on dialog content
  • add portal, hydration, and accessibility tests for Dialog

Testing

  • npm test

Summary by CodeRabbit

  • New Features

    • Dialog now exposes a data-state attribute (“open”/“closed”) to enable state-based styling.
  • Refactor

    • Updated Dialog content rendering to a primitive wrapper while preserving behavior and asChild semantics, improving consistency of focus and keyboard/overlay close interactions.
  • Tests

    • Added comprehensive Dialog portal test suite covering focus management, overlay/content layering, accessibility checks, SSR hydration, asChild semantics, and ref correctness.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 7, 2025

Walkthrough

Adds Dialog portal test suite; updates DialogPrimitiveContent to render via Primitive.div with asChild support; adds data-state attribute to DialogPrimitiveRoot reflecting isOpen.

Changes

Cohort / File(s) Summary
Dialog Portal Tests
src/components/ui/Dialog/tests/Dialog.portal.test.tsx
New tests covering portal rendering into custom container, focus/open-close behavior, overlay/content ordering, accessibility (axe), SSR render/hydrate, and asChild/ref semantics.
Dialog Primitive Content Rendering
src/core/primitives/Dialog/fragments/DialogPrimitiveContent.tsx
Switch wrapper to Primitive.div with asChild passthrough; maintain focus/keyboard logic; update typings and closing tag accordingly.
Dialog Root State Attribute
src/core/primitives/Dialog/fragments/DialogPrimitiveRoot.tsx
Add data-state attribute mirroring isOpen ("open"/"closed"); no API changes.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant Trigger as Dialog.Trigger
  participant Root as DialogPrimitiveRoot
  participant Portal as Dialog.Portal
  participant Overlay as Overlay
  participant Content as Content (Primitive.div)

  User->>Trigger: Click
  Trigger->>Root: set isOpen = true
  Note over Root: data-state="open"
  Root->>Portal: Render into container
  Portal->>Overlay: Mount
  Portal->>Content: Mount and focus first element

  User->>Content: Press Escape / click Overlay
  Content-->>Root: requestClose
  Root->>Trigger: restore focus
  Root->>Portal: Unmount children
  Note over Root: data-state="closed"
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • GoldGroove06
  • mrkazmi333

Poem

Thump-thump! I open the portal door,
Overlay sails, content on the floor.
data-state whispers “open,” then “closed,”
Focus hops back, just as supposed.
SSR dreams, no warnings to fret—
A rabbit approves this tidy set. 🐇✨

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch kotapi/verify-dialog-component-functionality

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review: Here are some suggestions.

Reply with @codex fix comments to fix any unresolved comments.

About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you open a pull request for review, mark a draft as ready, or comment "@codex review". If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex fix this CI failure" or "@codex address that feedback".

@kotAPI kotAPI linked an issue Sep 7, 2025 that may be closed by this pull request
@kotAPI kotAPI merged commit 260b49d into main Sep 7, 2025
7 checks passed
@kotAPI kotAPI deleted the kotapi/verify-dialog-component-functionality branch September 7, 2025 08:09
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
src/components/ui/Dialog/tests/Dialog.portal.test.tsx (1)

145-147: Good fix: hydrateRoot is unmounted and container removed.

This resolves the prior leak/scroll-lock issue flagged in earlier reviews. Thanks for closing the loop.

🧹 Nitpick comments (7)
src/core/primitives/Dialog/fragments/DialogPrimitiveRoot.tsx (1)

11-11: Minor TS style: add spacing after colon.

Purely cosmetic for consistency with the rest of the codebase.

-    className?:string
+    className?: string
src/core/primitives/Dialog/fragments/DialogPrimitiveContent.tsx (3)

99-99: Broaden event generic to support asChild.

When asChild renders non-divs (e.g., form), React.KeyboardEvent<HTMLDivElement> is too narrow. Use HTMLElement.

-                            ? (e: React.KeyboardEvent<HTMLDivElement>) => {
+                            ? (e: React.KeyboardEvent<HTMLElement>) => {

30-31: Loosen ref element type for asChild.

forwardRef<HTMLDivElement> and useRef<HTMLDivElement> conflict with polymorphic asChild. Prefer HTMLElement.

-const DialogPrimitiveContent = forwardRef<HTMLDivElement, DialogPrimitiveContentProps>((
+const DialogPrimitiveContent = forwardRef<HTMLElement, DialogPrimitiveContentProps>((
@@
-    const contentRef = useRef<HTMLDivElement | null>(null);
+    const contentRef = useRef<HTMLElement | null>(null);

Also applies to: 18-18


61-66: tabIndex removal via DOM mutation is brittle.

React will re-apply tabIndex={-1} on re-render, negating removeAttribute. Drive it from state and props instead.

+    const [hasFocusableChild, setHasFocusableChild] = React.useState(false);
@@
-            const firstFocusable = root.querySelector<HTMLElement>(focusableSelector);
-            if (firstFocusable) {
-                firstFocusable.focus();
-                // Remove tabIndex from content div when there are focusable children
-                root.removeAttribute('tabindex');
-            } else {
+            const firstFocusable = root.querySelector<HTMLElement>(focusableSelector);
+            if (firstFocusable) {
+                setHasFocusableChild(true);
+                firstFocusable.focus();
+            } else {
+                setHasFocusableChild(false);
                 (root as HTMLElement).focus({ preventScroll: true });
             }
@@
-                    tabIndex={-1}
+                    tabIndex={hasFocusableChild ? undefined : -1}

Also applies to: 89-89

src/components/ui/Dialog/tests/Dialog.portal.test.tsx (3)

60-86: Clean up the custom portal container after the test.

portalRoot is appended to document.body but never removed. Remove it post-assertions to avoid cross-test leakage.

   test('portal container mounts elements and preserves layering', async () => {
@@
     expect(relation & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+    portalRoot.remove();
   });

152-171: Type casts in asChild refs hint at missing polymorphic typing.

The casts work for tests, but consider making Trigger/Content polymorphic so ref types follow the rendered element, avoiding unknown as in consumers and tests.

Would you like a follow-up PR to add a polymorphic asChild type pattern (e.g., generic props + ElementType) to the primitives?


88-103: axe usage is fine; consider jest-axe for nicer DX.

Not required, but jest-axe provides matchers like toHaveNoViolations() and async helpers.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 142c0cb and 132c7a8.

📒 Files selected for processing (3)
  • src/components/ui/Dialog/tests/Dialog.portal.test.tsx (1 hunks)
  • src/core/primitives/Dialog/fragments/DialogPrimitiveContent.tsx (4 hunks)
  • src/core/primitives/Dialog/fragments/DialogPrimitiveRoot.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/components/ui/Dialog/tests/Dialog.portal.test.tsx (1)
docs/app/docs/components/dialog/docs/anatomy.tsx (1)
  • Dialog (3-17)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build

Comment on lines +85 to 88
<Primitive.div
ref={mergedRef}
asChild={asChild}
{...getFloatingProps()}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Don’t let consumer onKeyDown override escape/Tab trap; compose handlers.

Currently {...props} after your onKeyDown lets consumers replace core logic. Compose user and internal handlers via getFloatingProps({ onKeyDown }), and avoid passing onKeyDown again in {...props}.

-    return (
+    // Extract user onKeyDown to compose safely
+    const { onKeyDown: userOnKeyDown, ...restProps } = props as React.HTMLAttributes<HTMLElement>;
+    return (
         <>
             {shouldRender && (
                 <Primitive.div
                     ref={mergedRef}
                     asChild={asChild}
-                    {...getFloatingProps()}
+                    {...getFloatingProps({
+                      onKeyDown: isOpen
+                        ? (e: React.KeyboardEvent<HTMLElement>) => {
+                            userOnKeyDown?.(e);
+                            if (e.defaultPrevented) return;
+                            // escape + tab trap
+                            if (e.key === 'Escape') {
+                              e.preventDefault();
+                              e.stopPropagation();
+                              handleOpenChange(false);
+                              return;
+                            }
+                            if (e.key === 'Tab') {
+                              const focusableSelector = [
+                                'button:not([disabled])',
+                                '[href]',
+                                'input:not([disabled])',
+                                'select:not([disabled])',
+                                'textarea:not([disabled])',
+                                '[tabindex]:not([tabindex="-1"])'
+                              ].join(',');
+                              const focusableElements = Array.from(
+                                contentRef.current?.querySelectorAll(focusableSelector) || []
+                              ) as HTMLElement[];
+                              if (focusableElements.length === 0) return;
+                              const firstElement = focusableElements[0];
+                              const lastElement = focusableElements[focusableElements.length - 1];
+                              if (e.shiftKey) {
+                                if (document.activeElement === firstElement) {
+                                  e.preventDefault();
+                                  lastElement.focus();
+                                }
+                              } else {
+                                if (document.activeElement === lastElement) {
+                                  e.preventDefault();
+                                  firstElement.focus();
+                                }
+                              }
+                            }
+                        }
+                        : undefined
+                    })}
                     tabIndex={-1}
-                    style={{ outline: 'none' }}
                     role={role}
                     aria-modal={isOpen ? ariaModal : undefined}
                     aria-labelledby={isOpen ? ariaLabelledBy : undefined}
                     aria-describedby={isOpen ? ariaDescribedBy : undefined}
                     aria-hidden={!isOpen ? 'true' : undefined}
                     data-state={dataState}
-                    onKeyDown={
-                        isOpen
-                            ? (e: React.KeyboardEvent<HTMLDivElement>) => {
-                                // Handle escape key
-                                if (e.key === 'Escape') {
-                                    e.preventDefault();
-                                    e.stopPropagation();
-                                    handleOpenChange(false);
-                                }
-                                // Handle tab trapping
-                                if (e.key === 'Tab') {
-                                    const focusableSelector = [
-                                        'button:not([disabled])',
-                                        '[href]',
-                                        'input:not([disabled])',
-                                        'select:not([disabled])',
-                                        'textarea:not([disabled])',
-                                        '[tabindex]:not([tabindex="-1"])'
-                                    ].join(',');
-                                    const focusableElements = Array.from(
-                                        contentRef.current?.querySelectorAll(focusableSelector) || []
-                                    ) as HTMLElement[];
-                                    if (focusableElements.length === 0) return;
-                                    const firstElement = focusableElements[0];
-                                    const lastElement = focusableElements[focusableElements.length - 1];
-                                    if (e.shiftKey) {
-                                        // Shift + Tab
-                                        if (document.activeElement === firstElement) {
-                                            e.preventDefault();
-                                            lastElement.focus();
-                                        }
-                                    } else {
-                                        // Tab
-                                        if (document.activeElement === lastElement) {
-                                            e.preventDefault();
-                                            firstElement.focus();
-                                        }
-                                    }
-                                }
-                            }
-                            : undefined
-                    }
-                    {...props}
+                    {...restProps}
                 >

Also applies to: 97-145

🤖 Prompt for AI Agents
In src/core/primitives/Dialog/fragments/DialogPrimitiveContent.tsx around lines
85-88 (and likewise apply to 97-145), the current JSX spreads {...props} after
you set an internal onKeyDown which allows a consumer-supplied onKeyDown to
replace your escape/Tab trap; compose handlers instead. Create an internal
onKeyDown that implements the escape/Tab logic and then call getFloatingProps({
onKeyDown: internalOnKeyDown }) so getFloatingProps composes handlers; when
spreading props, exclude props.onKeyDown (or explicitly merge userOnKeyDown by
invoking it from internalOnKeyDown) so consumers cannot override the core
behavior. Ensure the same composition and exclusion of onKeyDown is applied to
the other block at lines 97-145.

asChild={asChild}
{...getFloatingProps()}
tabIndex={-1}
style={{ outline: 'none' }}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid removing focus outline.

style={{ outline: 'none' }} removes visible focus when the content itself is focused (no focusable children) — an a11y regression.

-                    style={{ outline: 'none' }}
+                    // Keep default outline for accessibility; customize via CSS if needed

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/core/primitives/Dialog/fragments/DialogPrimitiveContent.tsx around line
90, an inline style of style={{ outline: 'none' }} was added which removes the
visible focus outline and causes an accessibility regression when the content
itself is focused; remove this inline style and instead ensure focus visuals are
preserved by using a CSS approach such as leaving the default outline, or
applying a rule that only hides outline for non-keyboard interactions (e.g. use
:focus:not(:focus-visible){outline:none} or a focus-visible utility class) so
keyboard users still see a focus ring while mouse users aren’t shown an unwanted
outline.

Comment on lines +48 to +53
<div
ref={ref}
data-state={isOpen ? 'open' : 'closed'}
{...props}
className={className}
>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Prevent consumers from overriding the computed data-state.

Because {...props} comes after data-state, a consumer can unintentionally shadow the controlled attribute. Move {...props} before data-state so your state wins.

             <div
                 ref={ref}
-                data-state={isOpen ? 'open' : 'closed'}
-                {...props}
+                {...props}
+                data-state={isOpen ? 'open' : 'closed'}
                 className={className}
             >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
ref={ref}
data-state={isOpen ? 'open' : 'closed'}
{...props}
className={className}
>
<div
ref={ref}
{...props}
data-state={isOpen ? 'open' : 'closed'}
className={className}
>
🤖 Prompt for AI Agents
In src/core/primitives/Dialog/fragments/DialogPrimitiveRoot.tsx around lines 48
to 53, the JSX spreads {...props} after setting data-state which allows consumer
props to overwrite the computed data-state; move the {...props} spread to before
the data-state attribute (e.g., place {...props} prior to data-state={isOpen ?
'open' : 'closed'}) so the component-controlled data-state cannot be shadowed by
user-provided props.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[TEST] Dialog: portal focus return & SSR hydration

2 participants