fix(opal): Lock the document while the app frame is mounted (#14590)

This commit is contained in:
Raunak Bhagat authored and GitHub committed 2026-09-09 19:19:16 +00:00
1 parent d77e02b6c5
commit 48c2f9779c
7 files changed
+61 -54

No files matched your search

+31 -25
View File
@@ -13,9 +13,15 @@ footer.
Full-viewport flex row that wraps all other `RootLayout` primitives.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `children` | `ReactNode` | — | `Sidebar`, `LeftPanel`, `App`, `RightPanel` |
Mounting it locks the document: `html`/`body` get `height: 100%; overflow: hidden`
(via `:has()`), the frame anchors itself at `h-dvh overflow-hidden`, and
`MainContent` becomes the app's one scroller. The sidebar and panels are planted —
nothing that grows inside a page can scroll them out of view. Surfaces that do not
mount the frame (auth, error pages) keep normal document scrolling.
| Prop | Type | Default | Description |
| ---------- | ----------- | ------- | ------------------------------------------- |
| `children` | `ReactNode` | — | `Sidebar`, `LeftPanel`, `App`, `RightPanel` |
### Sidebar
@@ -28,11 +34,11 @@ Controlled sidebar that handles three viewport sizes:
backdrop that closes on click. `useSidebarFolded()` always returns `false`
here (content is always expanded; the overlay handles visibility).
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `folded` | `boolean` | — | Current fold state — controlled by the consumer |
| `onFoldToggle` | `() => void` | — | Called when the sidebar should toggle |
| `children` | `ReactNode` | — | Sidebar shell and body content |
| Prop | Type | Default | Description |
| -------------- | ------------ | ------- | ----------------------------------------------- |
| `folded` | `boolean` | — | Current fold state — controlled by the consumer |
| `onFoldToggle` | `() => void` | — | Called when the sidebar should toggle |
| `children` | `ReactNode` | — | Sidebar shell and body content |
### App
@@ -51,10 +57,10 @@ Accepts all standard `div` props.
Permanent `shrink-0` columns that push `App` rather than overlaying it.
Width is caller-supplied via `className` (e.g. `className="w-80"`).
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `className` | `string` | — | Width and any other overrides (twMerge applied) |
| `children` | `ReactNode` | — | Panel content |
| Prop | Type | Default | Description |
| ----------- | ----------- | ------- | ----------------------------------------------- |
| `className` | `string` | — | Width and any other overrides (twMerge applied) |
| `children` | `ReactNode` | — | Panel content |
### Header
@@ -64,10 +70,10 @@ Pinned `shrink-0` top bar inside `App`.
Pinned `shrink-0` bottom bar inside `App`.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `extraPadding` | `boolean` | `false` | Adds `14 px` top padding for shadow breathing room above the input bar |
| `children` | `ReactNode` | — | Footer content |
| Prop | Type | Default | Description |
| -------------- | ----------- | ------- | ---------------------------------------------------------------------- |
| `extraPadding` | `boolean` | `false` | Adds `14 px` top padding for shadow breathing room above the input bar |
| `children` | `ReactNode` | — | Footer content |
## Hooks
@@ -101,14 +107,12 @@ import { RootLayout } from "@opal/layouts";
<RootLayout.Header>
<AppHeader />
</RootLayout.Header>
<RootLayout.MainContent>
{children}
</RootLayout.MainContent>
<RootLayout.MainContent>{children}</RootLayout.MainContent>
<RootLayout.Footer>
<AppFooter />
</RootLayout.Footer>
</RootLayout.App>
</RootLayout.Root>
</RootLayout.Root>;
```
### With panels
@@ -122,9 +126,7 @@ import { RootLayout } from "@opal/layouts";
<FilterPanel />
</RootLayout.LeftPanel>
<RootLayout.App>
<RootLayout.MainContent>
{children}
</RootLayout.MainContent>
<RootLayout.MainContent>{children}</RootLayout.MainContent>
</RootLayout.App>
<RootLayout.RightPanel className="w-80">
<DetailPanel />
@@ -141,8 +143,12 @@ import { RootLayout } from "@opal/layouts";
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<RootLayout.Header><AppHeader /></RootLayout.Header>
<RootLayout.Header>
<AppHeader />
</RootLayout.Header>
<RootLayout.MainContent>{children}</RootLayout.MainContent>
<RootLayout.Footer><AppFooter /></RootLayout.Footer>
<RootLayout.Footer>
<AppFooter />
</RootLayout.Footer>
</RootLayout.App>
```
+24 -4
View File
@@ -1,8 +1,27 @@
@reference "#reference.css";
/* Root — full-viewport flex row */
/*
Scroll contract: when the app frame is mounted, the document never
scrolls. The frame is exactly one viewport tall, the sidebar is planted
inside it, and `__main` is the one scrollable slot. Scoped with `:has()`
so surfaces without the frame (auth, error pages) keep normal document
scrolling.
Without this, `html`/`body` have no height, so any in-flow element that
outgrows the viewport scrolls the document and drags the sidebar with it.
*/
html:has(.opal-root-layout),
html:has(.opal-root-layout) body {
height: 100%;
overflow: hidden;
}
/* Root — full-viewport flex row. `h-dvh` (not `h-full`) so its height never
depends on the wrapper chain above it; `overflow-hidden` because the frame
itself never scrolls. The desktop shell clamps this below its title bar in
css/desktop-titlebar.css. */
.opal-root-layout {
@apply flex flex-row w-full h-full;
@apply flex flex-row w-full h-dvh overflow-hidden;
}
/* App — fills remaining flex space; flex-col so Header/Footer pin correctly */
@@ -10,9 +29,10 @@
@apply flex-1 flex flex-col overflow-hidden h-full w-full;
}
/* Main content — scrollable slot inside App */
/* Main content — the one scrollable slot. `min-h-0` so it shrinks to its
flex basis instead of being grown past the frame by its content. */
.opal-root-layout__main {
@apply flex-1 overflow-auto;
@apply flex-1 min-h-0 overflow-auto;
}
/* Panel — permanent column (width determined by child content) */
+4 -1
View File
@@ -19,8 +19,11 @@
SidebarRoot — desktop column
--------------------------------------------------------------------------- */
/* `h-full`, not `h-screen`: the root frame owns the viewport height, and a
column asserting `100vh` on its own is what let a leaf define the app's
geometry (and overflow the frame on mobile, where 100vh > 100dvh). */
.opal-sidebar-root__column {
@apply h-screen flex flex-col bg-background-tint-02 pb-2 shrink-0;
@apply h-full min-h-0 flex flex-col bg-background-tint-02 pb-2 shrink-0;
width: var(--sidebar-width-expanded);
transition: width 200ms ease-in-out;
}
+1 -8
View File
@@ -129,14 +129,7 @@ export const SlackBotTable = ({ slackBots }: { slackBots: SlackBot[] }) => {
<PageSelector
totalPages={Math.ceil(slackBots.length / NUM_IN_PAGE)}
currentPage={page}
onPageChange={(newPage) => {
setPage(newPage);
window.scrollTo({
top: 0,
left: 0,
behavior: "smooth",
});
}}
onPageChange={setPage}
/>
</div>
</div>
+1
View File
@@ -55,6 +55,7 @@ html.onyx-desktop > body {
*/
html.onyx-desktop .h-screen,
html.onyx-desktop .max-h-screen,
html.onyx-desktop .opal-root-layout,
html.onyx-desktop .opal-sidebar-root__column,
html.onyx-desktop .opal-sidebar-root__overlay[data-variant="mobile"],
html.onyx-desktop .opal-sidebar-root__overlay[data-variant="small"] {
@@ -338,7 +338,6 @@ const StandardAnswersTable = ({
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
shouldScroll={true}
/>
</div>
</>
-15
View File
@@ -34,10 +34,6 @@ const getPaginationOptions = (
return paginationOptions;
};
const scrollUp = () => {
setTimeout(() => window.scrollTo({ top: 0 }), 50);
};
type PageLinkProps = {
linkText: string | number;
pageChangeHandler?: () => void;
@@ -86,21 +82,14 @@ export interface PageSelectorProps {
currentPage: number;
totalPages: number;
onPageChange: (newPage: number) => void;
shouldScroll?: boolean;
}
export const PageSelector = ({
currentPage,
totalPages,
onPageChange,
shouldScroll = false,
}: PageSelectorProps) => {
const paginationOptions = getPaginationOptions(currentPage, totalPages);
const modifiedScrollUp = () => {
if (shouldScroll) {
scrollUp();
}
};
return (
<div style={{ display: "inline-block" }}>
@@ -109,7 +98,6 @@ export const PageSelector = ({
unclickable={currentPage === 1}
pageChangeHandler={() => {
onPageChange(Math.max(currentPage - 1, 1));
modifiedScrollUp();
}}
/>
{!paginationOptions.includes(1) && (
@@ -119,7 +107,6 @@ export const PageSelector = ({
active={currentPage === 1}
pageChangeHandler={() => {
onPageChange(1);
modifiedScrollUp();
}}
/>
<PageLink linkText="..." unclickable={true} />
@@ -136,7 +123,6 @@ export const PageSelector = ({
linkText={page}
pageChangeHandler={() => {
onPageChange(page);
modifiedScrollUp();
}}
/>
);
@@ -146,7 +132,6 @@ export const PageSelector = ({
unclickable={currentPage === totalPages}
pageChangeHandler={() => {
onPageChange(Math.min(currentPage + 1, totalPages));
modifiedScrollUp();
}}
/>
</div>