Skip to content

spike(#3347): Add StickyContainer component#3619

Draft
Copilot wants to merge 3 commits intodevfrom
copilot/add-goa-sticky-container-component
Draft

spike(#3347): Add StickyContainer component#3619
Copilot wants to merge 3 commits intodevfrom
copilot/add-goa-sticky-container-component

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 20, 2026

New goa-sticky-container web 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-container component 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

Slot Description
header Optional sticky header (border appears when scrolled away from top)
(default) Scrollable content with inset scroll-indicator shadows
footer Optional sticky footer (border appears when scrolled away from bottom)
Prop Default Description
height "100%" Any valid CSS height (e.g. "400px", "100vh"). Applied directly to the host element.
testid / testId For automated testing

React usage:

<GoabStickyContainer
  height="100%"
  header={<SearchBar />}
  footer={selected.length > 0 ? <BulkActions count={selected.length} /> : null}
>
  <CaseList items={cases} />
</GoabStickyContainer>

Implementation

  • Height constraint: The height prop is applied directly to the custom-element host via shadowRoot.host.style.height in onMount/reactive statements. The React and Angular wrappers also set style={{ height }} / [style.height]="height" on the host element so the shadow-DOM flex layout is constrained before JS runs.
  • Layout: :host is the flex container (display: flex; flex-direction: column; overflow: hidden) — header/footer flex: 0 0 auto, content flex: 1 1 auto; overflow-y: auto; min-height: 0. No inner wrapper div; avoids the height: 100% referencing an unconstrained parent.
  • Scroll feedback: same inset shadow pattern as Drawer v2 / PushDrawer; header/footer borders transition in based on scroll position.
  • Accessibility: header/footer rendered as <section aria-label="...">, content as role="region".
  • CSS tokens: --goa-sticky-container-bg-color, --goa-sticky-container-header-bg-color, --goa-sticky-container-footer-bg-color for theming.
  • All three framework targets: Svelte (web component), React wrapper (GoabStickyContainer), Angular wrapper (GoabStickyContainer).

Make sure that you've checked the boxes below before you submit the PR

  • I have read and followed the setup steps
  • I have created necessary unit tests
  • I have tested the functionality in both React and Angular.

Steps needed to test

  1. npm run serve:prs:react → navigate to 3347 Sticky Container in the side menu.
  2. Verify four test cases: header+footer, header-only, footer-only, content-only.
  3. Each container should be visually bounded — content must not spill outside the border.
  4. Scroll each list — confirm scroll shadows appear on the content area and header/footer borders appear/disappear correctly.
  5. Check footer visibility is conditional (appears only when items are selected in Test 1).
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

  • Issue: Create reusable sticky header/footer component #3347
  • Goal: Provide a single component (to avoid shadow DOM composition issues) that supports:
    • an optional sticky header area
    • a scrollable content/body area
    • an optional sticky footer area
  • The component should use the existing Drawer (v2) patterns as reference for layout and scrolling behavior.

Component requirements

  1. New web component
  • Custom element tag: goa-sticky-container
  • Location: follow repo conventions under libs/web-components/src/components/sticky-container/ (or the most consistent existing pattern for new components).
  • Provide Svelte implementation with <svelte:options customElement={{ tag: "goa-sticky-container", props: ... }} />.
  1. Slots
  • Optional named slot "header" for sticky header content.
  • Default slot for scrollable main body content.
  • Optional named slot "footer" for sticky footer content.
  1. Props
  • height (string, reflected): defaults to "100%"; sets the overall component height (fills parent by default, but can be overridden).
  • offset (string, reflected): defaults to "0px"; applies to sticky header positioning (top offset). This supports cases where there is an existing fixed header inside the scroll region.
  • testid (string, reflected) if consistent with other components.
  1. Layout & behavior
  • Use flex column layout similar to Drawer v2:
    • header region: flex: 0 0 auto; position: sticky; top: offset; z-index appropriately; background set so content doesn’t bleed underneath.
    • body scroll region: flex: 1 1 auto; min-height: 0; overflow: auto.
    • footer region: flex: 0 0 auto; position: sticky; bottom: 0; z-index appropriately; background set.
  • Ensure keyboard focus is never hidden behind sticky regions:
    • Measure rendered header/footer heights and apply scroll-padding-top / scroll-padding-bottom to the scroll container.
    • scroll-padding-top must include header height + offset.
    • Recommended approach: use ResizeObserver on header/footer wrappers to update CSS variables and/or inline style.
  • Do not introduce focus trapping; tab order must remain natural.
  1. Visual behavior
  • Add borders/dividers for header/footer consistent with existing UI patterns (see Drawer v2):
    • Header bottom border appears when content is scrolled away from top.
    • Footer top border appears when content is not scrolled to bottom.
    • Implement via scroll position tracking on the scroll container.
  1. Docs
  • Create a component docs page per repo conventions demonstrating:
    • header-only usage
    • footer-only usage
    • header+footer usage (matching workspace demo intent: search toolbar at top; bulk actions at bottom)
    • usage of height and offset props
  1. Tests
  • Add/extend tests per repo conventions (at minimum: basic render, slots rendering, sticky behavior hooks like setting scroll-padding vars when header/footer present; any existing component test patterns).

Acceptance criteria mapping (must be satisfied)

  • A reusable component exists for sticky header and sticky footer regions.
  • Behaviour matches workspace demo intent (search toolbar at top; bulk actions at bottom).
  • Works inside a scroll container without requiring consumers to write custom CSS.
  • Create component docs page.

Implementation notes / reference

  • Use Drawer.svelte v2 layout patterns for scroll container sizing (flex + min-height:0 + overflow-y:auto) and scroll position detection.
  • Avoid reliance on consumers for CSS setup.

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:

  • Sticky header using position: sticky and flex: 0 0 auto
  • Scrolling content area with flex: 1 1 auto and overflow-y: auto
  • Two slots for header and content

Would 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.

…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
Copilot AI changed the title [WIP] Add reusable sticky container web component feat(#3347): Add GoabStickyContainer component with sticky header/footer and scrollable body Mar 20, 2026
Copilot AI requested a review from bdfranck March 20, 2026 17:11
@bdfranck
Copy link
Copy Markdown
Collaborator

@copilot I don't see a scrolling container in the examples. Instead, I see the content spilling out of the container.
image

…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
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 20, 2026

@copilot I don't see a scrolling container in the examples. Instead, I see the content spilling out of the container.
<img width="1706" height="1102" alt="image" src="https://github...

Fixed in d522554.

Root cause: the inner wrapper div had height: 100% which referenced the unconstrained shadow host (goa-sticky-container), causing it to auto-size to all content.

Fix:

  • Removed the inner wrapper div — :host is now the flex container (display: flex; flex-direction: column; overflow: hidden)
  • height prop is applied directly to the host element via shadowRoot.host.style.height in onMount/reactive statements
  • React wrapper also sets style={{ height }} on <goa-sticky-container> so the host is constrained before JS runs (Angular does the same with [style.height]="height")

The content now scrolls within the fixed-height host, with the header/footer remaining sticky above and below it.

@bdfranck bdfranck changed the title feat(#3347): Add GoabStickyContainer component with sticky header/footer and scrollable body spike(#3347): Add GoabStickyContainer component with sticky header/footer and scrollable body Mar 20, 2026
@bdfranck bdfranck requested a review from Copilot March 20, 2026 18:30
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +1 to +12
<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>
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +30
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");
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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();

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +57
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();
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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();

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +47

return (
<goa-sticky-container height={height} testid={testId} style={hostStyle} {...rest}>
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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}
>

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +21
<goa-sticky-container
[attr.height]="height"
[attr.testid]="testId"
[style.height]="height"
>
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +48
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>}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
@bdfranck bdfranck linked an issue Mar 23, 2026 that may be closed by this pull request
@bdfranck bdfranck changed the title spike(#3347): Add GoabStickyContainer component with sticky header/footer and scrollable body spike(#3347): Add StickyContainer component Mar 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create reusable sticky header/footer component

3 participants