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
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/cookie-banner/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"]
}
}
7 changes: 7 additions & 0 deletions libs/design-system/cookie-banner/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
CookieBanner,
CookieBannerAction,
CookieBannerDescription,
CookieBannerLogo,
CookieBannerTitle,
} from './lib/cookie-banner';
44 changes: 44 additions & 0 deletions libs/design-system/cookie-banner/src/lib/cookie-banner.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
@if (opened()) {
<section
class="ang-cookie-banner-container"
animate.leave="ang-cookie-banner-animate-leave"
[animate.enter]="animateOpen() ? 'ang-cookie-banner-animate-enter' : null"
[attr.aria-labelledby]="titleId()"
>
<ng-content select="ang-cookie-banner-logo, [angCookieBannerLogo]">
<!-- TODO: do I need this? -->
<div class="ang-cookie-banner-logo-placeholder"></div>
</ng-content>

<div class="ang-cookie-banner-content">
<ng-content select="ang-cookie-banner-title, [angCookieBannerTitle]">
<h2 class="ang-cookie-banner-title" [id]="titleId()">Manage your privacy preferences</h2>
</ng-content>

<ng-content>
<p class="ang-cookie-banner-description">
Cookies and similar technologies are used to play videos and to improve this website.
</p>
</ng-content>

@if (privacyPolicy(); as policy) {
<a angTextLink class="ang-cookie-banner-privacy-policy" target="_blank" [angAnyLink]="policy">
Privacy policy
<mat-icon fontIcon="arrow_right_alt" />
</a>
}
</div>

<div class="ang-cookie-banner-actions">
<ng-content select="ang-cookie-banner-action, [angCookieBannerAction]">
<button matButton="filled" class="ang-cookie-banner-action" (click)="handleClick(allowAll)">Allow all</button>
<button matButton="filled" class="ang-cookie-banner-action" (click)="handleClick(allowNecessary)">
Allow necessary only
</button>
<button matButton="outlined" class="ang-cookie-banner-action" (click)="handleClick(customize)">
Customize
</button>
</ng-content>
</div>
</section>
}
134 changes: 134 additions & 0 deletions libs/design-system/cookie-banner/src/lib/cookie-banner.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
@use 'internal/breakpoints';
@use 'internal/token-utils';
@use './tokens' as *;

$container-padding: 24px;
$container-gap: 32px;
$subcontainer-gap: 16px;
$content-max-width: 716px;
$action-min-width: 188px;

.ang-cookie-banner {
display: block;

.ang-cookie-banner-container {
display: flex;
flex-direction: column;
gap: $container-gap;
padding: $container-padding;
z-index: 1000;
}

.ang-cookie-banner-animate-enter {
animation: ang-cookie-banner-enter-animation 1s ease-in forwards;
}

.ang-cookie-banner-animate-leave {
animation: ang-cookie-banner-leave-animation 1s ease-out forwards;
}

@media (prefers-reduced-motion: reduce) {
.ang-cookie-banner-animate-enter,
.ang-cookie-banner-animate-leave {
animation: none;
}
}

.ang-cookie-banner-content {
display: flex;
flex-direction: column;
max-width: $content-max-width;
margin: 0 auto;
gap: $subcontainer-gap;
}

.ang-cookie-banner-title {
margin: 0;
font-family: token-utils.slot('title-font', $config);
font-size: token-utils.slot('title-size', $config);
font-weight: token-utils.slot('title-weight', $config);
line-height: token-utils.slot('title-line-height', $config);
letter-spacing: token-utils.slot('title-tracking', $config);
}

.ang-cookie-banner-description {
margin: 0;
flex: auto;
font-family: token-utils.slot('description-font', $config);
font-size: token-utils.slot('description-size', $config);
font-weight: token-utils.slot('description-weight', $config);
line-height: token-utils.slot('description-line-height', $config);
letter-spacing: token-utils.slot('description-tracking', $config);
}

.ang-cookie-banner-privacy-policy {
height: 48px;
font-family: token-utils.slot('description-font', $config);
font-size: token-utils.slot('description-size', $config);
line-height: token-utils.slot('description-line-height', $config);
letter-spacing: token-utils.slot('description-tracking', $config);
}

.ang-cookie-banner-actions {
display: flex;
flex-wrap: wrap;
gap: $subcontainer-gap;
}

.ang-cookie-banner-action {
flex: 1 1 $action-min-width;
min-width: $action-min-width;
}

@include breakpoints.breakpoint(medium, $boundary: 'open-end') {
$content-margin: 24px;
$actions-width: 320px;
$actions-margin-left: 16px;

.ang-cookie-banner-container {
flex-direction: row;
justify-content: center;
}

.ang-cookie-banner-content {
max-width: $content-max-width - 2 * $content-margin;
margin: 0 $content-margin;
}

.ang-cookie-banner-actions {
flex-direction: column;
flex-wrap: nowrap;
flex: 0 0 auto;
width: $actions-width - $actions-margin-left;
margin-left: $actions-margin-left;
}

.ang-cookie-banner-action {
flex: auto;
}
}
}

@keyframes ang-cookie-banner-enter-animation {
from {
opacity: 0;
transform: translateY(calc(100% + env(safe-area-inset-bottom)));
}

to {
opacity: 1;
transform: translateY(0);
}
}

@keyframes ang-cookie-banner-leave-animation {
from {
opacity: 1;
transform: translateY(0);
}

to {
opacity: 0;
transform: translateY(calc(100% + env(safe-area-inset-bottom)));
}
}
Loading