Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/prs/react/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export function App() {
<GoabxWorkSideMenuItem label="3407 Tabs Orientation" url="/features/3407-stack-on-mobile" />
<GoabxWorkSideMenuItem label="3398 Group open prop" url="/features/3398" />
<GoabxWorkSideMenuItem label="3478 Popover API Rewrite" url="/features/3478" />
<GoabxWorkSideMenuItem label="3347 Container Sticky Header" url="/features/3347" />
</GoabxWorkSideMenuGroup>
<GoabxWorkSideMenuItem icon="list" label="Everything" url="/everything" />
</>
Expand Down
2 changes: 2 additions & 0 deletions apps/prs/react/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import { Feat3398Route } from "./routes/features/feat3398";
import { Feat3478Route } from "./routes/features/feat3478";
import { Feat2885Route } from "./routes/features/feat2885";
import { Feat2885NavigationTabsRoute } from "./routes/features/feat2885-navigation-tabs";
import { Feat3347Route } from "./routes/features/feat3347";

const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);

Expand Down Expand Up @@ -187,6 +188,7 @@ root.render(
<Route path="features/v2-checkbox" element={<FeatV2CheckboxRoute />} />
<Route path="features/3398" element={<Feat3398Route />} />
<Route path="features/3478" element={<Feat3478Route />} />
<Route path="features/3347" element={<Feat3347Route />} />
<Route
path="features/2885-navigation-tabs"
element={<Feat2885NavigationTabsRoute />}
Expand Down
124 changes: 124 additions & 0 deletions apps/prs/react/src/routes/features/feat3347.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Feature #3347: Sticky header in Container component
*
* Added stickyHeader prop to Container. When true, the header stays visible
* using position:sticky and shows a scroll shadow when content has scrolled.
*/

import {
GoabBlock,
GoabText,
GoabDivider,
GoabDetails,
GoabContainer,
GoabButton,
GoabLink,
} from "@abgov/react-components";

const loremLines = Array.from(
{ length: 20 },
(_, i) =>
`Line ${i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt.`,
);

export function Feat3347Route() {
return (
<div>
<GoabText tag="h1" mt="m">
Feature #3347: Sticky header in Container
</GoabText>

<GoabBlock>
<GoabLink trailingIcon="open">
<a
href="https://github.com/GovAlta/ui-components/issues/3347"
target="_blank"
rel="noopener"
>
View on GitHub
</a>
</GoabLink>

<GoabDetails heading="Issue Description">
<GoabText tag="p">
Added <code>stickyHeader</code> prop to <code>GoabContainer</code>. When{" "}
<code>stickyHeader=true</code>, the container header uses{" "}
<code>position: sticky; top: 0</code> so it stays visible when scrolling the
page or its scrollable content. A scroll shadow appears under the header when
content has scrolled past the top.
</GoabText>
</GoabDetails>
</GoabBlock>

<GoabDivider mt="l" mb="l" />

<GoabText tag="h2">Test Cases</GoabText>

<GoabText tag="h3" mt="l">
Test 1: Sticky header with internal scroll (maxHeight)
</GoabText>
<GoabText tag="p">
Container has a fixed maxHeight so content scrolls inside it. The header should
remain visible and show a shadow when the content has been scrolled.
</GoabText>
<GoabContainer
type="interactive"
accent="thick"
maxHeight="300px"
stickyHeader
title="Sticky Header (maxHeight)"
actions={<GoabButton size="compact">Action</GoabButton>}
>
{loremLines.map((line, i) => (
<GoabText key={i} tag="p">
{line}
</GoabText>
))}
</GoabContainer>

<GoabDivider mt="l" mb="l" />

<GoabText tag="h3">Test 2: Sticky header on page scroll</GoabText>
<GoabText tag="p">
Container without a maxHeight. As you scroll the page the header should stick to
the top of the viewport.
</GoabText>
<GoabContainer
type="non-interactive"
accent="thick"
stickyHeader
title="Sticky Header (page scroll)"
actions={<GoabButton size="compact" type="secondary">Action</GoabButton>}
>
{loremLines.map((line, i) => (
<GoabText key={i} tag="p">
{line}
</GoabText>
))}
</GoabContainer>

<GoabDivider mt="l" mb="l" />

<GoabText tag="h3">Test 3: Without stickyHeader (default behaviour)</GoabText>
<GoabText tag="p">
For comparison — a container with maxHeight but without stickyHeader. Header is
already visible since the content scrolls below it.
</GoabText>
<GoabContainer
type="info"
accent="thick"
maxHeight="200px"
title="No Sticky Header"
actions={<GoabButton size="compact" type="tertiary">Action</GoabButton>}
>
{loremLines.map((line, i) => (
<GoabText key={i} tag="p">
{line}
</GoabText>
))}
</GoabContainer>
</div>
);
}

export default Feat3347Route;
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { By } from "@angular/platform-browser";
[ml]="ml"
[actions]="actions"
[title]="title"
[stickyHeader]="stickyHeader"
>
<ng-template #actions>
<goab-button (onClick)="onClick()">Save</goab-button>
Expand All @@ -55,6 +56,7 @@ class TestContainerComponent {
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
stickyHeader?: boolean;

onClick() {
/* do nothing */
Expand Down Expand Up @@ -115,4 +117,14 @@ describe("GoABContainer", () => {

expect(el?.innerHTML).toContain("Container content");
});

it("should set stickyheader attribute when stickyHeader is true", fakeAsync(() => {
component.stickyHeader = true;
fixture.detectChanges();
tick();
fixture.detectChanges();

const el = fixture.debugElement.query(By.css("goa-container")).nativeElement;
expect(el?.getAttribute("stickyheader")).toBe("true");
}));
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { GoabBaseComponent } from "../base.component";
[attr.maxwidth]="maxWidth"
[attr.minheight]="minHeight"
[attr.maxheight]="maxHeight"
[attr.stickyheader]="stickyHeader"
[attr.testid]="testId"
[attr.mt]="mt"
[attr.mb]="mb"
Expand Down Expand Up @@ -57,6 +58,7 @@ export class GoabContainer extends GoabBaseComponent implements OnInit {
@Input() maxWidth?: string;
@Input() minHeight?: string;
@Input() maxHeight?: string;
@Input() stickyHeader?: boolean;
@Input() title!: TemplateRef<any>;
@Input() actions!: TemplateRef<any>;

Expand Down
10 changes: 10 additions & 0 deletions libs/react-components/src/lib/container/container.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,14 @@ describe("Container", () => {
const el = container.querySelector("goa-container");
expect(el?.getAttribute("data-grid")).toBe("cell");
});

it("should set stickyHeader attribute", () => {
const { container } = render(
<GoabContainer stickyHeader={true}>
Container content
</GoabContainer>,
);
const el = container.querySelector("goa-container");
expect(el?.getAttribute("stickyheader")).toBe("true");
});
});
5 changes: 4 additions & 1 deletion libs/react-components/src/lib/container/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface WCProps extends Margins {
maxwidth?: string;
minheight?: string;
maxheight?: string;
stickyheader?: string;
testid?: string;
}

Expand All @@ -40,6 +41,7 @@ export interface GoabContainerProps extends Margins, DataAttributes {
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
stickyHeader?: boolean;
testId?: string;
}

Expand All @@ -48,14 +50,15 @@ export function GoabContainer({
title,
actions,
children,
stickyHeader,
...rest
}: GoabContainerProps): JSX.Element {
const _props = transformProps<WCProps>(rest, lowercase);

const headingContent = heading || title;

return (
<goa-container {..._props}>
<goa-container {..._props} stickyheader={stickyHeader ? "true" : undefined}>
{headingContent && <div slot="title">{headingContent}</div>}
{children}
{actions && <div slot="actions">{actions}</div>}
Expand Down
22 changes: 22 additions & 0 deletions libs/web-components/src/components/container/Container.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ describe("GoA Container", () => {
});
});

describe("Sticky header", () => {
it("should add the sticky class to the header when stickyHeader is true", async () => {
const baseElement = render(GoAContainer, {
testid: "container-test",
stickyHeader: true,
});
const container = await baseElement.findByTestId("container-test");
const header = container?.querySelector("header");
expect(header?.classList).toContain("sticky");
});

it("should not add the sticky class to the header when stickyHeader is false", async () => {
const baseElement = render(GoAContainer, {
testid: "container-test",
stickyHeader: false,
});
const container = await baseElement.findByTestId("container-test");
const header = container?.querySelector("header");
expect(header?.classList).not.toContain("sticky");
});
});

describe("Margins", () => {
it(`should add the margin`, async () => {
const baseElement = render(GoAContainer, {
Expand Down
50 changes: 48 additions & 2 deletions libs/web-components/src/components/container/Container.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
maxWidth: { type: "String", attribute: "maxwidth" },
minHeight: { type: "String", attribute: "minheight" },
maxHeight: { type: "String", attribute: "maxheight" },
stickyHeader: { type: "Boolean", attribute: "stickyheader" },
},
}}
/>
Expand All @@ -14,7 +15,7 @@
import { calculateMargin } from "../../common/styling";
import type { Spacing } from "../../common/styling";
import { ensureSlotExists, typeValidator } from "../../common/utils";
import { onMount } from "svelte";
import { onMount, onDestroy } from "svelte";

// Validator
const [Types, validateType] = typeValidator("Container type", [
Expand Down Expand Up @@ -60,6 +61,8 @@
export let minHeight = "";
/** Sets the maximum height of the container. */
export let maxHeight = "";
/** When true, keeps the header visible when the container content scrolls. */
export let stickyHeader: boolean = false;
/** Sets a data-testid attribute for automated testing. */
export let testid: string = "";
/** Top margin. */
Expand All @@ -74,6 +77,24 @@
// Private

let _contentEl: HTMLElement;
let _scrollPos: "top" | "middle" | "bottom" | null = null;

function handleContentScroll() {
if (!_contentEl) return;
const { scrollTop, scrollHeight, clientHeight } = _contentEl;
const hasScroll = scrollHeight > clientHeight;
if (!hasScroll) {
_scrollPos = null;
return;
}
if (scrollTop < 1) {
_scrollPos = "top";
} else if (Math.abs(scrollHeight - scrollTop - clientHeight) < 1) {
_scrollPos = "bottom";
} else {
_scrollPos = "middle";
}
}

// Hooks

Expand All @@ -84,6 +105,18 @@
validateWidth(width);

ensureSlotExists(_contentEl);

if (stickyHeader) {
_contentEl.addEventListener("scroll", handleContentScroll);
const hasScroll = _contentEl.scrollHeight > _contentEl.clientHeight;
_scrollPos = hasScroll ? "top" : null;
}
});

onDestroy(() => {
if (stickyHeader) {
_contentEl?.removeEventListener("scroll", handleContentScroll);
}
});
</script>

Expand All @@ -103,9 +136,10 @@
padding--${padding}
accent--${accent}
width--${width}
${_scrollPos === "middle" || _scrollPos === "bottom" ? "scrolled" : ""}
`}
>
<header class="heading--{accent}">
<header class="heading--{accent}" class:sticky={stickyHeader}>
{#if $$slots.title}
<div class="title">
<slot name="title" />
Expand Down Expand Up @@ -152,6 +186,7 @@
border: var(--goa-container-border);
border-top-left-radius: var(--goa-container-border-radius);
border-top-right-radius: var(--goa-container-border-radius);
flex-shrink: 0;
}

.content {
Expand Down Expand Up @@ -301,4 +336,15 @@
.width--content {
flex-grow: 0;
}

/* Sticky header */
header.sticky {
position: sticky;
top: 0;
z-index: 1;
}

.scrolled header.sticky {
box-shadow: 0 4px 4px -4px rgba(0, 0, 0, 0.2);
}
</style>
Loading