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/cookie-banner/ng-package.json b/libs/design-system/cookie-banner/ng-package.json new file mode 100644 index 0000000..65195bb --- /dev/null +++ b/libs/design-system/cookie-banner/ng-package.json @@ -0,0 +1,6 @@ +{ + "lib": { + "entryFile": "src/index.ts", + "styleIncludePaths": ["../src/sass"] + } +} diff --git a/libs/design-system/cookie-banner/src/index.ts b/libs/design-system/cookie-banner/src/index.ts new file mode 100644 index 0000000..ffdabea --- /dev/null +++ b/libs/design-system/cookie-banner/src/index.ts @@ -0,0 +1,7 @@ +export { + CookieBanner, + CookieBannerAction, + CookieBannerDescription, + CookieBannerLogo, + CookieBannerTitle, +} from './lib/cookie-banner'; diff --git a/libs/design-system/cookie-banner/src/lib/cookie-banner.html b/libs/design-system/cookie-banner/src/lib/cookie-banner.html new file mode 100644 index 0000000..f72f0ea --- /dev/null +++ b/libs/design-system/cookie-banner/src/lib/cookie-banner.html @@ -0,0 +1,44 @@ +@if (opened()) { + +} diff --git a/libs/design-system/cookie-banner/src/lib/cookie-banner.scss b/libs/design-system/cookie-banner/src/lib/cookie-banner.scss new file mode 100644 index 0000000..f0bb750 --- /dev/null +++ b/libs/design-system/cookie-banner/src/lib/cookie-banner.scss @@ -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))); + } +} diff --git a/libs/design-system/cookie-banner/src/lib/cookie-banner.stories.ts b/libs/design-system/cookie-banner/src/lib/cookie-banner.stories.ts new file mode 100644 index 0000000..2b10724 --- /dev/null +++ b/libs/design-system/cookie-banner/src/lib/cookie-banner.stories.ts @@ -0,0 +1,101 @@ +import { MatButton } from '@angular/material/button'; +import { TextLink } from '@atlasng/design-system/text-link'; +import { argsToTemplate, Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { expect, waitFor } from 'storybook/test'; +import { + CookieBanner, + CookieBannerAction, + CookieBannerDescription, + CookieBannerLogo, + CookieBannerTitle, +} from './cookie-banner'; + +const TOGGLE_BUTTONS = ` +
+ + +
+`; + +const meta: Meta = { + title: 'Design System/Cookie Banner', + component: CookieBanner, + subcomponents: [CookieBannerLogo, CookieBannerTitle, CookieBannerDescription, CookieBannerAction], + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/BCEJn9KCIbBJ5MzqnojKQp/AtlasNG-Components?node-id=4497-607', + }, + layout: 'fullscreen', + }, + args: { + privacyPolicy: 'https://example.com', + }, + argTypes: { + privacyPolicy: { + control: 'text', + }, + }, + decorators: [ + moduleMetadata({ + imports: [ + CookieBanner, + CookieBannerLogo, + CookieBannerTitle, + CookieBannerDescription, + CookieBannerAction, + MatButton, + TextLink, + ], + }), + ], + render: (args) => ({ + props: args, + template: ` + ${TOGGLE_BUTTONS} + + + `, + }), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomContent: Story = { + render: (args) => ({ + props: args, + template: ` + ${TOGGLE_BUTTONS} + + + Wow, a custom title! + + This cookie banner has custom content. You can put whatever you want in here, like + text links, icons, and more. + + + Learn more + + `, + }), +}; + +export const Animations: Story = { + play: async ({ canvas, userEvent }) => { + const headerText = 'Manage your privacy preferences'; + const openButton = canvas.getByRole('button', { name: 'Open banner' }); + const closeButton = canvas.getByRole('button', { name: 'Close banner' }); + + // Verify banner appears immediately, i.e. no enter animation + await expect(canvas.queryByText(headerText)).toBeInTheDocument(); + + await userEvent.click(closeButton); + await waitFor(() => expect(canvas.queryByText(headerText)).not.toBeInTheDocument(), { timeout: 2000 }); + + await userEvent.click(openButton); + await waitFor(() => expect(canvas.queryByText(headerText)).toBeInTheDocument(), { timeout: 2000 }); + }, +}; diff --git a/libs/design-system/cookie-banner/src/lib/cookie-banner.ts b/libs/design-system/cookie-banner/src/lib/cookie-banner.ts new file mode 100644 index 0000000..d23a5f8 --- /dev/null +++ b/libs/design-system/cookie-banner/src/lib/cookie-banner.ts @@ -0,0 +1,120 @@ +import { coerceElement } from '@angular/cdk/coercion'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + contentChild, + Directive, + HostAttributeToken, + inject, + input, + model, + output, + OutputEmitterRef, + signal, + ViewEncapsulation, +} from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { provideEventScope } from '@atlasng/analytics'; +import { AnyLink, AnyLinkCommand, IdGenerator } from '@atlasng/common'; +import { TextLink } from '@atlasng/design-system/text-link'; + +const DEFAULT_TITLE_ID_PREFIX = 'ang-cookie-banner-title'; + +@Directive({ + selector: 'ang-cookie-banner-logo, [angCookieBannerLogo]', + host: { class: 'ang-cookie-banner-logo' }, +}) +export class CookieBannerLogo {} + +@Directive({ + selector: 'ang-cookie-banner-title, [angCookieBannerTitle]', + host: { + class: 'ang-cookie-banner-title', + '[attr.id]': 'id()', + }, +}) +export class CookieBannerTitle { + readonly id = input( + inject(new HostAttributeToken('id'), { optional: true }) ?? inject(IdGenerator).getId(DEFAULT_TITLE_ID_PREFIX), + ); +} + +@Directive({ + selector: 'ang-cookie-banner-description, [angCookieBannerDescription]', + host: { class: 'ang-cookie-banner-description' }, +}) +export class CookieBannerDescription {} + +@Directive({ + selector: 'ang-cookie-banner-action, [angCookieBannerAction]', + host: { + class: 'ang-cookie-banner-action', + '(click)': 'handleClick()', + }, +}) +export class CookieBannerAction { + readonly closeOnClick = input(true); + + private readonly banner = inject(CookieBanner); + + protected handleClick(): void { + if (this.closeOnClick() && this.banner.closeOnClick()) { + this.banner.close(); + } + } +} + +@Component({ + selector: 'ang-cookie-banner', + imports: [AnyLink, MatButton, MatIcon, TextLink], + templateUrl: './cookie-banner.html', + styleUrl: './cookie-banner.scss', + providers: [provideEventScope('cookie-banner')], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + class: 'ang-cookie-banner', + }, +}) +export class CookieBanner { + readonly opened = model(true); + + readonly privacyPolicy = input(); + + readonly containerEl = input(undefined, { transform: coerceElement }); + readonly reserveSpace = input(true); + readonly closeOnClick = input(true); + + readonly allowAll = output(); + readonly allowNecessary = output(); + readonly customize = output(); + + protected readonly animateOpen = signal(false); + protected readonly titleId = computed(() => this.titleDir()?.id() ?? this.idGenerator.getId(DEFAULT_TITLE_ID_PREFIX)); + + private readonly titleDir = contentChild(CookieBannerTitle, { descendants: true }); + private readonly idGenerator = inject(IdGenerator); + + constructor() { + afterNextRender(() => this.animateOpen.set(true)); + } + + open(): void { + this.opened.set(true); + this.animateOpen.set(true); + } + + close(): void { + this.opened.set(false); + } + + protected handleClick(ref: OutputEmitterRef): void { + ref.emit(); + if (this.closeOnClick()) { + this.close(); + } + } +} diff --git a/libs/design-system/cookie-banner/src/lib/tokens.scss b/libs/design-system/cookie-banner/src/lib/tokens.scss new file mode 100644 index 0000000..ac52e0a --- /dev/null +++ b/libs/design-system/cookie-banner/src/lib/tokens.scss @@ -0,0 +1,21 @@ +@use 'internal/token-utils'; + +$config: ( + namespace: 'cookie-banner', + tokens: ( + title-font: token-utils.sys-token(title-medium-font), + title-line-height: token-utils.sys-token(title-medium-line-height), + title-size: token-utils.sys-token(title-medium-size), + title-tracking: token-utils.sys-token(title-medium-tracking), + title-weight: token-utils.sys-token(title-medium-weight), + description-font: token-utils.sys-token(body-large-font), + description-line-height: token-utils.sys-token(body-large-line-height), + description-size: token-utils.sys-token(body-large-size), + description-tracking: token-utils.sys-token(body-large-tracking), + description-weight: token-utils.sys-token(body-large-weight), + ), +); + +@mixin overrides($overrides) { + @include token-utils.apply-overrides($overrides, $config); +} diff --git a/libs/design-system/src/sass/internal/_breakpoints.scss b/libs/design-system/src/sass/internal/_breakpoints.scss new file mode 100644 index 0000000..22284dc --- /dev/null +++ b/libs/design-system/src/sass/internal/_breakpoints.scss @@ -0,0 +1,150 @@ +@use 'sass:list'; +@use 'sass:map'; +@use 'sass:meta'; + +$_breakpoint-min: 0px; +$_breakpoint-max: 1000000px; +$_breakpoint-keywords: ('boundary'); +$_breakpoint-boundary-modes: ('open', 'open-start', 'open-end', 'closed'); + +$breakpoints: ( + x-small: ( + $_breakpoint-min, + 599.98px, + ), + small: ( + 600px, + 959.98px, + ), + medium: ( + 960px, + 1279.98px, + ), + large: ( + 1280px, + 1919.98px, + ), + x-large: ( + 1920px, + $_breakpoint-max, + ), +); + +// Create a media query for the given breakpoint(s) and boundary mode (default: 'closed'). +@mixin breakpoint($breakpoint, $args...) { + $keywords: meta.keywords($args); + $invalid-keywords: map.remove($keywords, $_breakpoint-keywords...); + $breakpoint-keys: list.join(($breakpoint), $args); + $boundary-mode: map.get($keywords, 'boundary') or 'closed'; + + @if list.length($invalid-keywords) != 0 { + @error 'Invalid keyword arguments: #{map.keys($invalid-keywords)}.'; + } + + @if not list.index($_breakpoint-boundary-modes, $boundary-mode) { + @error 'Invalid boundary mode: #{$boundary-mode}. Valid boundary modes are: #{$_breakpoint-boundary-modes}.'; + } + + @each $key in $breakpoint-keys { + @if not map.has-key($breakpoints, $key) { + @error 'Invalid breakpoint: #{$key}. Valid breakpoints are: #{map.keys($breakpoints)}.'; + } + } + + $ranges: _select-ranges($breakpoint-keys); + $ranges: _merge-adjacent-ranges($ranges); + $ranges: _trim-boundaries($ranges, $boundary-mode); + $queries: (); + @each $range in $ranges { + $queries: list.append($queries, _range-to-query($range), $separator: comma); + } + + @if list.length($queries) != 0 { + @media #{$queries} { + @content; + } + } @else { + @warn 'Breakpoints cover the entire range of possible widths. This is likely a mistake, please check the specified breakpoints and boundary mode.'; + @content; + } +} + +@function _select-ranges($keys) { + $ranges: (); + @each $key, $range in $breakpoints { + @if list.index($keys, $key) { + $ranges: list.append($ranges, $range); + } + } + + @return $ranges; +} + +@function _merge-adjacent-ranges($ranges, $tolerance: 1px) { + $merged: (); + $current: null; + @each $range in $ranges { + @if $current == null { + $current: $range; + } @else { + $currentMax: list.nth($current, 2); + $min: list.nth($range, 1); + $max: list.nth($range, 2); + $diff: $min - $currentMax; + + @if $diff <= $tolerance { + $current: list.set-nth($current, 2, $max); + } @else { + $merged: list.append($merged, $current); + $current: $range; + } + } + } + + @if $current != null { + $merged: list.append($merged, $current); + } + + @return $merged; +} + +@function _trim-boundaries($ranges, $boundary-mode) { + $length: list.length($ranges); + $first: list.nth($ranges, 1); + $last: list.nth($ranges, $length); + + @if list.index(('open', 'open-start'), $boundary-mode) or list.nth($first, 1) == $_breakpoint-min { + $first: list.set-nth($first, 1, null); + } + + @if list.index(('open', 'open-end'), $boundary-mode) or list.nth($last, 2) == $_breakpoint-max { + $last: list.set-nth($last, 2, null); + } + + @if $length == 1 { + $min: list.nth($first, 1); + $max: list.nth($last, 2); + @if $min == null and $max == null { + @return (); + } + + @return list.append((), ($min, $max)); + } + + $ranges: list.set-nth($ranges, 1, $first); + $ranges: list.set-nth($ranges, $length, $last); + @return $ranges; +} + +@function _range-to-query($range) { + $min: list.nth($range, 1); + $max: list.nth($range, 2); + + @if $min == null { + @return '(max-width: #{$max})'; + } @else if $max == null { + @return '(min-width: #{$min})'; + } @else { + @return '(min-width: #{$min}) and (max-width: #{$max})'; + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json index d0bb5c0..66fc74f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -30,6 +30,7 @@ "@atlasng/design-system": ["./libs/design-system/src/index.ts"], "@atlasng/design-system/buttons/help": ["./libs/design-system/buttons/help/src/index.ts"], "@atlasng/design-system/buttons/social-media": ["./libs/design-system/buttons/social-media/src/index.ts"], + "@atlasng/design-system/cookie-banner": ["./libs/design-system/cookie-banner/src/index.ts"], "@atlasng/design-system/indicators/end-of-results": [ "./libs/design-system/indicators/end-of-results/src/index.ts" ],