From 6ef31be490e4e632a9d1f14f55be3360ed2870e1 Mon Sep 17 00:00:00 2001 From: Fred Visser <1458528+fredvisser@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:38:49 -0500 Subject: [PATCH 1/2] Add OK FV sticky-header component and wrappers --- ...-a3fa1a66-e706-4190-b074-923d78dde1a5.json | 7 + ...-919dec65-a16e-429d-a9b3-05b95b8ae1a9.json | 7 + ...-0d6660a8-22e7-449f-8175-0eb63bde8931.json | 7 + ...-98ed5700-6c71-4a8c-9a21-da50c968ec53.json | 7 + .../fv/sticky-header/ng-package.json | 6 + .../ok-fv-sticky-header.directive.ts | 17 ++ .../ok-fv-sticky-header.module.ts | 12 + .../ok-angular/fv/sticky-header/public-api.ts | 2 + .../ok-fv-sticky-header.directive.spec.ts | 47 ++++ .../Fv/StickyHeader/OkFvStickyHeader.razor | 11 + .../Fv/StickyHeader/OkFvStickyHeader.razor.cs | 24 ++ .../Components/Fv/OkFvStickyHeaderTests.cs | 69 +++++ packages/ok-components/src/fv/all-fv.ts | 1 + .../src/fv/sticky-header/index.ts | 100 +++++++ .../src/fv/sticky-header/styles.ts | 22 ++ .../src/fv/sticky-header/template.ts | 23 ++ .../sticky-header/tests/sticky-header.spec.ts | 75 ++++++ .../ok-react/src/fv/sticky-header/index.ts | 8 + .../ok/fv/sticky-header/fv-sticky-header.mdx | 26 ++ .../sticky-header/fv-sticky-header.stories.ts | 245 ++++++++++++++++++ 20 files changed, 716 insertions(+) create mode 100644 change/@ni-ok-angular-a3fa1a66-e706-4190-b074-923d78dde1a5.json create mode 100644 change/@ni-ok-blazor-919dec65-a16e-429d-a9b3-05b95b8ae1a9.json create mode 100644 change/@ni-ok-components-0d6660a8-22e7-449f-8175-0eb63bde8931.json create mode 100644 change/@ni-ok-react-98ed5700-6c71-4a8c-9a21-da50c968ec53.json create mode 100644 packages/angular-workspace/ok-angular/fv/sticky-header/ng-package.json create mode 100644 packages/angular-workspace/ok-angular/fv/sticky-header/ok-fv-sticky-header.directive.ts create mode 100644 packages/angular-workspace/ok-angular/fv/sticky-header/ok-fv-sticky-header.module.ts create mode 100644 packages/angular-workspace/ok-angular/fv/sticky-header/public-api.ts create mode 100644 packages/angular-workspace/ok-angular/fv/sticky-header/tests/ok-fv-sticky-header.directive.spec.ts create mode 100644 packages/blazor-workspace/OkBlazor/Source/Fv/StickyHeader/OkFvStickyHeader.razor create mode 100644 packages/blazor-workspace/OkBlazor/Source/Fv/StickyHeader/OkFvStickyHeader.razor.cs create mode 100644 packages/blazor-workspace/Tests/OkBlazor.Tests/Unit/Components/Fv/OkFvStickyHeaderTests.cs create mode 100644 packages/ok-components/src/fv/sticky-header/index.ts create mode 100644 packages/ok-components/src/fv/sticky-header/styles.ts create mode 100644 packages/ok-components/src/fv/sticky-header/template.ts create mode 100644 packages/ok-components/src/fv/sticky-header/tests/sticky-header.spec.ts create mode 100644 packages/react-workspace/ok-react/src/fv/sticky-header/index.ts create mode 100644 packages/storybook/src/ok/fv/sticky-header/fv-sticky-header.mdx create mode 100644 packages/storybook/src/ok/fv/sticky-header/fv-sticky-header.stories.ts diff --git a/change/@ni-ok-angular-a3fa1a66-e706-4190-b074-923d78dde1a5.json b/change/@ni-ok-angular-a3fa1a66-e706-4190-b074-923d78dde1a5.json new file mode 100644 index 0000000000..d71539f745 --- /dev/null +++ b/change/@ni-ok-angular-a3fa1a66-e706-4190-b074-923d78dde1a5.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Add the OK FV sticky-header component and framework wrappers", + "packageName": "@ni/ok-angular", + "email": "1458528+fredvisser@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-ok-blazor-919dec65-a16e-429d-a9b3-05b95b8ae1a9.json b/change/@ni-ok-blazor-919dec65-a16e-429d-a9b3-05b95b8ae1a9.json new file mode 100644 index 0000000000..71157762ed --- /dev/null +++ b/change/@ni-ok-blazor-919dec65-a16e-429d-a9b3-05b95b8ae1a9.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Add the OK FV sticky-header component and framework wrappers", + "packageName": "@ni/ok-blazor", + "email": "1458528+fredvisser@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-ok-components-0d6660a8-22e7-449f-8175-0eb63bde8931.json b/change/@ni-ok-components-0d6660a8-22e7-449f-8175-0eb63bde8931.json new file mode 100644 index 0000000000..8ce3ebdf55 --- /dev/null +++ b/change/@ni-ok-components-0d6660a8-22e7-449f-8175-0eb63bde8931.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Add the OK FV sticky-header component and framework wrappers", + "packageName": "@ni/ok-components", + "email": "1458528+fredvisser@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-ok-react-98ed5700-6c71-4a8c-9a21-da50c968ec53.json b/change/@ni-ok-react-98ed5700-6c71-4a8c-9a21-da50c968ec53.json new file mode 100644 index 0000000000..48952ef483 --- /dev/null +++ b/change/@ni-ok-react-98ed5700-6c71-4a8c-9a21-da50c968ec53.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Add the OK FV sticky-header component and framework wrappers", + "packageName": "@ni/ok-react", + "email": "1458528+fredvisser@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/angular-workspace/ok-angular/fv/sticky-header/ng-package.json b/packages/angular-workspace/ok-angular/fv/sticky-header/ng-package.json new file mode 100644 index 0000000000..d6e64898fa --- /dev/null +++ b/packages/angular-workspace/ok-angular/fv/sticky-header/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "public-api.ts" + } +} diff --git a/packages/angular-workspace/ok-angular/fv/sticky-header/ok-fv-sticky-header.directive.ts b/packages/angular-workspace/ok-angular/fv/sticky-header/ok-fv-sticky-header.directive.ts new file mode 100644 index 0000000000..c9988f11af --- /dev/null +++ b/packages/angular-workspace/ok-angular/fv/sticky-header/ok-fv-sticky-header.directive.ts @@ -0,0 +1,17 @@ +import { Directive, ElementRef } from '@angular/core'; +import type { FvStickyHeader } from '@ni/ok-components/dist/esm/fv/sticky-header'; +import { fvStickyHeaderTag } from '@ni/ok-components/dist/esm/fv/sticky-header'; + +export type { FvStickyHeader }; +export { fvStickyHeaderTag }; + +/** + * Directive to provide Angular integration for the sticky header. + */ +@Directive({ + selector: 'ok-fv-sticky-header', + standalone: false +}) +export class OkFvStickyHeaderDirective { + public constructor(private readonly elementRef: ElementRef) {} +} diff --git a/packages/angular-workspace/ok-angular/fv/sticky-header/ok-fv-sticky-header.module.ts b/packages/angular-workspace/ok-angular/fv/sticky-header/ok-fv-sticky-header.module.ts new file mode 100644 index 0000000000..0f137197b2 --- /dev/null +++ b/packages/angular-workspace/ok-angular/fv/sticky-header/ok-fv-sticky-header.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { OkFvStickyHeaderDirective } from './ok-fv-sticky-header.directive'; + +import '@ni/ok-components/dist/esm/fv/sticky-header'; + +@NgModule({ + declarations: [OkFvStickyHeaderDirective], + imports: [CommonModule], + exports: [OkFvStickyHeaderDirective] +}) +export class OkFvStickyHeaderModule { } diff --git a/packages/angular-workspace/ok-angular/fv/sticky-header/public-api.ts b/packages/angular-workspace/ok-angular/fv/sticky-header/public-api.ts new file mode 100644 index 0000000000..17a195589d --- /dev/null +++ b/packages/angular-workspace/ok-angular/fv/sticky-header/public-api.ts @@ -0,0 +1,2 @@ +export * from './ok-fv-sticky-header.directive'; +export * from './ok-fv-sticky-header.module'; diff --git a/packages/angular-workspace/ok-angular/fv/sticky-header/tests/ok-fv-sticky-header.directive.spec.ts b/packages/angular-workspace/ok-angular/fv/sticky-header/tests/ok-fv-sticky-header.directive.spec.ts new file mode 100644 index 0000000000..62eb27c02e --- /dev/null +++ b/packages/angular-workspace/ok-angular/fv/sticky-header/tests/ok-fv-sticky-header.directive.spec.ts @@ -0,0 +1,47 @@ +import { Component, ElementRef, provideZoneChangeDetection, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { FvStickyHeader } from '../ok-fv-sticky-header.directive'; +import { OkFvStickyHeaderDirective } from '../ok-fv-sticky-header.directive'; +import { OkFvStickyHeaderModule } from '../ok-fv-sticky-header.module'; + +describe('Ok fv sticky header', () => { + describe('module', () => { + it('custom element is defined', () => { + expect(customElements.get('ok-fv-sticky-header')).not.toBeUndefined(); + }); + }); + + @Component({ + template: ` + +
Primary header
+
Sticky header
+
+ `, + standalone: false + }) + class TestHostComponent { + @ViewChild('stickyHeader', { read: OkFvStickyHeaderDirective }) public directive: OkFvStickyHeaderDirective; + @ViewChild('stickyHeader', { read: ElementRef }) public elementRef: ElementRef; + } + + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TestHostComponent], + imports: [OkFvStickyHeaderModule], + providers: [provideZoneChangeDetection()] + }); + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + }); + + it('projects the primary and sticky headers', () => { + const element = fixture.componentInstance.elementRef.nativeElement; + + expect(fixture.componentInstance.directive).toBeDefined(); + expect(element.querySelector('[slot="header"]')?.textContent).toBe('Primary header'); + expect(element.querySelector('[slot="sticky-header"]')?.textContent).toBe('Sticky header'); + }); +}); diff --git a/packages/blazor-workspace/OkBlazor/Source/Fv/StickyHeader/OkFvStickyHeader.razor b/packages/blazor-workspace/OkBlazor/Source/Fv/StickyHeader/OkFvStickyHeader.razor new file mode 100644 index 0000000000..781bd21329 --- /dev/null +++ b/packages/blazor-workspace/OkBlazor/Source/Fv/StickyHeader/OkFvStickyHeader.razor @@ -0,0 +1,11 @@ +@namespace OkBlazor + + @if (Header is not null) + { +
@Header
+ } + @if (StickyHeader is not null) + { +
@StickyHeader
+ } +
diff --git a/packages/blazor-workspace/OkBlazor/Source/Fv/StickyHeader/OkFvStickyHeader.razor.cs b/packages/blazor-workspace/OkBlazor/Source/Fv/StickyHeader/OkFvStickyHeader.razor.cs new file mode 100644 index 0000000000..ef2e3d7313 --- /dev/null +++ b/packages/blazor-workspace/OkBlazor/Source/Fv/StickyHeader/OkFvStickyHeader.razor.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Components; + +namespace OkBlazor; + +public partial class OkFvStickyHeader : ComponentBase +{ + /// + /// The primary header rendered in the normal document flow. + /// + [Parameter] + public RenderFragment? Header { get; set; } + + /// + /// The alternate header rendered in a sticky overlay after the primary header leaves the viewport. + /// + [Parameter] + public RenderFragment? StickyHeader { get; set; } + + /// + /// Any additional attributes that did not match known properties. + /// + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary? AdditionalAttributes { get; set; } +} diff --git a/packages/blazor-workspace/Tests/OkBlazor.Tests/Unit/Components/Fv/OkFvStickyHeaderTests.cs b/packages/blazor-workspace/Tests/OkBlazor.Tests/Unit/Components/Fv/OkFvStickyHeaderTests.cs new file mode 100644 index 0000000000..201202b8a3 --- /dev/null +++ b/packages/blazor-workspace/Tests/OkBlazor.Tests/Unit/Components/Fv/OkFvStickyHeaderTests.cs @@ -0,0 +1,69 @@ +using BlazorWorkspace.Testing.Unit; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Xunit; + +namespace OkBlazor.Tests.Unit.Components; + +/// +/// Tests for . +/// +public class OkFvStickyHeaderTests : BunitTestBase +{ + [Fact] + public void OkFvStickyHeader_Render_HasStickyHeaderMarkup() + { + var component = Render(); + + Assert.NotNull(component.Find("ok-fv-sticky-header")); + } + + [Fact] + public void OkFvStickyHeader_Render_ProjectsNamedSlots() + { + void RenderHeader(RenderTreeBuilder builder) + { + builder.AddContent(0, "Primary header"); + } + + void RenderStickyHeader(RenderTreeBuilder builder) + { + builder.AddContent(0, "Sticky header"); + } + + var component = Render(parameters => parameters + .Add(parameter => parameter.Header, RenderHeader) + .Add(parameter => parameter.StickyHeader, RenderStickyHeader)); + + var slottedElements = component.FindAll("[slot]"); + Assert.Collection( + slottedElements, + element => + { + Assert.Equal("header", element.GetAttribute("slot")); + Assert.Contains("Primary header", element.TextContent); + }, + element => + { + Assert.Equal("sticky-header", element.GetAttribute("slot")); + Assert.Contains("Sticky header", element.TextContent); + }); + } + + [Fact] + public void OkFvStickyHeader_Render_DoesNotCreateEmptySlots() + { + var component = Render(); + + Assert.Empty(component.FindAll("[slot]")); + } + + [Fact] + public void OkFvStickyHeader_SupportsAdditionalAttributes() + { + var exception = Record.Exception(() => Render(parameters => parameters.AddUnmatched("class", "foo"))); + + Assert.Null(exception); + } +} diff --git a/packages/ok-components/src/fv/all-fv.ts b/packages/ok-components/src/fv/all-fv.ts index 27b0c990ea..c171621596 100644 --- a/packages/ok-components/src/fv/all-fv.ts +++ b/packages/ok-components/src/fv/all-fv.ts @@ -6,6 +6,7 @@ import './master-detail-list-item'; import './master-detail-list'; import './split-button'; import './split-button-anchor'; +import './sticky-header'; import './summary-panel'; import './summary-panel-tile'; import './search-input'; diff --git a/packages/ok-components/src/fv/sticky-header/index.ts b/packages/ok-components/src/fv/sticky-header/index.ts new file mode 100644 index 0000000000..06bfd675a7 --- /dev/null +++ b/packages/ok-components/src/fv/sticky-header/index.ts @@ -0,0 +1,100 @@ +import { observable } from '@ni/fast-element'; +import { DesignSystem, FoundationElement } from '@ni/fast-foundation'; +import { styles } from './styles'; +import { template } from './template'; + +declare global { + interface HTMLElementTagNameMap { + 'ok-fv-sticky-header': FvStickyHeader; + } +} + +/** + * Displays an alternate header in a sticky overlay after the primary header leaves the viewport. + */ +export class FvStickyHeader extends FoundationElement { + /** @internal */ + @observable + public hasHeader = false; + + /** @internal */ + @observable + public hasStickyHeader = false; + + /** @internal */ + @observable + public headerIsVisible = true; + + /** @internal */ + @observable + public showStickyHeader = false; + + /** @internal */ + public readonly headerSlot!: HTMLSlotElement; + + /** @internal */ + public readonly stickyHeaderSlot!: HTMLSlotElement; + + private readonly headerVisibility = new Map(); + private readonly headerIntersectionObserver = new IntersectionObserver(entries => { + this.handleHeaderIntersection(entries); + }); + + /** @internal */ + public override connectedCallback(): void { + super.connectedCallback(); + this.updateStickyHeaderContent(); + this.observeHeader(); + } + + /** @internal */ + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.headerIntersectionObserver.disconnect(); + } + + /** @internal */ + public handleHeaderSlotChange(): void { + this.observeHeader(); + } + + /** @internal */ + public handleStickyHeaderSlotChange(event: Event): void { + this.hasStickyHeader = (event.target as HTMLSlotElement).assignedElements({ flatten: true }).length > 0; + this.updateStickyHeaderContent(); + } + + /** @internal */ + public handleHeaderIntersection(entries: IntersectionObserverEntry[]): void { + entries.forEach(entry => this.headerVisibility.set(entry.target, entry.isIntersecting)); + this.headerIsVisible = Array.from(this.headerVisibility.values()).some(Boolean); + this.updateStickyHeaderContent(); + } + + private observeHeader(): void { + this.headerIntersectionObserver.disconnect(); + const headerElements = this.headerSlot.assignedElements({ flatten: true }); + this.hasHeader = headerElements.length > 0; + this.headerIsVisible = true; + this.headerVisibility.clear(); + headerElements.forEach(element => { + this.headerVisibility.set(element, true); + this.headerIntersectionObserver.observe(element); + }); + this.updateStickyHeaderContent(); + } + + private updateStickyHeaderContent(): void { + this.hasStickyHeader = this.stickyHeaderSlot.assignedElements({ flatten: true }).length > 0; + this.showStickyHeader = this.hasHeader && this.hasStickyHeader && !this.headerIsVisible; + } +} + +const okFvStickyHeader = FvStickyHeader.compose({ + baseName: 'fv-sticky-header', + template, + styles +}); + +DesignSystem.getOrCreate().withPrefix('ok').register(okFvStickyHeader()); +export const fvStickyHeaderTag = 'ok-fv-sticky-header'; diff --git a/packages/ok-components/src/fv/sticky-header/styles.ts b/packages/ok-components/src/fv/sticky-header/styles.ts new file mode 100644 index 0000000000..c9e25b1c93 --- /dev/null +++ b/packages/ok-components/src/fv/sticky-header/styles.ts @@ -0,0 +1,22 @@ +import { css } from '@ni/fast-element'; +import { elevation2BoxShadow, headerBackgroundColor } from '@ni/nimble-components/dist/esm/theme-provider/design-tokens'; +import { display } from '../../utilities/style/display'; + +export const styles = css` + ${display('block')} + + .sticky-header { + position: fixed; + inset-block-start: 0; + inset-inline: 0; + z-index: 1; + box-sizing: border-box; + width: 100%; + background-color: var(${headerBackgroundColor.cssCustomProperty}); + box-shadow: var(${elevation2BoxShadow.cssCustomProperty}); + } + + .sticky-header[hidden] { + display: none; + } +`; diff --git a/packages/ok-components/src/fv/sticky-header/template.ts b/packages/ok-components/src/fv/sticky-header/template.ts new file mode 100644 index 0000000000..c65e2df3e2 --- /dev/null +++ b/packages/ok-components/src/fv/sticky-header/template.ts @@ -0,0 +1,23 @@ +import { html, ref } from '@ni/fast-element'; +import type { FvStickyHeader } from '.'; + +export const template = html` +
+ +
+ +`; diff --git a/packages/ok-components/src/fv/sticky-header/tests/sticky-header.spec.ts b/packages/ok-components/src/fv/sticky-header/tests/sticky-header.spec.ts new file mode 100644 index 0000000000..54b12632ef --- /dev/null +++ b/packages/ok-components/src/fv/sticky-header/tests/sticky-header.spec.ts @@ -0,0 +1,75 @@ +import { html } from '@ni/fast-element'; +import { waitForUpdatesAsync } from '@ni/nimble-components/dist/esm/testing/async-helpers'; +import { waitAnimationFrame } from '@ni/nimble-components/dist/esm/utilities/testing/component'; +import { fixture, type Fixture } from '../../../utilities/tests/fixture'; +import { FvStickyHeader, fvStickyHeaderTag } from '..'; + +async function setup(): Promise> { + return await fixture(html` + <${fvStickyHeaderTag}> +
Primary header
+
Sticky header
+ + `); +} + +describe('FvStickyHeader', () => { + let element: FvStickyHeader; + let connect: () => Promise; + let disconnect: (() => Promise) | undefined; + + afterEach(async () => { + await disconnect?.(); + disconnect = undefined; + }); + + it('can construct an element instance', () => { + expect(document.createElement(fvStickyHeaderTag)).toBeInstanceOf(FvStickyHeader); + }); + + it('projects both header slots', async () => { + ({ element, connect, disconnect } = await setup()); + await connect(); + await waitForUpdatesAsync(); + + expect(element.querySelector('[slot="header"]')?.textContent).toBe('Primary header'); + expect(element.querySelector('[slot="sticky-header"]')?.textContent).toBe('Sticky header'); + }); + + it('hides the sticky header while the primary header is visible', async () => { + ({ element, connect, disconnect } = await setup()); + await connect(); + await waitForUpdatesAsync(); + + expect(element.shadowRoot?.querySelector('.sticky-header')?.hasAttribute('hidden')).toBeTrue(); + }); + + it('shows the sticky header after the primary header leaves the viewport', async () => { + ({ element, connect, disconnect } = await setup()); + await connect(); + await waitForUpdatesAsync(); + + const header = element.querySelector('[slot="header"]')!; + header.style.transform = 'translateY(-100px)'; + await waitAnimationFrame(); + await waitForUpdatesAsync(); + + expect(element.shadowRoot?.querySelector('.sticky-header')?.hasAttribute('hidden')).toBeFalse(); + }); + + it('hides the sticky header when the primary header returns to the viewport', async () => { + ({ element, connect, disconnect } = await setup()); + await connect(); + await waitForUpdatesAsync(); + + const header = element.querySelector('[slot="header"]')!; + header.style.transform = 'translateY(-100px)'; + await waitAnimationFrame(); + await waitForUpdatesAsync(); + header.style.transform = ''; + await waitAnimationFrame(); + await waitForUpdatesAsync(); + + expect(element.shadowRoot?.querySelector('.sticky-header')?.hasAttribute('hidden')).toBeTrue(); + }); +}); diff --git a/packages/react-workspace/ok-react/src/fv/sticky-header/index.ts b/packages/react-workspace/ok-react/src/fv/sticky-header/index.ts new file mode 100644 index 0000000000..886a89df11 --- /dev/null +++ b/packages/react-workspace/ok-react/src/fv/sticky-header/index.ts @@ -0,0 +1,8 @@ +'use client'; + +import { FvStickyHeader, fvStickyHeaderTag } from '@ni/ok-components/dist/esm/fv/sticky-header'; +import { wrap } from '../../utilities/react-wrapper'; + +export { fvStickyHeaderTag }; +export { type FvStickyHeader }; +export const OkFvStickyHeader = wrap(FvStickyHeader); diff --git a/packages/storybook/src/ok/fv/sticky-header/fv-sticky-header.mdx b/packages/storybook/src/ok/fv/sticky-header/fv-sticky-header.mdx new file mode 100644 index 0000000000..440beeb89b --- /dev/null +++ b/packages/storybook/src/ok/fv/sticky-header/fv-sticky-header.mdx @@ -0,0 +1,26 @@ +import { Canvas, Controls, Meta, Title } from '@storybook/addon-docs/blocks'; +import ComponentApisLink from '../../../docs/component-apis-link.mdx'; +import * as stickyHeaderStories from './fv-sticky-header.stories'; + + + + +A page-header container that replaces its primary header with a compact sticky overlay when the primary header scrolls out of the viewport. + +Use the `header` slot for the full page header and the `sticky-header` slot for its compact replacement. The sticky header stays hidden while the primary header is visible and is positioned at the top of the viewport after the primary header leaves the viewport. + +Both slots are optional, but the sticky overlay is only shown when both slots contain content. + +<Canvas of={stickyHeaderStories.fvStickyHeader} /> + +## Summary Panel Header + +This composition uses a full-size summary panel in the `header` slot and a compact summary panel in the `sticky-header` slot. + +<Canvas of={stickyHeaderStories.fvStickyHeaderWithSummaryPanel} /> + +## API + +<Controls of={stickyHeaderStories.fvStickyHeader} /> + +<ComponentApisLink /> diff --git a/packages/storybook/src/ok/fv/sticky-header/fv-sticky-header.stories.ts b/packages/storybook/src/ok/fv/sticky-header/fv-sticky-header.stories.ts new file mode 100644 index 0000000000..283a883a98 --- /dev/null +++ b/packages/storybook/src/ok/fv/sticky-header/fv-sticky-header.stories.ts @@ -0,0 +1,245 @@ +import type { Meta, StoryObj } from '@storybook/html-vite'; +import { html } from '@ni/fast-element'; +import { fvStickyHeaderTag } from '@ni/ok-components/dist/esm/fv/sticky-header'; +import { fvSummaryPanelTag } from '@ni/ok-components/dist/esm/fv/summary-panel'; +import { fvSummaryPanelTileTag } from '@ni/ok-components/dist/esm/fv/summary-panel-tile'; +import { + applicationBackgroundColor, + bodyFont, + bodyFontColor, + dividerBackgroundColor, + headerBackgroundColor, + standardPadding +} from '@ni/nimble-components/dist/esm/theme-provider/design-tokens'; +import { + apiCategory, + createUserSelectedThemeStory, + okWarning +} from '../../../utilities/storybook'; + +const storyStyles = ` + .sticky-header-story { + min-block-size: 1200px; + font: var(${bodyFont.cssCustomProperty}); + color: var(${bodyFontColor.cssCustomProperty}); + background: var(${applicationBackgroundColor.cssCustomProperty}); + } + + .sticky-header-story__primary { + box-sizing: border-box; + padding: 28px 32px 24px; + color: var(${bodyFontColor.cssCustomProperty}); + background: var(${headerBackgroundColor.cssCustomProperty}); + border-block-end: 1px solid var(${dividerBackgroundColor.cssCustomProperty}); + } + + .sticky-header-story__primary-content, + .sticky-header-story__body { + max-inline-size: 760px; + margin-inline: auto; + } + + .sticky-header-story__eyebrow { + margin: 0 0 8px; + color: var(${bodyFontColor.cssCustomProperty}); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .sticky-header-story__title { + margin: 0; + font-size: 28px; + line-height: 1.2; + } + + .sticky-header-story__metadata { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + margin: 12px 0 0; + color: var(${bodyFontColor.cssCustomProperty}); + } + + .sticky-header-story__sticky { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + box-sizing: border-box; + min-block-size: 56px; + padding: 12px 32px; + color: var(${bodyFontColor.cssCustomProperty}); + background: var(${headerBackgroundColor.cssCustomProperty}); + } + + .sticky-header-story__sticky-title { + font-weight: 600; + } + + .sticky-header-story__sticky-nav { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin: 0; + } + + .sticky-header-story__sticky-nav a { + color: inherit; + text-decoration: none; + } + + .sticky-header-story__sticky-nav a:first-child { + font-weight: 600; + text-decoration: underline; + text-underline-offset: 4px; + } + + .sticky-header-story__body { + padding: 48px 32px 240px; + } + + .sticky-header-story__section { + padding: var(${standardPadding.cssCustomProperty}) 0; + border-block-end: 1px solid var(${dividerBackgroundColor.cssCustomProperty}); + } + + .sticky-header-story__section:first-child { + padding-block-start: 0; + } + + .sticky-header-story__section:last-child { + border-block-end: 0; + } + + .sticky-header-story__section h2 { + margin: 0 0 12px; + font-size: 20px; + } + + .sticky-header-story__section p { + max-inline-size: 620px; + margin: 0; + line-height: 1.6; + } +`; + +const metadata: Meta = { + title: 'Ok/Fv Sticky Header', + render: createUserSelectedThemeStory(html` + ${okWarning({ + componentName: 'fv sticky header', + statusLink: './?path=/docs/component-status--docs#ok-components' + })} + <style class="code-hide">${storyStyles}</style> + <div class="sticky-header-story"> + <${fvStickyHeaderTag}> + <header slot="header" class="sticky-header-story__primary"> + <div class="sticky-header-story__primary-content"> + <p class="sticky-header-story__eyebrow">Test Monitor</p> + <h1 class="sticky-header-story__title">System validation overview</h1> + <p class="sticky-header-story__metadata"> + <span>Workspace: Lab Operations</span> + <span>Last updated 4 minutes ago</span> + </p> + </div> + </header> + <nav slot="sticky-header" class="sticky-header-story__sticky" aria-label="System validation sections"> + <span class="sticky-header-story__sticky-title">System validation overview</span> + <span class="sticky-header-story__sticky-nav"> + <a href="#runs">Runs</a> + <a href="#assets">Assets</a> + <a href="#activity">Activity</a> + </span> + </nav> + </${fvStickyHeaderTag}> + <main class="sticky-header-story__body"> + <section class="sticky-header-story__section" id="runs"> + <h2>Recent runs</h2> + <p>Review the latest validation runs, compare result trends, and open a run to inspect its individual steps.</p> + </section> + <section class="sticky-header-story__section" id="assets"> + <h2>Tracked assets</h2> + <p>Connected systems and instruments are ready for the next scheduled validation sequence.</p> + </section> + <section class="sticky-header-story__section" id="activity"> + <h2>Recent activity</h2> + <p>Operators approved three result sets and assigned two follow-up investigations to the lab operations queue.</p> + </section> + </main> + </div> + `), + argTypes: { + header: { + description: 'Primary page header rendered in the normal document flow.', + table: { category: apiCategory.slots }, + control: false + }, + stickyHeader: { + name: 'sticky-header', + description: 'Alternate header rendered in a fixed overlay after the primary header leaves the viewport.', + table: { category: apiCategory.slots }, + control: false + } + } +}; + +export default metadata; + +export const fvStickyHeader: StoryObj = {}; + +const summaryPanelTiles = html` + <${fvSummaryPanelTileTag} count="7" label="open items"></${fvSummaryPanelTileTag}> + <${fvSummaryPanelTileTag} count="39" label="pending reviews"></${fvSummaryPanelTileTag}> + <${fvSummaryPanelTileTag} count="5" label="active alerts" selected></${fvSummaryPanelTileTag}> + <${fvSummaryPanelTileTag} count="1" label="new task"></${fvSummaryPanelTileTag}> + <${fvSummaryPanelTileTag} count="12" label="saved queries"></${fvSummaryPanelTileTag}> + <${fvSummaryPanelTileTag} count="3" label="shared views"></${fvSummaryPanelTileTag}> +`; + +export const fvStickyHeaderWithSummaryPanel: StoryObj = { + render: createUserSelectedThemeStory(html` + ${okWarning({ + componentName: 'fv sticky header', + statusLink: './?path=/docs/component-status--docs#ok-components' + })} + <style class="code-hide">${storyStyles}</style> + <div class="sticky-header-story"> + <${fvStickyHeaderTag}> + <header slot="header" class="sticky-header-story__primary"> + <div class="sticky-header-story__primary-content"> + <p class="sticky-header-story__eyebrow">Test Monitor</p> + <h1 class="sticky-header-story__title">System validation overview</h1> + <p class="sticky-header-story__metadata"> + <span>Workspace: Lab Operations</span> + <span>Last updated 4 minutes ago</span> + </p> + <${fvSummaryPanelTag}> + ${summaryPanelTiles} + </${fvSummaryPanelTag}> + </div> + </header> + <div slot="sticky-header" class="sticky-header-story__sticky"> + <${fvSummaryPanelTag} size="compact"> + ${summaryPanelTiles} + </${fvSummaryPanelTag}> + </div> + </${fvStickyHeaderTag}> + <main class="sticky-header-story__body"> + <section class="sticky-header-story__section" id="runs"> + <h2>Recent runs</h2> + <p>Review the latest validation runs, compare result trends, and open a run to inspect its individual steps.</p> + </section> + <section class="sticky-header-story__section" id="assets"> + <h2>Tracked assets</h2> + <p>Connected systems and instruments are ready for the next scheduled validation sequence.</p> + </section> + <section class="sticky-header-story__section" id="activity"> + <h2>Recent activity</h2> + <p>Operators approved three result sets and assigned two follow-up investigations to the lab operations queue.</p> + </section> + </main> + </div> + `) +}; From 2fce36fc9fbba8c5eea375ade8dd4a746418f50f Mon Sep 17 00:00:00 2001 From: Fred Visser <1458528+fredvisser@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:20:53 -0500 Subject: [PATCH 2/2] Fix sticky header viewport detection --- .../Unit/Components/Fv/OkFvStickyHeaderTests.cs | 1 - .../ok-components/src/fv/sticky-header/index.ts | 5 ++++- .../fv/sticky-header/tests/sticky-header.spec.ts | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/blazor-workspace/Tests/OkBlazor.Tests/Unit/Components/Fv/OkFvStickyHeaderTests.cs b/packages/blazor-workspace/Tests/OkBlazor.Tests/Unit/Components/Fv/OkFvStickyHeaderTests.cs index 201202b8a3..423a9c0828 100644 --- a/packages/blazor-workspace/Tests/OkBlazor.Tests/Unit/Components/Fv/OkFvStickyHeaderTests.cs +++ b/packages/blazor-workspace/Tests/OkBlazor.Tests/Unit/Components/Fv/OkFvStickyHeaderTests.cs @@ -1,6 +1,5 @@ using BlazorWorkspace.Testing.Unit; using Bunit; -using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Xunit; diff --git a/packages/ok-components/src/fv/sticky-header/index.ts b/packages/ok-components/src/fv/sticky-header/index.ts index 06bfd675a7..93bebf44a7 100644 --- a/packages/ok-components/src/fv/sticky-header/index.ts +++ b/packages/ok-components/src/fv/sticky-header/index.ts @@ -66,7 +66,10 @@ export class FvStickyHeader extends FoundationElement { /** @internal */ public handleHeaderIntersection(entries: IntersectionObserverEntry[]): void { - entries.forEach(entry => this.headerVisibility.set(entry.target, entry.isIntersecting)); + entries.forEach(entry => { + const hasLeftViewport = !entry.isIntersecting && entry.boundingClientRect.top < 0; + this.headerVisibility.set(entry.target, !hasLeftViewport); + }); this.headerIsVisible = Array.from(this.headerVisibility.values()).some(Boolean); this.updateStickyHeaderContent(); } diff --git a/packages/ok-components/src/fv/sticky-header/tests/sticky-header.spec.ts b/packages/ok-components/src/fv/sticky-header/tests/sticky-header.spec.ts index 54b12632ef..2a9690a2cf 100644 --- a/packages/ok-components/src/fv/sticky-header/tests/sticky-header.spec.ts +++ b/packages/ok-components/src/fv/sticky-header/tests/sticky-header.spec.ts @@ -44,6 +44,22 @@ describe('FvStickyHeader', () => { expect(element.shadowRoot?.querySelector('.sticky-header')?.hasAttribute('hidden')).toBeTrue(); }); + it('keeps the sticky header hidden when the primary header is below the viewport', async () => { + ({ element, connect, disconnect } = await setup()); + await connect(); + await waitForUpdatesAsync(); + + const header = element.querySelector<HTMLElement>('[slot="header"]')!; + element.handleHeaderIntersection([{ + target: header, + isIntersecting: false, + boundingClientRect: { top: window.innerHeight + 100 } + } as unknown as IntersectionObserverEntry]); + await waitForUpdatesAsync(); + + expect(element.shadowRoot?.querySelector('.sticky-header')?.hasAttribute('hidden')).toBeTrue(); + }); + it('shows the sticky header after the primary header leaves the viewport', async () => { ({ element, connect, disconnect } = await setup()); await connect();