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..423a9c0828 --- /dev/null +++ b/packages/blazor-workspace/Tests/OkBlazor.Tests/Unit/Components/Fv/OkFvStickyHeaderTests.cs @@ -0,0 +1,68 @@ +using BlazorWorkspace.Testing.Unit; +using Bunit; +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..93bebf44a7 --- /dev/null +++ b/packages/ok-components/src/fv/sticky-header/index.ts @@ -0,0 +1,103 @@ +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 => { + 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(); + } + + 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..2a9690a2cf --- /dev/null +++ b/packages/ok-components/src/fv/sticky-header/tests/sticky-header.spec.ts @@ -0,0 +1,91 @@ +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('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('[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(); + 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> + `) +};