Conversation
…ter and scrollable body Closes #3347 Co-authored-by: bdfranck <1479091+bdfranck@users.noreply.github.com> Agent-Logs-Url: https://github.com/GovAlta/ui-components/sessions/cc6eb474-4f81-4e4a-bd5e-502aa213304e
…ntainer
The inner `.sticky-container` div's `height: 100%` was referencing the
unconstrained shadow host, causing items to spill out. Fix:
- Remove the outer wrapper div; make `:host` the flex container
- Apply the `height` prop directly to the host element via `shadowRoot.host`
in `onMount` / reactive statements
- React wrapper also sets `style={{ height }}` on `<goa-sticky-container>`
so the host is constrained before JS runs
- Angular wrapper adds `[style.height]="height"` for the same reason
- Update Svelte tests to match new DOM structure
Co-authored-by: bdfranck <1479091+bdfranck@users.noreply.github.com>
Agent-Logs-Url: https://github.com/GovAlta/ui-components/sessions/960188a6-afbe-4529-add8-db55f7fa7f7a
Fixed in d522554. Root cause: the inner wrapper div had Fix:
The content now scrolls within the fixed-height host, with the header/footer remaining sticky above and below it. |
There was a problem hiding this comment.
Pull request overview
Adds a new goa-sticky-container web component to the design-system component set, plus Angular/React wrappers and a PRs playground route to demonstrate sticky header/footer regions around a scrollable body.
Changes:
- Implement
goa-sticky-container(Svelte custom element) with optional header/footer slots and scroll-position-based shadows/borders. - Add React and Angular wrapper components exporting
GoabStickyContainer. - Add unit tests and a React PRs demo route for issue #3347.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| libs/web-components/src/index.ts | Exports the new StickyContainer web component from the web-components package. |
| libs/web-components/src/components/sticky-container/StickyContainer.svelte | Implements the sticky container custom element layout and scroll feedback behavior. |
| libs/web-components/src/components/sticky-container/StickyContainerWrapper.test.svelte | Adds a Svelte test wrapper for the component (currently not instantiating the Svelte component). |
| libs/web-components/src/components/sticky-container/StickyContainer.spec.ts | Adds Svelte unit tests for slots, testid, and height handling (contains async assertion issues). |
| libs/react-components/src/lib/sticky-container/sticky-container.tsx | Adds React wrapper component for <goa-sticky-container>. |
| libs/react-components/src/lib/sticky-container/sticky-container.spec.tsx | Adds React unit tests for slot projection, height, and attributes. |
| libs/react-components/src/index.ts | Re-exports the new React wrapper. |
| libs/angular-components/src/lib/components/sticky-container/sticky-container.ts | Adds Angular standalone wrapper for <goa-sticky-container>. |
| libs/angular-components/src/lib/components/sticky-container/sticky-container.spec.ts | Adds Angular unit tests for attribute/style binding and slot projection. |
| libs/angular-components/src/lib/components/index.ts | Re-exports the new Angular wrapper. |
| apps/prs/react/src/routes/features/feat3347.tsx | Adds a PRs playground page demonstrating 4 usage scenarios. |
| apps/prs/react/src/main.tsx | Registers the new PRs route. |
| apps/prs/react/src/app/app.tsx | Adds the new route to the PRs side menu. |
| <svelte:options customElement="test-sticky-container" /> | ||
|
|
||
| <script lang="ts"> | ||
| export let header: string = ""; | ||
| export let content: string = ""; | ||
| export let footer: string = ""; | ||
| export let height: string = "400px"; | ||
| </script> | ||
|
|
||
| <goa-sticky-container {height}> | ||
| {#if header} | ||
| <div slot="header" class="header-content">{header}</div> |
There was a problem hiding this comment.
The test wrapper renders a raw tag without importing/instantiating StickyContainer.svelte, so the tests using this wrapper won't exercise the component logic (e.g., $$slots checks, scroll handlers, data-testid mapping). Update the wrapper to import StickyContainer.svelte and render that component (similar to DrawerWrapper.test.svelte) so unit tests validate the real implementation.
| import GoAStickyContainerWrapper from "./StickyContainerWrapper.test.svelte"; | ||
| import GoAStickyContainer from "./StickyContainer.svelte"; | ||
| import { it, describe, expect } from "vitest"; | ||
|
|
||
| describe("GoA StickyContainer", () => { | ||
| it("renders the default slot content", async () => { | ||
| render(GoAStickyContainerWrapper, { | ||
| content: "Body content here", | ||
| }); | ||
| const content = document.querySelector(".body-content"); | ||
| expect(content?.textContent).toContain("Body content here"); | ||
| }); | ||
|
|
||
| it("renders header slot content when provided", async () => { | ||
| render(GoAStickyContainerWrapper, { | ||
| header: "Sticky header", | ||
| content: "Body content", | ||
| }); | ||
| const header = document.querySelector(".header-content"); | ||
| expect(header?.textContent).toContain("Sticky header"); | ||
| }); | ||
|
|
||
| it("renders footer slot content when provided", async () => { | ||
| render(GoAStickyContainerWrapper, { | ||
| footer: "Sticky footer", | ||
| content: "Body content", | ||
| }); | ||
| const footer = document.querySelector(".footer-content"); | ||
| expect(footer?.textContent).toContain("Sticky footer"); |
There was a problem hiding this comment.
The header/footer/default-slot assertions here rely on StickyContainerWrapper.test.svelte, which currently doesn't instantiate StickyContainer.svelte. As a result these tests can pass even if StickyContainer's slot conditionals and layout logic are broken. Consider rendering StickyContainer.svelte directly with slots (or fix the wrapper to import StickyContainer) so these assertions validate the component behavior.
| import GoAStickyContainerWrapper from "./StickyContainerWrapper.test.svelte"; | |
| import GoAStickyContainer from "./StickyContainer.svelte"; | |
| import { it, describe, expect } from "vitest"; | |
| describe("GoA StickyContainer", () => { | |
| it("renders the default slot content", async () => { | |
| render(GoAStickyContainerWrapper, { | |
| content: "Body content here", | |
| }); | |
| const content = document.querySelector(".body-content"); | |
| expect(content?.textContent).toContain("Body content here"); | |
| }); | |
| it("renders header slot content when provided", async () => { | |
| render(GoAStickyContainerWrapper, { | |
| header: "Sticky header", | |
| content: "Body content", | |
| }); | |
| const header = document.querySelector(".header-content"); | |
| expect(header?.textContent).toContain("Sticky header"); | |
| }); | |
| it("renders footer slot content when provided", async () => { | |
| render(GoAStickyContainerWrapper, { | |
| footer: "Sticky footer", | |
| content: "Body content", | |
| }); | |
| const footer = document.querySelector(".footer-content"); | |
| expect(footer?.textContent).toContain("Sticky footer"); | |
| import GoAStickyContainer from "./StickyContainer.svelte"; | |
| import { it, describe, expect } from "vitest"; | |
| describe("GoA StickyContainer", () => { | |
| it("renders the default slot content", async () => { | |
| const { getByText } = render(GoAStickyContainer, { | |
| slots: { | |
| default: "Body content here", | |
| }, | |
| }); | |
| const content = getByText("Body content here"); | |
| expect(content).toBeTruthy(); | |
| }); | |
| it("renders header slot content when provided", async () => { | |
| const { getByText } = render(GoAStickyContainer, { | |
| slots: { | |
| header: "Sticky header", | |
| default: "Body content", | |
| }, | |
| }); | |
| const header = getByText("Sticky header"); | |
| expect(header).toBeTruthy(); | |
| }); | |
| it("renders footer slot content when provided", async () => { | |
| const { getByText } = render(GoAStickyContainer, { | |
| slots: { | |
| footer: "Sticky footer", | |
| default: "Body content", | |
| }, | |
| }); | |
| const footer = getByText("Sticky footer"); | |
| expect(footer).toBeTruthy(); |
| const { findByTestId } = render(GoAStickyContainer, { | ||
| testid: "sticky-height-test", | ||
| height: "500px", | ||
| }); | ||
| expect(findByTestId("sticky-height-test")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("renders with the default height prop without errors", () => { | ||
| const { findByTestId } = render(GoAStickyContainer, { | ||
| testid: "sticky-default-height", | ||
| }); | ||
| expect(findByTestId("sticky-default-height")).toBeTruthy(); |
There was a problem hiding this comment.
These assertions use findByTestId() without awaiting it. Since findByTestId returns a Promise, the expectations will always pass regardless of whether the element exists. Use getByTestId for synchronous queries, or await findByTestId before asserting.
| const { findByTestId } = render(GoAStickyContainer, { | |
| testid: "sticky-height-test", | |
| height: "500px", | |
| }); | |
| expect(findByTestId("sticky-height-test")).toBeTruthy(); | |
| }); | |
| it("renders with the default height prop without errors", () => { | |
| const { findByTestId } = render(GoAStickyContainer, { | |
| testid: "sticky-default-height", | |
| }); | |
| expect(findByTestId("sticky-default-height")).toBeTruthy(); | |
| const { getByTestId } = render(GoAStickyContainer, { | |
| testid: "sticky-height-test", | |
| height: "500px", | |
| }); | |
| expect(getByTestId("sticky-height-test")).toBeTruthy(); | |
| }); | |
| it("renders with the default height prop without errors", () => { | |
| const { getByTestId } = render(GoAStickyContainer, { | |
| testid: "sticky-default-height", | |
| }); | |
| expect(getByTestId("sticky-default-height")).toBeTruthy(); |
|
|
||
| return ( | ||
| <goa-sticky-container height={height} testid={testId} style={hostStyle} {...rest}> |
There was a problem hiding this comment.
The {...rest} spread comes after style={hostStyle}, so a consumer-provided style in rest will override (and potentially remove) the height style derived from the height prop. Merge styles explicitly (and/or spread rest before style) so height remains applied when consumers pass additional inline styles.
| return ( | |
| <goa-sticky-container height={height} testid={testId} style={hostStyle} {...rest}> | |
| const { style: restStyle, ...restProps } = rest as React.HTMLAttributes<HTMLElement>; | |
| const mergedStyle = { | |
| ...(restStyle as React.CSSProperties), | |
| ...hostStyle, | |
| }; | |
| return ( | |
| <goa-sticky-container | |
| height={height} | |
| testid={testId} | |
| {...restProps} | |
| style={mergedStyle} | |
| > |
| <goa-sticky-container | ||
| [attr.height]="height" | ||
| [attr.testid]="testId" | ||
| [style.height]="height" | ||
| > |
There was a problem hiding this comment.
This wrapper only applies [style.height] when the height input is provided. Since the web component defaults height to "100%", consider also binding a default (e.g., height || '100%') so the host is height-constrained before the custom element JS runs, matching the PR's stated implementation approach.
| export function GoabStickyContainer({ | ||
| header, | ||
| children, | ||
| footer, | ||
| height, | ||
| testId, | ||
| ...rest | ||
| }: GoabStickyContainerProps): JSX.Element { | ||
| // Set height as an inline style directly on the host element so the shadow-DOM | ||
| // flex layout is constrained before JavaScript runs. | ||
| const hostStyle = height ? ({ height } as React.CSSProperties) : undefined; | ||
|
|
||
| return ( | ||
| <goa-sticky-container height={height} testid={testId} style={hostStyle} {...rest}> | ||
| {header && <div slot="header">{header}</div>} |
There was a problem hiding this comment.
The wrapper sets the host inline height only when the height prop is explicitly provided. Given the web component's default height is "100%" and the PR description notes wrappers set style.height to constrain layout before JS runs, consider defaulting height to "100%" in the wrapper when it’s undefined so the initial render is constrained consistently.

New
goa-sticky-containerweb component providing sticky header/footer regions flanking a scrollable content area — enabling patterns like a persistent search toolbar at the top and a bulk-action bar at the bottom without consumer CSS.Before (the change)
No reusable component existed for sticky header/footer regions within a scroll container. Consumers had to write their own CSS to achieve this layout pattern.
After (the change)
A new
goa-sticky-containercomponent constrains its content to a fixed height, with the header pinned at the top, the body scrolling within the remaining space, and the footer pinned at the bottom.Component API
headerfooterheight"100%""400px","100vh"). Applied directly to the host element.testid/testIdReact usage:
Implementation
heightprop is applied directly to the custom-element host viashadowRoot.host.style.heightinonMount/reactive statements. The React and Angular wrappers also setstyle={{ height }}/[style.height]="height"on the host element so the shadow-DOM flex layout is constrained before JS runs.:hostis the flex container (display: flex; flex-direction: column; overflow: hidden) — header/footerflex: 0 0 auto, contentflex: 1 1 auto; overflow-y: auto; min-height: 0. No inner wrapper div; avoids theheight: 100%referencing an unconstrained parent.<section aria-label="...">, content asrole="region".--goa-sticky-container-bg-color,--goa-sticky-container-header-bg-color,--goa-sticky-container-footer-bg-colorfor theming.GoabStickyContainer), Angular wrapper (GoabStickyContainer).Make sure that you've checked the boxes below before you submit the PR
Steps needed to test
npm run serve:prs:react→ navigate to 3347 Sticky Container in the side menu.Original prompt
Create a new reusable web component in GovAlta/ui-components to support sticky UI regions within a scroll container, as requested in issue #3347.
Context
Component requirements
Acceptance criteria mapping (must be satisfied)
Implementation notes / reference
The following is the prior conversation context from the user's chat exploration (may be truncated):
User: Create a new Svelte component that has a sticky header and a scrolling content area. There should be two slots for adding content to the header and the main body. Use the drawer component for reference.
Assistant: I'll help you create a pull request for a new Svelte component with a sticky header and scrolling content area. Let me first look at the drawer component to understand the reference structure.
Assistant: Let me search for the drawer component to use as a reference:
Assistant: Now I can see the drawer component structure. Let me look for the complete Drawer.svelte file to better understand its implementation:
Assistant: Perfect! I can see the issue and understand what you want to create. Based on the drawer component reference and the issue requirements, I can help you create a new Svelte component with a sticky header and scrolling content area.
Looking at the issue, this is for creating a reusable sticky header/footer component for the GovAlta/ui-components repository. The drawer component shows a great pattern to follow with:
position: stickyandflex: 0 0 autoflex: 1 1 autoandoverflow-y: autoWould you like me to create a pull request...
This pull request was created from Copilot chat.
⌨️ Start Copilot coding agent tasks without leaving your editor — available in VS Code, Visual Studio, JetBrains IDEs and Eclipse.