diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 5c8ef42..8cec09e 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -9,3 +9,4 @@ export { type RouterlessLinkHandlerContext, } from './lib/any-link/link-handler'; export { RouterLinkHandler, type RouterLinkHandlerContext } from './lib/any-link/router-link-handler'; +export { ID_GENERATOR_OPTIONS, IdGenerator, type IdGeneratorOptions } from './lib/id-generator'; diff --git a/libs/common/src/lib/id-generator.spec.ts b/libs/common/src/lib/id-generator.spec.ts new file mode 100644 index 0000000..db84e3e --- /dev/null +++ b/libs/common/src/lib/id-generator.spec.ts @@ -0,0 +1,55 @@ +import { APP_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ID_GENERATOR_OPTIONS, IdGenerator, type IdGeneratorOptions } from './id-generator'; + +describe('IdGenerator', () => { + afterEach(() => { + vi.restoreAllMocks(); + TestBed.resetTestingModule(); + }); + + function setup(options?: { + appId?: string; + generatorOptions?: IdGeneratorOptions; + randomValue?: number; + }): IdGenerator { + if (options?.randomValue !== undefined) { + vi.spyOn(Math, 'random').mockReturnValue(options.randomValue); + } + + TestBed.configureTestingModule({ + providers: [ + { provide: APP_ID, useValue: options?.appId ?? 'ng' }, + { provide: ID_GENERATOR_OPTIONS, useValue: options?.generatorOptions ?? {} }, + ], + }); + + return TestBed.inject(IdGenerator); + } + + it('generates ids with prefix, random infix, and an incrementing counter by default', () => { + const generator = setup({ randomValue: 0.5 }); + + const firstId = generator.getId('field'); + const secondId = generator.getId('field'); + + expect(firstId).toMatch(/^field-[0-9a-f]+-0$/); + expect(secondId).toMatch(/^field-[0-9a-f]+-1$/); + }); + + it('includes non-default app id in generated ids', () => { + const generator = setup({ appId: 'atlas', randomValue: 0.5 }); + + const id = generator.getId('field'); + + expect(id).toMatch(/^field-atlas-[0-9a-f]+-0$/); + }); + + it('omits random infix when randomize is disabled', () => { + const generator = setup({ generatorOptions: { randomize: false }, appId: 'atlas' }); + + const id = generator.getId('field'); + + expect(id).toBe('field-atlas-0'); + }); +}); diff --git a/libs/common/src/lib/id-generator.ts b/libs/common/src/lib/id-generator.ts new file mode 100644 index 0000000..c25b81c --- /dev/null +++ b/libs/common/src/lib/id-generator.ts @@ -0,0 +1,65 @@ +import { APP_ID, inject, Injectable, InjectionToken } from '@angular/core'; + +/** + * Configures how IDs are composed by {@link IdGenerator}. + */ +export interface IdGeneratorOptions { + /** + * Includes a random infix segment in generated IDs when enabled. (Enabled by default) + */ + randomize?: boolean; +} + +/** Maximum random value used to build the optional hexadecimal infix segment. */ +const INFIX_MAX = 0xffffff; + +/** Default configuration applied when no custom options are provided. */ +const DEFAULT_GENERATOR_OPTIONS: Required = { + randomize: true, +}; + +/** Dependency injection token for overriding {@link IdGenerator} options. */ +export const ID_GENERATOR_OPTIONS = new InjectionToken('ID_GENERATOR_OPTIONS', { + providedIn: 'root', + factory: () => DEFAULT_GENERATOR_OPTIONS, +}); + +/** + * Generates stable, incrementing DOM-safe IDs. + */ +@Injectable({ + providedIn: 'root', +}) +export class IdGenerator { + /** Angular application ID. */ + private readonly appId = inject(APP_ID); + + /** Effective generator options after merging defaults with user provided overrides. */ + private readonly options = { ...DEFAULT_GENERATOR_OPTIONS, ...inject(ID_GENERATOR_OPTIONS) }; + + /** Random hexadecimal segment reused across generated IDs when randomization is enabled. */ + private readonly infix = Math.floor(INFIX_MAX * Math.random()).toString(16); + + /** Monotonic counter that guarantees uniqueness within this service instance. */ + private counter = 0; + + /** + * Builds a unique ID using the provided prefix and generator configuration. + * + * @param prefix Prefix for the ID. + * @returns A hyphen-delimited identifier. + */ + getId(prefix: string): string { + const parts = [prefix]; + + if (this.appId !== 'ng') { + parts.push(this.appId); + } + if (this.options.randomize) { + parts.push(this.infix); + } + + parts.push(`${this.counter++}`); + return parts.join('-'); + } +} diff --git a/libs/design-system/section-header/ng-package.json b/libs/design-system/section-header/ng-package.json new file mode 100644 index 0000000..65195bb --- /dev/null +++ b/libs/design-system/section-header/ng-package.json @@ -0,0 +1,6 @@ +{ + "lib": { + "entryFile": "src/index.ts", + "styleIncludePaths": ["../src/sass"] + } +} diff --git a/libs/design-system/section-header/src/index.ts b/libs/design-system/section-header/src/index.ts new file mode 100644 index 0000000..90bdac5 --- /dev/null +++ b/libs/design-system/section-header/src/index.ts @@ -0,0 +1 @@ +export { SectionHeader } from './lib/section-header'; diff --git a/libs/design-system/section-header/src/lib/section-header.html b/libs/design-system/section-header/src/lib/section-header.html new file mode 100644 index 0000000..174bc80 --- /dev/null +++ b/libs/design-system/section-header/src/lib/section-header.html @@ -0,0 +1,15 @@ +@if (anchor()) { + + + + + +} + +
+ +
+ +@if (underlined()) { + +} diff --git a/libs/design-system/section-header/src/lib/section-header.scss b/libs/design-system/section-header/src/lib/section-header.scss new file mode 100644 index 0000000..d272d4e --- /dev/null +++ b/libs/design-system/section-header/src/lib/section-header.scss @@ -0,0 +1,96 @@ +@use 'sass:map'; +@use 'internal/token-utils'; + +$level-configs: ( + 1: ( + font: var(token-utils.sys-token(display-medium)), + link-offset: 7px, + margin: 0.5rem, + ), + 2: ( + font: var(token-utils.sys-token(display-small)), + link-offset: 2px, + margin: 0.375rem, + ), + 3: ( + font: var(token-utils.sys-token(headline-large)), + link-offset: 0, + margin: 0.5rem, + ), + 4: ( + font: var(token-utils.sys-token(headline-medium)), + link-offset: -2px, + margin: 0.25rem, + ), + 5: ( + font: var(token-utils.sys-token(headline-small)), + link-offset: -5px, + margin: 0.25rem, + ), + 6: ( + font: var(token-utils.sys-token(title-large)), + link-offset: -6px, + margin: 0.25rem, + ), +); + +$color-config: ( + divider-color: var(token-utils.sys-token(outline)), + text-color: var(token-utils.sys-token(on-background)), +); + +:host { + display: block; + margin: 0; + + .ang-section-header-link-container { + display: none; + vertical-align: top; + height: 0; + line-height: 1; + + @media (min-width: 640px) { + display: inline-block; + } + + .ang-section-header-link { + position: relative; + margin-left: calc(-2.5rem - 5px); + + .ang-section-header-icon { + visibility: hidden; + } + + &:focus-visible .ang-section-header-icon { + visibility: visible; + } + } + } + + .ang-section-header-content { + display: inline-block; + color: map.get($color-config, text-color); + } + + &:hover .ang-section-header-link-container .ang-section-header-link .ang-section-header-icon { + visibility: visible; + } + + mat-divider { + border-color: map.get($color-config, divider-color); + } + + @each $level, $config in $level-configs { + &h#{$level} { + font: map.get($config, font); + + .ang-section-header-link-container .ang-section-header-link { + top: map.get($config, link-offset); + } + + mat-divider { + margin-top: map.get($config, margin); + } + } + } +} diff --git a/libs/design-system/section-header/src/lib/section-header.spec.ts b/libs/design-system/section-header/src/lib/section-header.spec.ts new file mode 100644 index 0000000..0d8c55a --- /dev/null +++ b/libs/design-system/section-header/src/lib/section-header.spec.ts @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/angular'; +import { SectionHeader } from './section-header'; + +describe('SectionHeader', () => { + function setup({ anchor, underlined }: { anchor?: string; underlined?: boolean } = {}) { + const anchorBinding = anchor !== undefined ? ` [anchor]="'${anchor}'"` : ''; + const underlinedBinding = underlined !== undefined ? ` [underlined]="${underlined}"` : ''; + + return render(`

Section Title

`, { + imports: [SectionHeader], + }); + } + + it('renders projected heading content', async () => { + await setup(); + + expect(screen.getByRole('heading', { name: 'Section Title' })).toBeInTheDocument(); + }); + + it('renders an anchor link to the matching hash when anchor is provided', async () => { + const { container } = await setup({ anchor: 'docs' }); + + const link = container.querySelector('a.ang-section-header-link'); + const content = container.querySelector('.ang-section-header-content'); + + expect(link).toHaveAttribute('href', '#docs'); + expect(content).toHaveAttribute('id'); + expect(link).toHaveAttribute('aria-labelledby', content?.getAttribute('id')); + }); + + it('does not render an anchor link when anchor is not provided', async () => { + await setup(); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('renders a divider by default', async () => { + const { container } = await setup(); + + expect(container.querySelector('mat-divider')).toBeInTheDocument(); + }); + + it('does not render a divider when underlined is false', async () => { + const { container } = await setup({ underlined: false }); + + expect(container.querySelector('mat-divider')).not.toBeInTheDocument(); + }); +}); diff --git a/libs/design-system/section-header/src/lib/section-header.stories.ts b/libs/design-system/section-header/src/lib/section-header.stories.ts new file mode 100644 index 0000000..6fa9636 --- /dev/null +++ b/libs/design-system/section-header/src/lib/section-header.stories.ts @@ -0,0 +1,60 @@ +import { argsToTemplate, Meta, StoryObj } from '@storybook/angular'; +import { SectionHeader } from './section-header'; + +interface ExtraArgs { + level: number; + content: string; +} + +const meta: Meta = { + component: SectionHeader, + title: 'Design System/Section Header', + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/BCEJn9KCIbBJ5MzqnojKQp/AtlasNG-Components?node-id=2355-1046', + }, + }, + args: { + level: 1, + anchor: 'anchor', + content: 'Content Text', + underlined: true, + }, + argTypes: { + level: { + control: { type: 'number', min: 1, max: 6 }, + description: 'The heading level (1-6) to determine the appropriate HTML tag.', + }, + anchor: { + control: 'text', + description: 'The anchor ID for the section header link.', + }, + content: { + control: 'text', + description: 'The text content of the section header.', + }, + underlined: { + control: 'boolean', + description: 'Whether to display the underline.', + }, + }, + render: (args) => ({ + props: args, + styles: ['[angSectionHeader] { margin: 0 2rem; }'], + template: ` + Heading ${args.level} ${args.content} + `, + }), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const LongText: Story = { + args: { + content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris.', + }, +}; diff --git a/libs/design-system/section-header/src/lib/section-header.ts b/libs/design-system/section-header/src/lib/section-header.ts new file mode 100644 index 0000000..1263748 --- /dev/null +++ b/libs/design-system/section-header/src/lib/section-header.ts @@ -0,0 +1,26 @@ +import { booleanAttribute, ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { IdGenerator } from '@atlasng/common'; + +@Component({ + selector: + // eslint-disable-next-line @angular-eslint/component-selector + `h1[angSectionHeader], h2[angSectionHeader], h3[angSectionHeader], + h4[angSectionHeader], h5[angSectionHeader], h6[angSectionHeader]`, + imports: [MatDividerModule, MatIconModule, MatButtonModule], + templateUrl: './section-header.html', + styleUrl: './section-header.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SectionHeader { + /** Anchor for href */ + readonly anchor = input(); + + /** Whether to display the underline */ + readonly underlined = input(true, { transform: booleanAttribute }); + + /** Unique ID for the section header */ + protected readonly headerId = inject(IdGenerator).getId('ang-section-header'); +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 62dee4c..8cdf26f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -36,6 +36,7 @@ ], "@atlasng/design-system/indicators/no-results": ["./libs/design-system/indicators/no-results/src/index.ts"], "@atlasng/design-system/indicators/results": ["./libs/design-system/indicators/results/src/index.ts"], + "@atlasng/design-system/section-header": ["./libs/design-system/section-header/src/index.ts"], "@atlasng/design-system/text-link": ["./libs/design-system/text-link/src/index.ts"] } },