Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
7 changes: 7 additions & 0 deletions change/@ni-ok-react-98ed5700-6c71-4a8c-9a21-da50c968ec53.json
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "../../../../../node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "public-api.ts"
}
}
Original file line number Diff line number Diff line change
@@ -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<FvStickyHeader>) {}
}
Original file line number Diff line number Diff line change
@@ -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 { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './ok-fv-sticky-header.directive';
export * from './ok-fv-sticky-header.module';
Original file line number Diff line number Diff line change
@@ -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: `
<ok-fv-sticky-header #stickyHeader>
<header slot="header">Primary header</header>
<div slot="sticky-header">Sticky header</div>
</ok-fv-sticky-header>
`,
standalone: false
})
class TestHostComponent {
@ViewChild('stickyHeader', { read: OkFvStickyHeaderDirective }) public directive: OkFvStickyHeaderDirective;
@ViewChild('stickyHeader', { read: ElementRef }) public elementRef: ElementRef<FvStickyHeader>;
}

let fixture: ComponentFixture<TestHostComponent>;

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');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@namespace OkBlazor
<ok-fv-sticky-header @attributes="AdditionalAttributes">
@if (Header is not null)
{
<div slot="header">@Header</div>
}
@if (StickyHeader is not null)
{
<div slot="sticky-header">@StickyHeader</div>
}
</ok-fv-sticky-header>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Components;

namespace OkBlazor;

public partial class OkFvStickyHeader : ComponentBase
{
/// <summary>
/// The primary header rendered in the normal document flow.
/// </summary>
[Parameter]
public RenderFragment? Header { get; set; }

/// <summary>
/// The alternate header rendered in a sticky overlay after the primary header leaves the viewport.
/// </summary>
[Parameter]
public RenderFragment? StickyHeader { get; set; }

/// <summary>
/// Any additional attributes that did not match known properties.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object>? AdditionalAttributes { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using BlazorWorkspace.Testing.Unit;
using Bunit;
using Microsoft.AspNetCore.Components.Rendering;
using Xunit;

namespace OkBlazor.Tests.Unit.Components;

/// <summary>
/// Tests for <see cref="OkFvStickyHeader"/>.
/// </summary>
public class OkFvStickyHeaderTests : BunitTestBase
{
[Fact]
public void OkFvStickyHeader_Render_HasStickyHeaderMarkup()
{
var component = Render<OkFvStickyHeader>();

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<OkFvStickyHeader>(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<OkFvStickyHeader>();

Assert.Empty(component.FindAll("[slot]"));
}

[Fact]
public void OkFvStickyHeader_SupportsAdditionalAttributes()
{
var exception = Record.Exception(() => Render<OkFvStickyHeader>(parameters => parameters.AddUnmatched("class", "foo")));

Assert.Null(exception);
}
}
1 change: 1 addition & 0 deletions packages/ok-components/src/fv/all-fv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
103 changes: 103 additions & 0 deletions packages/ok-components/src/fv/sticky-header/index.ts
Original file line number Diff line number Diff line change
@@ -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<Element, boolean>();
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';
22 changes: 22 additions & 0 deletions packages/ok-components/src/fv/sticky-header/styles.ts
Original file line number Diff line number Diff line change
@@ -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;
}
`;
23 changes: 23 additions & 0 deletions packages/ok-components/src/fv/sticky-header/template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { html, ref } from '@ni/fast-element';
import type { FvStickyHeader } from '.';

export const template = html<FvStickyHeader>`
<div class="header" part="header">
<slot
name="header"
${ref('headerSlot')}
@slotchange="${x => x.handleHeaderSlotChange()}"
></slot>
</div>
<div
class="sticky-header"
part="sticky-header"
?hidden="${x => !x.showStickyHeader}"
>
<slot
name="sticky-header"
${ref('stickyHeaderSlot')}
@slotchange="${(x, c) => x.handleStickyHeaderSlotChange(c.event)}"
></slot>
</div>
`;
Loading