Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/appshell-hidden-regions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] AppShell takes `hiddenRegions?: ReadonlyArray<'topNav' | 'sideNav'>` to hide its top nav or side nav, for example for a focus mode, while they stay mounted. A hidden region takes no space, its contents are inert and keep their state, and focus inside it moves to the main content. The region slides out by its edge as the content takes its space, in one motion. On a phone, the drawer leaves out each hidden region, and its menu button goes when nothing is left to show. A region that hides while the drawer is open closes it, and revealing the region does not reopen it. MobileNav moves focus to main when the button that opened it is hidden.

@AKnassa @humbertovirtudes @cixzhang
49 changes: 49 additions & 0 deletions apps/storybook/stories/AppShell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,55 @@ export const TopNavWithSideNav: Story = {
),
};

function FocusModeDemo() {
const [isFocusMode, setIsFocusMode] = useState(false);
return (
<AppShell
contentPadding={6}
topNav={<AppTopNav />}
sideNav={<SideNavWithoutHeader />}
hiddenRegions={isFocusMode ? ['topNav', 'sideNav'] : undefined}>
<Button
label={isFocusMode ? 'Exit focus mode' : 'Focus mode'}
onClick={() => setIsFocusMode(!isFocusMode)}
/>
<MockContent />
</AppShell>
);
}

/**
* Focus mode: `hiddenRegions` hides the top nav and the side nav. They slide
* out by their edges as the content takes their space, in one motion, and
* keep their state. The exit control lives in the content.
*/
export const FocusMode: Story = {
render: () => <FocusModeDemo />,
};

function SectionFocusModeDemo() {
const [isHidden, setIsHidden] = useState(false);
return (
<AppShell
variant="section"
contentPadding={6}
topNav={<AppTopNav />}
sideNav={<SideNavWithoutHeader />}
hiddenRegions={isHidden ? ['sideNav'] : undefined}>
<Button
label={isHidden ? 'Show side nav' : 'Hide side nav'}
onClick={() => setIsHidden(!isHidden)}
/>
<MockContent />
</AppShell>
);
}

/** The section variant's side nav divider goes with the hidden side nav. */
export const HiddenSideNavSection: Story = {
render: () => <SectionFocusModeDemo />,
};

/**
* A long app name on a phone. The heading truncates with an ellipsis so the
* end content and the menu toggle stay inside the bar.
Expand Down
78 changes: 55 additions & 23 deletions docs/specs/components/AppShell/AppShell.spec.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/specs/components/MobileNav/MobileNav.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| FR3 | Opening calls `showModal()`, and records the focused element as the opener; where focus lands inside the open drawer is uncontracted here. While open, the page's scroller clips its overflow and keeps its scrollbar gutter: the root, or `<body>` when `<body>` sets its own overflow and the root's stays `visible`. Pull-to-refresh and the page's bounce stop, because a scroll lock under a modal holds the page (`spec:AST-094` FR19). Closing releases all three. | Current source and tests |
| FR4 | The native cancel request (Escape) is routed through the shared dismissal stack and requests close only when this drawer is topmost. A click on the overlay outside the Drawer requests close; a click inside does not. The Close button, named by `@astryx.mobileNav.closeNavigation`, requests close. A caller `onClick` on the dialog runs before the backdrop check; one that calls `preventDefault()` cancels that backdrop close. | Current source and tests |
| FR5 | After a close request the drawer slides out while the dialog stays open, and the dialog closes within its slide-out transition and no later than 250 ms after the request (on the next task, never in the commit that requests it, under reduced motion or a zero-length transition). Reopening before then cancels the close. Unmounting closes the dialog. After the dialog closes, the rest of the slide-out is inert. | Current source and tests |
| FR6 | When the dialog closes, focus returns to the recorded opener if it is still connected and was not the document body; otherwise focus is left alone. A reopen that interrupted a close keeps the original opener. | Current source and tests |
| FR6 | When the dialog closes, focus returns to the recorded opener if it is still connected and visible and was not the document body; a connected but hidden opener counts as gone, and focus moves to main; inside AppShell, an opener removed because the drawer has nothing left to show sends focus to that shell's main. Otherwise focus stays where it is. A reopen that interrupted a close keeps the original opener. | Current source and tests |
| FR7 | The Drawer enters from its resolved inline edge, from off-screen to on-screen, and the backdrop fades in from transparent, without a reduced-motion preference. Under reduced motion, motion follows `design:reduced-motion` DR1–DR3 (Current-state impact records today's shortfall). The overlay clips the off-screen drawer without becoming a scroll container and allows pinch zoom. | Current source and tests |
| FR8 | `side="start"` or `"end"` sets the edge. `side="auto"` MUST keep the drawer's start and end relationship to the element that opened it (`design:bidirectionality` DR7). Implementation gap: it resolves on each open from the focused element's physical half of the viewport (left half picks `start`, otherwise `end`); when nothing is focused at open it keeps its last value, and before any open it is `end`. | Current source |
| FR9 | The Drawer is `width` px wide (default 320), never wider than 85% of the viewport, so a strip of the page stays beside it. | Current source and tests |
Expand Down Expand Up @@ -275,7 +275,7 @@ ownership.
| FR4 | `layerDismissalFamilies.test.tsx` MobileNav cases | Escape over a required Dialog; cancel while topmost and while not | Closing when not topmost, or not closing when topmost, fails. | `audit:MobileNav/behavior` |
| FR5, ORD3 | `MobileNavCloseVisibility.test.tsx`; `MobileNavCloseEdgeCases.test.tsx`; `MobileNavCloseTiming.test.ts` | Close, reduced motion, reopen, unmount, the 250 ms bound | Closing before the slide-out or a stuck dialog fails. | `audit:MobileNav/behavior` |
| FR5 | `MobileNavTouchPresentation.a11y.chromium.spec.ts` (the slide-out after the close) | Closed dialog, drawer still sliding out; open drawer | A tap on the leaving drawer reaching it, or the browser's accessibility tree exposing the dialog or its links after the close, fails. | `audit:MobileNav/behavior` |
| FR6, AR2 | `MobileNavFocus.test.tsx` | Standalone, AppShell, Escape, removed opener | Focus not returned, or returned too early, fails. | `audit:MobileNav/accessibility` |
| FR6, AR2 | `MobileNavFocus.test.tsx`, including a hidden opener | Standalone, AppShell, Escape, removed opener, hidden opener | Focus not returned, or returned too early, fails. | `audit:MobileNav/accessibility` |
| FR7 | `MobileNavEntryAnimation.test.tsx` | Start and end edges | Missing starting style or a scroll-container overlay fails. | `audit:MobileNav/behavior` |
| FR11, AR3 | `MobileNavToggle.test.tsx`; `MobileNavReopen.test.tsx` | Closed, open, reopen | Broken expanded state or id link fails. | `audit:MobileNav/accessibility` |
| AR4 | `MobileNavPinchZoom.a11y.chromium.spec.ts` | Open drawer on a touch viewport | Pinch zoom blocked fails. | `audit:MobileNav/accessibility` |
Expand Down
Loading