Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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('-');
}
}
5 changes: 5 additions & 0 deletions libs/design-system/cookie-banner/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
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';
40 changes: 40 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,40 @@
@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, $mode: gte) {
$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
Loading