Skip to content
Open
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 libs/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
55 changes: 55 additions & 0 deletions libs/common/src/lib/id-generator.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
65 changes: 65 additions & 0 deletions libs/common/src/lib/id-generator.ts
Original file line number Diff line number Diff line change
@@ -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<IdGeneratorOptions> = {
randomize: true,
};

/** Dependency injection token for overriding {@link IdGenerator} options. */
export const ID_GENERATOR_OPTIONS = new InjectionToken<IdGeneratorOptions>('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('-');
}
}
6 changes: 6 additions & 0 deletions libs/design-system/section-header/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"lib": {
"entryFile": "src/index.ts",
"styleIncludePaths": ["../src/sass"]
}
}
1 change: 1 addition & 0 deletions libs/design-system/section-header/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SectionHeader } from './lib/section-header';
15 changes: 15 additions & 0 deletions libs/design-system/section-header/src/lib/section-header.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@if (anchor()) {
<span class="ang-section-header-link-container">
<a mat-icon-button class="ang-section-header-link" [attr.aria-labelledby]="headerId" [attr.href]="`#${anchor()}`">
<mat-icon class="ang-section-header-icon" fontIcon="link" />
</a>
</span>
}

<div class="ang-section-header-content" [attr.id]="headerId">
<ng-content />
</div>

@if (underlined()) {
<mat-divider />
}
96 changes: 96 additions & 0 deletions libs/design-system/section-header/src/lib/section-header.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
48 changes: 48 additions & 0 deletions libs/design-system/section-header/src/lib/section-header.spec.ts
Original file line number Diff line number Diff line change
@@ -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(`<h2 angSectionHeader${anchorBinding}${underlinedBinding}>Section Title</h2>`, {
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();
});
});
Original file line number Diff line number Diff line change
@@ -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<SectionHeader & ExtraArgs> = {
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.',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

},
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: `<h${args.level} angSectionHeader ${argsToTemplate(args, { exclude: ['content', 'level'] })}>
Heading ${args.level} ${args.content}
</h${args.level}>`,
}),
};

export default meta;
type Story = StoryObj<SectionHeader & ExtraArgs>;

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.',
},
};
26 changes: 26 additions & 0 deletions libs/design-system/section-header/src/lib/section-header.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

/** 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');
}
1 change: 1 addition & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
},
Expand Down