Skip to content

fix(frontend): fix the ResponsiveDialog layout, the header separator, the layout landmarks, and the inset width - #202

Merged
pdevito3 merged 9 commits into
mainfrom
fix/ui-layout
Oct 28, 2026
Merged

pdevito3 merged 9 commits into
mainfrom
fix/ui-layout

Conversation

@pdevito3

@pdevito3 pdevito3 commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Why

The root layout and the ResponsiveDialog had layout and accessibility defects:

What changed

  • ResponsiveDialog: the header and footer stick to the top and bottom of the content that scrolls. They cover the full width, side gutters included, so a field that scrolls under them does not show next to them. The title leaves room for the close button. The header has 0.5 rem of background below the title that takes no space, so it covers the close button and a short dialog keeps its height.
  • ResponsiveDialog: the header and footer report their heights with a ResizeObserver. The scroll box has scroll padding of those heights, so Tab does not move a field under them.
  • ResponsiveDialog: the bottom sheet cap uses var(--toast-reserve, 4.5rem). The override of that cap in index.css is gone, so one place owns it.
  • __root.tsx: the separator centers on the trigger. The devtools render in a div. The breadcrumb looks up the page name by the path of the matched route, not by the URL. The keys are typed by the generated route paths, so a wrong key fails the build.
  • app-sidebar.tsx: the sidebar links are in <nav aria-label="Main">.
  • SidebarInset: adds min-w-0, so it shrinks to the room next to the sidebar.

Refs #152
Closes #160
Closes #161
Closes #162
Refs #159

Scope

  • ResponsiveDialog content runs under the close button, and the footer scrolls out of view #152 is fixed for each dialog that uses ResponsiveDialogFooter, such as the text filter and the components page. It stays open for the date and multiselect filters. Their Add Filter buttons are in their own footers, so in a tall sheet they still scroll out of view. The same controls also show in the desktop submenu popover, where the sticky footer offsets do not fit. A fix needs a footer that knows its surface, and a design choice for the Select All and Add row of the multiselect on a phone.
  • React Aria links cannot take route params, and the breadcrumb shows "Page" for a route with params #159 is only half fixed. The breadcrumb part is fixed. The link part needs a design choice, so I did not do it. React Aria's useHref gets only the href, so params in routerOptions cannot make a correct <a href>. Options:
    • (a) Wrap the React Aria link with TanStack's createLink(RACLink). This is what the TanStack docs show. It is a new link API with typed params.
    • (b) Type href as a plain string. Callers build the path, and route typing is lost.
    • (c) Let routerOptions take params. This type-checks, but the rendered href is wrong.
  • A combobox outside a modal clears the typed text on a page scroll, and Tab picks the option under the pointer #149 is skipped. Both behaviors are React Aria defaults, so a fix is a design choice:
    • A scroll closes the list and resets the text (useCloseOnScroll closes the popover, and close commits the selection).
    • Tab commits the option under the mouse. The hovered option is also shown as focused, so this matches React Aria's model.
    • Options: (A) override React Aria internals, as Fleet did (about 40 lines, tied to internals). (B) allowsCustomValue, which keeps the text but changes what the field means. (C) a controlled inputValue at the call site. (D) report it to react-spectrum.
  • The root queue items are already fixed on main: the nested main (fix(frontend): correct four small UI defects #132), min-h-screen in the route wrappers (fix(frontend): correct seven small UI defects #126), and items-center on the button size row in components.tsx.
  • min-w-0 fixes a wide child inside a box that scrolls. A wide child with no box that scrolls still makes the page wider. That is normal CSS.

Tradeoffs

  • I first tried a close-button row above the content. It added 12 px on a phone and pushed the date filter's Add Filter button off a short screen. The sticky header keeps the height.
  • A min height on the header covered the close button, but it made a short desktop dialog 6 px taller. Plain bottom padding would make it 8 px taller and push Add Filter off a 766 px screen. Padding with a negative margin of the same size covers the button and keeps the height. The cost: the header background covers the first 0.5 rem below it, so content needs that gap. Every current dialog has it. The header JSDoc says so.
  • On a phone, a sticky header with a long description uses some of the space that scrolls.
  • The sticky offsets match the p-4 of the content. If that padding changes, these must change too.
  • The header must be the first child and the footer the last. Their JSDoc says so.
  • In the Playwright WebKit build, Tab does not scroll the dialog at all, on main too. So the focus behavior was checked in Chromium only.
  • Focus on a button in the sticky footer still scrolls the content, because the browser applies the bottom scroll padding to it. Main scrolls further in the same case.

Blast Radius

  • Every ResponsiveDialog with a ResponsiveDialogHeader or ResponsiveDialogFooter gets the sticky header and footer. A short dialog has the same size and title position as on main: the Edit First Name filter at 1280 px is 266 px tall with the title at the same y, and each phone sheet has the height it has on main.
  • Sheet and Drawer do not change. Their screenshots are the same, pixel for pixel, before and after the last fix.
  • SidebarInset is used once, in __root.tsx.

Verification

  • Vitest browser tests in responsive-dialog.test.tsx at 1280 x 720 and 390 x 844: title, scroll, footer, and focus. A new test scrolls a field in focus under a short title and checks that only the header shows in the title row. It fails before the header fix (the side gutter at both sizes, and the field below the title on a phone) and passes now.
  • Playwright specs in app-shell.spec.ts: separator centered, no contentinfo from the devtools, links in the Main nav, no side scroll at 1024 px. The filter-demo and toast specs from fix(frontend): fix toast and sheet defects on a phone #203 pass. The filter sheet spacing test now measures from the title, as its name says.
  • pnpm lint, pnpm build, pnpm test, and pnpm test:e2e --workers=2 pass.

Screenshots

Each image shows main, then this PR before the last fix, then this PR now.

View main / before / now
Created Date filter, 320 px, scrolled to the end, light
Created Date filter, 320 px, scrolled to the end, dark
Edit Created Date dialog, 1280 x 480, scrolled, light
Edit Created Date dialog, 1280 x 480, scrolled, dark
Edit First Name dialog, 1280 px, light
Edit First Name dialog, 1280 px, dark
Components dialog, 1280 px, light
Components dialog, 1280 px, dark
Components dialog, 320 px, light
Components dialog, 320 px, dark

Sheet and Drawer, now, light and dark (the same as before the last fix):

View light / dark
Sheet, 320 px
Sheet, 1280 px
Drawer, 320 px
Drawer, 1280 px

The app shell header, before and after:

View Before After
Header, light before after
Header, dark before after

@pdevito3

Copy link
Copy Markdown
Owner Author

Review findings that this PR does not fix:

  • Low: focus on a button in the sticky footer scrolls the content, about 300 px in a long dialog. The bottom scroll padding also applies to the footer's own buttons. Main scrolls to the end in the same case, so this is not a regression.
  • Outside this diff: data-table.test.tsx "the pager button keeps the focus..." failed once in 4 full pnpm test runs and passed 5 of 5 alone. toaster.test.tsx (drawer case at line 143) failed once in 9 runs with "element is not stable ... detached". Both files belong to other work, so I did not change them.

…ndmarks, and wide content

These tests fail on main. A long ResponsiveDialog puts the title and the
scrolled content under the close button, and its footer scrolls out of
view. The header separator sits 6 px above the sidebar trigger. The router
devtools add a contentinfo landmark. The sidebar links have no navigation
landmark. A wide child at 1024 px makes the page scroll 256 px sideways.

A test also tabs through a long form in the dialog and checks that the
field in focus is not hidden. It passes on main and guards the sticky
header and footer.
…d the inset width

The vertical separator had self-stretch, so in the centered header row it
started at the top. It now centers on the sidebar trigger.

The router devtools rendered in a footer, which is a contentinfo
landmark. They now render in a div.

The sidebar links had no navigation landmark. They are now in a nav
named Main.

SidebarInset had no min-w-0. A wide child, such as a table in a box that
scrolls, made the inset grow and the page scroll sideways. The inset now
shrinks to the room next to the sidebar.

Closes #160
Closes #161
Closes #162
The breadcrumb read the page name from a map keyed by the URL path. A
route with params, such as /users/$id, never matched and showed "Page".
The map is now keyed by the full path of the matched route. The keys are
typed by the generated route paths, so a wrong key fails the build.

Refs #159
…r of the content

The close button was absolute over the content that scrolls. A long title
ran under it, and the content scrolled under it. The header now sticks to
the top of the content that scrolls, covers the close button, and the
title leaves room for the button.

The footer scrolled with the content, so a tall dialog hid its buttons.
The footer now sticks to the bottom of the content that scrolls.

When a field gets focus, the scroll box gets scroll padding of the header
and footer heights, so the browser does not scroll the field under them.

The dialog keeps its height. A close button row above the content would
add 12 px on a phone and push Add Filter of the 320 x 780 date filter off
the screen.

Closes #152
…der and footer

The scroll padding was set in the focus event. Chromium decides the
scroll before that padding applies, so the first field that got focus
after a scroll stayed under the header.

The header and the footer now report their heights to the dialog with a
ResizeObserver, and the scroll box always has scroll padding of those
heights. The test now scrolls before the first Tab.

The rule to put the header first and the footer last is now in their
JSDoc.
The test name said that the wide content scrolls in its own box, but the
test only checked that the page does not scroll sideways.
…ls under it

At 320 px, the Created Date filter showed the border and focus ring of
the date field around the title after a scroll. The header covered only
the content box, so the ring showed in the side gutter. On a phone the
header also ended at the bottom of the title box, and the text reaches
about 3 px below it.

The new test scrolls a field in focus under a short title and checks
that only the header shows in the row of the title. It fails until the
next commit.
…h the same height as main

The header and the footer now cover the side gutters of the content
that scrolls, so a focus ring there does not show next to them. The
scroll box has the rounded corners of the dialog, so the header and the
footer do not cover them.

The header gets 0.5rem of padding below the title, and a negative margin
of the same size, so the padding does not take space. It covers the
close button and the content just below the title. The min height for
the close button is gone, so a short desktop dialog has the height and
the title position of main again. It was 6 px taller.

The filter sheet spacing test measured from the bottom of the header
box. It now measures from the title, as its name says. On main the two
were the same.
ResponsiveDialog capped the sheet with a fixed 4.5rem for one toast, and
index.css overrode that cap with --toast-reserve. The component now uses
var(--toast-reserve, 4.5rem) and the max-height transition, and the
override in index.css is gone. The drawer already works this way.
@pdevito3

Copy link
Copy Markdown
Owner Author

Independent review of the fix-forward commits: no high or medium findings. One low finding stays open:

  • ResponsiveDialogHeader and ResponsiveDialogFooter use pairs of negative margin and padding (-mb-2 pb-2, -mx-4 px-4). tailwind-merge keeps one class for each property, so a consumer className such as mb-4 or px-6 replaces only one half of a pair. Today no consumer passes a className to either component. Other ui/ components share the same limit. So I left it as it is.

A second low finding (the JSDoc gap below the header was too small for a focus ring) is fixed in 9659931.

@pdevito3
pdevito3 merged commit 3b6af03 into main Oct 28, 2026
16 checks passed
@pdevito3
pdevito3 deleted the fix/ui-layout branch October 28, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant