diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index e33973cfa..b277333e9 100644 --- a/skills/tedi-angular/references/components.md +++ b/skills/tedi-angular/references/components.md @@ -200,6 +200,15 @@ Both entry points declare `[tedi-floating-button]`. The Community component is `icon` input, not from projection, so you cannot swap in your own `tedi-icon` or add trailing content. It is decorative and `aria-hidden`, which means the projected text is the whole accessible name — if the icon carries meaning the text doesn't, compose a heading and an icon yourself. +- **`tedi-sheet` content goes in ``.** The sheet renders it into an + overlay only while open, so content placed directly in `` stays on the page. The + header, body, footer, `tediSheetTrigger` and `tediSheetClose` find the sheet through DI and must be + inside it. Header slots are attributes on the projected elements (`tedi-sheet-header-start`, + `tedi-sheet-header-end`, `tedi-sheet-header-actions`), and so is the footer's end edge + (`tedi-sheet-footer-end`). The exception is `tedi-sheet-header-content`, which replaces the whole + header layout: it is a directive, so import `SheetHeaderContentDirective` or the element is + silently dropped, and the sheet is then unlabelled until you set `ariaLabelledBy` or `ariaLabel` + on `tediSheetContent`. ### Clear buttons @@ -258,6 +267,10 @@ Both entry points declare `[tedi-floating-button]`. The Community component is can wrap: it centres the icon on the *first line* rather than the middle of the block. A heading value in `modifiers` overrides `element`'s typography and the icon follows it, so `element="h2" modifiers="h4"` is an `

` that looks like an `h4` throughout. +- **`tedi-sheet` for mobile bottom sheets, not `ModalService` with `position: 'bottom'`.** The sheet + adds drag to close, snap points and a collapsed state, and with `showOverlay`, `trapFocus` and + `lockScroll` off (plus `closeOnBackdropClick` off) it stays docked while the page behind remains + usable. ### Responsive behaviour that isn't an input diff --git a/src/tedi/components/overlay/index.ts b/src/tedi/components/overlay/index.ts index 789c9ea9e..67f273730 100644 --- a/src/tedi/components/overlay/index.ts +++ b/src/tedi/components/overlay/index.ts @@ -4,3 +4,4 @@ export * from "./modal"; export * from "./tooltip"; export * from "./info-tooltip"; export * from "./popover"; +export * from "./sheet"; diff --git a/src/tedi/components/overlay/sheet/index.ts b/src/tedi/components/overlay/sheet/index.ts new file mode 100644 index 000000000..968f16033 --- /dev/null +++ b/src/tedi/components/overlay/sheet/index.ts @@ -0,0 +1,8 @@ +export * from "./sheet.component"; +export * from "./sheet-content.directive"; +export * from "./sheet-trigger.directive"; +export * from "./sheet-close.directive"; +export * from "./sheet-header/sheet-header.component"; +export * from "./sheet-header/sheet-header-content.directive"; +export * from "./sheet-body/sheet-body.component"; +export * from "./sheet-footer/sheet-footer.component"; diff --git a/src/tedi/components/overlay/sheet/sheet-body/sheet-body.component.ts b/src/tedi/components/overlay/sheet/sheet-body/sheet-body.component.ts new file mode 100644 index 000000000..8448c7b44 --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet-body/sheet-body.component.ts @@ -0,0 +1,36 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, + ViewEncapsulation, +} from "@angular/core"; +import { CdkScrollable } from "@angular/cdk/scrolling"; +import { SheetComponent } from "../sheet.component"; + +export type SheetBodyPadding = "default" | "none"; + +/** Sheet body. Scrolls when its content is taller than the sheet. */ +@Component({ + standalone: true, + selector: "tedi-sheet-body", + template: "", + styleUrl: "../sheet.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [CdkScrollable], + host: { + class: "tedi-sheet-body", + "[class.tedi-sheet-body--no-padding]": "padding() === 'none'", + "[hidden]": "sheet.collapsed()", + }, +}) +export class SheetBodyComponent { + /** + * Use `none` for content that brings its own padding, e.g. tabs or a full-width list. + * @default default + */ + readonly padding = input("default"); + + protected readonly sheet = inject(SheetComponent); +} diff --git a/src/tedi/components/overlay/sheet/sheet-close.directive.ts b/src/tedi/components/overlay/sheet/sheet-close.directive.ts new file mode 100644 index 000000000..1afff5371 --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet-close.directive.ts @@ -0,0 +1,14 @@ +import { Directive, inject } from "@angular/core"; +import { SheetComponent } from "./sheet.component"; + +/** Closes the surrounding `` on click, e.g. from a footer button. */ +@Directive({ + standalone: true, + selector: "[tediSheetClose]", + host: { + "(click)": "sheet.close()", + }, +}) +export class SheetCloseDirective { + protected readonly sheet = inject(SheetComponent); +} diff --git a/src/tedi/components/overlay/sheet/sheet-content.directive.ts b/src/tedi/components/overlay/sheet/sheet-content.directive.ts new file mode 100644 index 000000000..b511b82b7 --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet-content.directive.ts @@ -0,0 +1,144 @@ +import { + booleanAttribute, + computed, + Directive, + inject, + input, + output, + TemplateRef, +} from "@angular/core"; +import { + breakpointInput, + BreakpointInput, + BreakpointService, +} from "../../../services/breakpoint/breakpoint.service"; + +export type SheetRadius = "default" | "card" | "none"; +export type SheetPadding = "default" | "small"; + +/** + * The sheet panel's content and settings. Holds ``, + * `` and ``. Put a `cdkFocusInitial` + * attribute on the element that should get focus when the sheet opens. + */ +@Directive({ + standalone: true, + selector: "ng-template[tediSheetContent]", +}) +export class SheetContentDirective { + /** @internal */ + readonly templateRef = inject(TemplateRef); + + /** + * Show the drag handle. While it's shown, the sheet can be dragged from the + * handle or from anywhere on `` except its interactive content. + * @default true + */ + readonly showHandle = input(true, { transform: booleanAttribute }); + + /** + * Trap focus inside the sheet while it's open. + * @default true + */ + readonly trapFocus = input(true, { transform: booleanAttribute }); + + /** + * Return focus to the element that had it before the sheet opened. + * @default true + */ + readonly returnFocus = input(true, { transform: booleanAttribute }); + + /** + * Show the dimmed backdrop. For a docked sheet that leaves the page usable, + * also set `trapFocus` and `lockScroll` to `false`, and the sheet's + * `closeOnBackdropClick` to `false`. + * @default true + */ + readonly showOverlay = input(true, { transform: booleanAttribute }); + + /** + * Block page scrolling while the sheet is open. + * @default true + */ + readonly lockScroll = input(true, { transform: booleanAttribute }); + + /** + * Keep the content rendered while the sheet is closed, so form values and + * scroll position survive closing and reopening. + * @default false + */ + readonly keepMounted = input(false, { transform: booleanAttribute }); + + /** + * Rest heights as fractions of the viewport height (`0`–`1`), e.g. `[0.4, 0.9]`. + * Releasing a drag snaps to the nearest one; dragging below the lowest closes the sheet. + */ + readonly snapPoints = input(); + + /** + * Index into the sorted `snapPoints` to open at. The last snap point the + * user dragged to is kept across closing and reopening. + * @default the tallest snap point + */ + readonly defaultSnapPoint = input(); + + /** Emits the fraction the sheet snapped to after a drag. */ + readonly snapPointChange = output(); + + /** + * Render visually hidden close buttons at the start and end of the sheet, + * so touch screen reader users can leave it without an Escape key. Only + * applies while `trapFocus` is `true`. + * @default true + */ + readonly visuallyHiddenDismiss = input(true, { transform: booleanAttribute }); + + /** + * Id of the element that labels the sheet. Only needed when the label is + * outside ``, whose `title` labels the sheet automatically. + */ + readonly ariaLabelledBy = input(); + + /** + * Accessible name for a sheet without a visible title. Ignored when + * `ariaLabelledBy` is set or the header has a `title`. + */ + readonly ariaLabel = input(); + + /** Minimum panel height (any CSS length, e.g. `60dvh`). Not applied while collapsed. */ + readonly minHeight = input(); + + /** + * Maximum panel height (any CSS length). The body scrolls past it. + * Ignored while `snapPoints` are set. + * @default 90dvh + */ + readonly maxHeight = input(); + + /** + * Padding of the header, body and footer. To remove only the body padding, + * use `padding="none"` on ``. + * @default default + */ + readonly padding = input("default"); + + /** + * Top corner radius. `card` matches a card's rounded corners. + * Accepts a plain value or a breakpoint object, e.g. `{ xs: 'none', md: 'card' }`. + * @default default + */ + readonly radius = input( + { xs: "default" as SheetRadius }, + { transform: (v: BreakpointInput) => breakpointInput(v) }, + ); + + /** Extra class on the sheet panel. */ + readonly panelClass = input(); + + private readonly breakpointService = inject(BreakpointService); + + /** @internal */ + readonly resolvedRadius = computed(() => + this.breakpointService.resolveBreakpointInput(this.radius()), + ); +} diff --git a/src/tedi/components/overlay/sheet/sheet-footer/sheet-footer.component.ts b/src/tedi/components/overlay/sheet/sheet-footer/sheet-footer.component.ts new file mode 100644 index 000000000..cd40cbfee --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet-footer/sheet-footer.component.ts @@ -0,0 +1,45 @@ +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + inject, + input, + ViewEncapsulation, +} from "@angular/core"; +import { SheetComponent } from "../sheet.component"; + +export type SheetFooterAlign = "left" | "center" | "right"; + +/** + * Sheet footer. Add `tedi-sheet-footer-end` to the elements that should sit + * at the end edge, e.g. an overflow menu button; the rest then align to the start. + */ +@Component({ + standalone: true, + selector: "tedi-sheet-footer", + template: "", + styleUrl: "../sheet.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class]": "'tedi-sheet-footer tedi-sheet-footer--' + align()", + "[class.tedi-sheet-footer--full-width]": "fullWidth()", + "[hidden]": "sheet.collapsed()", + }, +}) +export class SheetFooterComponent { + /** + * Placement of the actions. Ignored when an element has `tedi-sheet-footer-end`. + * @default left + */ + readonly align = input("left"); + + /** + * Stretch the actions to share the footer width equally. Elements with + * `tedi-sheet-footer-end` keep their own width. + * @default false + */ + readonly fullWidth = input(false, { transform: booleanAttribute }); + + protected readonly sheet = inject(SheetComponent); +} diff --git a/src/tedi/components/overlay/sheet/sheet-header/sheet-header-content.directive.ts b/src/tedi/components/overlay/sheet/sheet-header/sheet-header-content.directive.ts new file mode 100644 index 000000000..b915c04cb --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet-header/sheet-header-content.directive.ts @@ -0,0 +1,16 @@ +import { Directive } from "@angular/core"; + +/** + * Custom header content. Replaces the header's whole default layout: title, + * slots, and the collapse and close buttons. The content then labels nothing, + * so set `ariaLabelledBy` or `ariaLabel` on `tediSheetContent`, and add your + * own `tediSheetClose` control if the sheet should be closable from it. + */ +@Directive({ + standalone: true, + selector: "[tedi-sheet-header-content]", + host: { + class: "tedi-sheet-header__content", + }, +}) +export class SheetHeaderContentDirective {} diff --git a/src/tedi/components/overlay/sheet/sheet-header/sheet-header.component.html b/src/tedi/components/overlay/sheet/sheet-header/sheet-header.component.html new file mode 100644 index 000000000..3cc99326e --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet-header/sheet-header.component.html @@ -0,0 +1,99 @@ +@if (customContent()) { + +} @else { +
+ +
+ @if (title()) { + @switch (headingLevel()) { + @case ("h1") { +

+ {{ title() }} +

+ } + @case ("h2") { +

+ {{ title() }} +

+ } + @case ("h4") { +

+ {{ title() }} +

+ } + @case ("h5") { +
+ {{ title() }} +
+ } + @case ("h6") { +
+ {{ title() }} +
+ } + @default { +

+ {{ title() }} +

+ } + } + } +
+ + @if (collapsible()) { + + } + + @if (closeButton()) { + + } +
+} diff --git a/src/tedi/components/overlay/sheet/sheet-header/sheet-header.component.ts b/src/tedi/components/overlay/sheet/sheet-header/sheet-header.component.ts new file mode 100644 index 000000000..233c03886 --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet-header/sheet-header.component.ts @@ -0,0 +1,103 @@ +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + contentChild, + effect, + inject, + input, + OnDestroy, + ViewEncapsulation, +} from "@angular/core"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { + HeadingModifiers, + TextComponent, +} from "../../../base/text/text.component"; +import { ClosingButtonComponent } from "../../../buttons/closing-button/closing-button.component"; +import { CollapseButtonComponent } from "../../../buttons/collapse-button/collapse-button.component"; +import { SheetComponent } from "../sheet.component"; +import { SheetHeaderContentDirective } from "./sheet-header-content.directive"; + +export type SheetHeaderVariant = "brand" | "default"; +export type SheetHeaderTitlePosition = "start" | "center"; +export type SheetHeaderHeadingLevel = HeadingModifiers; + +/** + * Sheet header. Project leading content with `tedi-sheet-header-start`, + * trailing content with `tedi-sheet-header-end`, and extra icon buttons + * (between the collapse and close buttons) with `tedi-sheet-header-actions`. + * An element with `tedi-sheet-header-content` replaces the whole layout. + */ +@Component({ + standalone: true, + selector: "tedi-sheet-header", + imports: [TextComponent, ClosingButtonComponent, CollapseButtonComponent], + templateUrl: "./sheet-header.component.html", + styleUrl: "../sheet.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class]": "'tedi-sheet-header tedi-sheet-header--' + variant()", + "[class.tedi-sheet-header--center]": "titlePosition() === 'center'", + }, +}) +export class SheetHeaderComponent implements OnDestroy { + /** Title. Labels the sheet dialog. */ + readonly title = input(); + + /** + * Heading level of the title. It always looks like an H6; set the level + * that fits the page's heading outline. + * @default h3 + */ + readonly headingLevel = input("h3"); + + /** + * `brand` is a brand-blue header with white content. + * @default brand + */ + readonly variant = input("brand"); + + /** + * With `center`, the start content and the buttons stay at the edges. + * @default start + */ + readonly titlePosition = input("start"); + + /** + * Show a button that collapses the sheet to its header. + * @default false + */ + readonly collapsible = input(false, { transform: booleanAttribute }); + + /** + * Show the close button. + * @default true + */ + readonly closeButton = input(true, { transform: booleanAttribute }); + + protected readonly customContent = contentChild(SheetHeaderContentDirective); + + protected readonly sheet = inject(SheetComponent); + private readonly translationService = inject(TediTranslationService); + + private readonly expandLabel = this.translationService.track("sheet.expand"); + private readonly collapseLabel = + this.translationService.track("sheet.collapse"); + + protected readonly collapseToggleLabel = computed(() => + this.sheet.collapsed() ? this.expandLabel() : this.collapseLabel(), + ); + + constructor() { + effect(() => + this.sheet.hasTitle.set(!!this.title() && !this.customContent()), + ); + } + + ngOnDestroy(): void { + this.sheet.hasTitle.set(false); + } +} diff --git a/src/tedi/components/overlay/sheet/sheet-trigger.directive.ts b/src/tedi/components/overlay/sheet/sheet-trigger.directive.ts new file mode 100644 index 000000000..10574ffba --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet-trigger.directive.ts @@ -0,0 +1,17 @@ +import { Directive, inject } from "@angular/core"; +import { SheetComponent } from "./sheet.component"; + +/** Opens and closes the surrounding `` on click. */ +@Directive({ + standalone: true, + selector: "[tediSheetTrigger]", + host: { + "aria-haspopup": "dialog", + "[attr.aria-expanded]": "sheet.open()", + "[attr.aria-controls]": "sheet.open() ? sheet.panelId : null", + "(click)": "sheet.toggle()", + }, +}) +export class SheetTriggerDirective { + protected readonly sheet = inject(SheetComponent); +} diff --git a/src/tedi/components/overlay/sheet/sheet.component.html b/src/tedi/components/overlay/sheet/sheet.component.html new file mode 100644 index 000000000..7bc06e1ba --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet.component.html @@ -0,0 +1,64 @@ + + + @if (content(); as config) { +
+
+ @let showDismiss = config.trapFocus() && config.visuallyHiddenDismiss(); + @if (showDismiss) { + + } + @if (config.showHandle()) { + + } + + @if (showDismiss) { + + } +
+
+ } +
+ + + + + diff --git a/src/tedi/components/overlay/sheet/sheet.component.scss b/src/tedi/components/overlay/sheet/sheet.component.scss new file mode 100644 index 000000000..bdde44982 --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet.component.scss @@ -0,0 +1,277 @@ +$sheet-enter-duration: 0.35s; +$sheet-enter-easing: cubic-bezier(0.32, 0.72, 0, 1); +$sheet-fade-duration: 200ms; + +.tedi-sheet { + display: contents; +} + +// The pane covers the viewport; only the backdrop and the panel take clicks. +.cdk-overlay-pane.tedi-sheet-pane { + pointer-events: none; +} + +.tedi-sheet__overlay { + position: absolute; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; + overflow: clip; + pointer-events: auto; + + &::before { + position: absolute; + inset: 0; + content: ""; + background: var(--general-surface-overlay); + opacity: 0; + transition: opacity $sheet-fade-duration ease-out; + } + + &[data-status="open"]::before { + opacity: 1; + } + + &[hidden] { + display: none; + } + + &--no-overlay { + pointer-events: none; + + &::before { + content: none; + } + + .tedi-sheet__panel { + pointer-events: auto; + } + } +} + +.tedi-sheet__panel { + --tedi-sheet-radius: var(--sheet-header-radius-md); + --tedi-sheet-padding-x: var(--sheet-header-padding-md); + --tedi-sheet-padding-y: var(--sheet-header-padding-sm); + --tedi-sheet-header-min-height: var(--sheet-header-height); + + position: relative; + display: flex; + flex-direction: column; + width: 100%; + max-height: 90dvh; + overflow: hidden; + outline: none; + background: var(--sheet-body-default); + border-top-left-radius: var(--tedi-sheet-radius); + border-top-right-radius: var(--tedi-sheet-radius); + box-shadow: 0 1px 5px 0 var(--general-effect-colors-elevation-general-primary); + transform: translateY(100%); + transition: + transform $sheet-enter-duration $sheet-enter-easing, + height $sheet-enter-duration $sheet-enter-easing; + + &[data-status="open"] { + transform: translateY(0); + } + + &--padding-small { + --tedi-sheet-padding-x: var(--sheet-header-padding-sm); + } + + &--radius-card { + --tedi-sheet-radius: var(--card-radius-rounded); + } + + &--radius-none { + --tedi-sheet-radius: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .tedi-sheet__overlay::before, + .tedi-sheet__panel { + transition: none; + } +} + +.tedi-sheet__handle-zone { + position: absolute; + top: 0; + left: 50%; + z-index: 1; + display: flex; + align-items: flex-start; + justify-content: center; + width: 6rem; + height: var(--tedi-dimensions-13); + padding-top: var(--tedi-dimensions-04); + touch-action: none; + cursor: grab; + transform: translateX(-50%); +} + +.tedi-sheet__handle { + width: 2rem; + height: var(--tedi-borders-04); + background: var(--drag-indicator-light); + border-radius: var(--tedi-dimensions-13); +} + +.tedi-sheet-header { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + justify-content: space-between; + min-height: var(--tedi-sheet-header-min-height); + padding: var(--tedi-sheet-padding-y) var(--tedi-sheet-padding-x); + border-top-left-radius: var(--tedi-sheet-radius); + border-top-right-radius: var(--tedi-sheet-radius); + + &--brand { + color: var(--general-text-white); + background: var(--sheet-header-background-brand); + + .tedi-sheet-header__close { + --general-icon-primary: var(--button-main-neutral-inverted-text-default); + + background-color: var( + --button-main-neutral-inverted-icon-only-background-default + ); + border-color: transparent; + + @each $state in hover, active { + &:#{$state} { + --general-icon-primary: var( + --button-main-neutral-inverted-text-#{$state} + ); + + background-color: var( + --button-main-neutral-inverted-icon-only-background-#{$state} + ); + } + } + + &:focus-visible { + outline-color: var(--button-main-neutral-inverted-border-focus); + } + } + } + + &--default { + color: var(--general-text-primary); + background: var(--sheet-header-background-default); + border-bottom: var(--tedi-borders-01) solid var(--sheet-border); + } + + &--center { + // Equal side columns keep a short title centred; a long one stops short of the start content and actions. + display: grid; + grid-template-columns: 1fr minmax(0, auto) 1fr; + + .tedi-sheet-header__start { + grid-column: 1; + justify-self: start; + } + + .tedi-sheet-header__title { + grid-column: 2; + text-align: center; + } + + .tedi-sheet-header__actions { + grid-column: 3; + justify-self: end; + } + } + + &__title { + flex: 1 1 auto; + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__content { + flex: 1 1 auto; + min-width: 0; + } + + &__start, + &__actions { + display: flex; + flex-shrink: 0; + gap: var(--layout-grid-gutters-08); + align-items: center; + } + + &__start:empty { + display: none; + } + + // Flipped: on a bottom sheet, expanding moves up. + &__collapse-toggle > * { + scale: 1 -1; + } +} + +.tedi-sheet__panel--draggable .tedi-sheet-header { + touch-action: none; + cursor: grab; + user-select: none; +} + +// The panel holds the pointer capture from the press, so its cursor is the one shown. +.tedi-sheet__panel--grabbing, +.tedi-sheet__panel.tedi-sheet__panel--grabbing * { + cursor: grabbing; +} + +.tedi-sheet-body { + flex: 1 1 auto; + padding: var(--tedi-sheet-padding-x); + overflow-y: auto; + background: var(--sheet-body-default); + + &--no-padding { + padding: 0; + } +} + +.tedi-sheet-footer { + display: flex; + gap: var(--button-gutter-x); + align-items: center; + padding: var(--tedi-sheet-padding-x); + background: var(--sheet-footer-background); + border-top: var(--tedi-borders-01) solid var(--sheet-border); + + &[hidden] { + display: none; + } + + &--center { + justify-content: center; + } + + &--right { + justify-content: flex-end; + } + + &:has(> [tedi-sheet-footer-end]) { + justify-content: flex-start; + } + + > [tedi-sheet-footer-end]:not( + [tedi-sheet-footer-end] ~ [tedi-sheet-footer-end] + ) { + margin-inline-start: auto; + } + + &--full-width > :not([tedi-sheet-footer-end]) { + flex: 1 1 0; + } +} diff --git a/src/tedi/components/overlay/sheet/sheet.component.spec.ts b/src/tedi/components/overlay/sheet/sheet.component.spec.ts new file mode 100644 index 000000000..977098d95 --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet.component.spec.ts @@ -0,0 +1,857 @@ +import { Component, Injectable, signal } from "@angular/core"; +import { + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + flush, + TestBed, + tick, +} from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { InteractivityChecker } from "@angular/cdk/a11y"; +import { OverlayContainer } from "@angular/cdk/overlay"; +import { TediTranslationService } from "../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; +import { SheetComponent } from "./sheet.component"; +import { SheetContentDirective } from "./sheet-content.directive"; +import { SheetTriggerDirective } from "./sheet-trigger.directive"; +import { SheetCloseDirective } from "./sheet-close.directive"; +import { + SheetHeaderComponent, + SheetHeaderHeadingLevel, +} from "./sheet-header/sheet-header.component"; +import { SheetBodyComponent } from "./sheet-body/sheet-body.component"; +import { SheetFooterComponent } from "./sheet-footer/sheet-footer.component"; +import { SheetHeaderContentDirective } from "./sheet-header/sheet-header-content.directive"; + +// jsdom has no layout, so CDK would treat every element as hidden and unfocusable. +@Injectable() +class LayoutlessInteractivityChecker extends InteractivityChecker { + override isVisible(): boolean { + return true; + } +} + +class TranslationMock { + translate(key: string) { + return key; + } + track(key: string) { + return () => key; + } +} + +@Component({ + standalone: true, + imports: [ + SheetComponent, + SheetContentDirective, + SheetTriggerDirective, + SheetCloseDirective, + SheetHeaderComponent, + SheetBodyComponent, + SheetFooterComponent, + ], + template: ` + + + + + 3 + + + + + + + + + + `, +}) +class TestHostComponent { + open = signal(false); + collapsed = signal(false); + closeOnEscape = signal(true); + closeOnBackdropClick = signal(true); + showHandle = signal(true); + keepMounted = signal(false); + snapPoints = signal(undefined); + ariaLabel = signal(undefined); + radius = signal<"default" | "card" | "none">("default"); + showOverlay = signal(true); + trapFocus = signal(true); + padding = signal<"default" | "small">("default"); + minHeight = signal(undefined); + maxHeight = signal(undefined); + variant = signal<"brand" | "default">("brand"); + titlePosition = signal<"start" | "center">("start"); + title = signal("Layers"); + headingLevel = signal("h3"); + collapsible = signal(false); + closeButton = signal(true); + snapped: number | null = null; +} + +const pointer = ( + type: string, + init: { x?: number; y: number; pointerType?: string; buttons?: number }, +) => { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: init.x ?? 0, + clientY: init.y, + button: 0, + buttons: init.buttons ?? 1, + }); + Object.defineProperties(event, { + pointerId: { value: 1 }, + pointerType: { value: init.pointerType ?? "touch" }, + }); + return event; +}; + +describe("SheetComponent", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let overlayElement: HTMLElement; + + beforeAll(() => { + HTMLElement.prototype.setPointerCapture ??= () => undefined; + HTMLElement.prototype.releasePointerCapture ??= () => undefined; + HTMLElement.prototype.hasPointerCapture ??= () => false; + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + { + provide: InteractivityChecker, + useClass: LayoutlessInteractivityChecker, + }, + ], + }); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + overlayElement = TestBed.inject(OverlayContainer).getContainerElement(); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + const panel = () => + overlayElement.querySelector(".tedi-sheet__panel"); + const header = () => + overlayElement.querySelector(".tedi-sheet-header")!; + const trigger = () => + fixture.nativeElement.querySelector(".trigger") as HTMLButtonElement; + + const openSheet = () => { + host.open.set(true); + fixture.detectChanges(); + tick(16); + fixture.detectChanges(); + }; + + const drag = ( + from: Element, + moves: { x?: number; y: number }[], + pointerType = "touch", + ) => { + from.dispatchEvent(pointer("pointerdown", { y: 0, pointerType })); + for (const move of moves) { + from.dispatchEvent(pointer("pointermove", { ...move, pointerType })); + fixture.detectChanges(); + } + const last = moves[moves.length - 1]; + from.dispatchEvent( + pointer("pointerup", { ...last, pointerType, buttons: 0 }), + ); + fixture.detectChanges(); + }; + + it("renders nothing until opened", () => { + expect(panel()).toBeNull(); + }); + + it("opens from the trigger and wires the trigger's ARIA", fakeAsync(() => { + expect(trigger().getAttribute("aria-haspopup")).toBe("dialog"); + expect(trigger().getAttribute("aria-expanded")).toBe("false"); + + trigger().click(); + fixture.detectChanges(); + tick(16); + fixture.detectChanges(); + + expect(host.open()).toBe(true); + expect(panel()).not.toBeNull(); + expect(trigger().getAttribute("aria-expanded")).toBe("true"); + expect(trigger().getAttribute("aria-controls")).toBe(panel()!.id); + flush(); + })); + + it("is a modal dialog labelled by the header title", fakeAsync(() => { + openSheet(); + + const title = overlayElement.querySelector("h3.tedi-sheet-header__title")!; + expect(panel()!.getAttribute("role")).toBe("dialog"); + expect(panel()!.getAttribute("aria-modal")).toBe("true"); + expect(panel()!.getAttribute("aria-labelledby")).toBe(title.id); + expect(panel()!.hasAttribute("aria-label")).toBe(false); + flush(); + })); + + it("uses ariaLabel when there is no title", fakeAsync(() => { + host.title.set(undefined); + host.ariaLabel.set("Map layers"); + openSheet(); + + expect(panel()!.hasAttribute("aria-labelledby")).toBe(false); + expect(panel()!.getAttribute("aria-label")).toBe("Map layers"); + flush(); + })); + + it("renders the title at the given heading level", fakeAsync(() => { + host.headingLevel.set("h2"); + openSheet(); + + expect( + overlayElement + .querySelector("h2.tedi-sheet-header__title") + ?.textContent?.trim(), + ).toBe("Layers"); + flush(); + })); + + it("projects start content into the header", fakeAsync(() => { + openSheet(); + + expect( + overlayElement.querySelector(".tedi-sheet-header__start .start"), + ).not.toBeNull(); + flush(); + })); + + it("moves focus into the sheet and returns it to the trigger", fakeAsync(() => { + trigger().focus(); + trigger().click(); + fixture.detectChanges(); + tick(16); + fixture.detectChanges(); + + expect(document.activeElement).toBe(overlayElement.querySelector(".field")); + + overlayElement + .querySelector(".tedi-sheet-header__close")! + .click(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(trigger()); + flush(); + })); + + it("closes from the close button and removes the panel after the transition", fakeAsync(() => { + openSheet(); + + overlayElement + .querySelector(".tedi-sheet-header__close")! + .click(); + fixture.detectChanges(); + + expect(host.open()).toBe(false); + expect(panel()!.dataset["status"]).toBe("close"); + + tick(300); + fixture.detectChanges(); + expect(panel()).toBeNull(); + })); + + it("hides the close button with closeButton false", fakeAsync(() => { + host.closeButton.set(false); + openSheet(); + + expect( + overlayElement.querySelector(".tedi-sheet-header__close"), + ).toBeNull(); + flush(); + })); + + it("makes the sheet inert while it animates out", fakeAsync(() => { + openSheet(); + const overlay = () => + overlayElement.querySelector(".tedi-sheet__overlay")!; + expect(overlay().hasAttribute("inert")).toBe(false); + + host.open.set(false); + fixture.detectChanges(); + expect(overlay().hasAttribute("inert")).toBe(true); + + openSheet(); + expect(overlay().hasAttribute("inert")).toBe(false); + flush(); + })); + + it("closes from a tediSheetClose element", fakeAsync(() => { + openSheet(); + + overlayElement.querySelector(".cancel")!.click(); + fixture.detectChanges(); + + expect(host.open()).toBe(false); + flush(); + })); + + it("closes on Escape unless closeOnEscape is false", fakeAsync(() => { + host.closeOnEscape.set(false); + openSheet(); + + const escape = () => + document.body.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + + escape(); + fixture.detectChanges(); + expect(host.open()).toBe(true); + + host.closeOnEscape.set(true); + fixture.detectChanges(); + escape(); + fixture.detectChanges(); + expect(host.open()).toBe(false); + flush(); + })); + + it("closes on a backdrop click, but not on a click in the panel", fakeAsync(() => { + openSheet(); + + panel()!.click(); + fixture.detectChanges(); + expect(host.open()).toBe(true); + + overlayElement.querySelector(".tedi-sheet__overlay")!.click(); + fixture.detectChanges(); + expect(host.open()).toBe(false); + flush(); + })); + + it("stays open on a backdrop click with closeOnBackdropClick false", fakeAsync(() => { + host.closeOnBackdropClick.set(false); + openSheet(); + + overlayElement.querySelector(".tedi-sheet__overlay")!.click(); + fixture.detectChanges(); + expect(host.open()).toBe(true); + flush(); + })); + + it("renders visually hidden dismiss buttons that close the sheet", fakeAsync(() => { + openSheet(); + + const dismiss = overlayElement.querySelectorAll( + ".tedi-sheet__dismiss", + ); + expect(dismiss.length).toBe(2); + expect(dismiss[0].getAttribute("tabindex")).toBe("-1"); + + dismiss[0].click(); + fixture.detectChanges(); + expect(host.open()).toBe(false); + flush(); + })); + + it("keeps the content mounted and hidden with keepMounted", fakeAsync(() => { + host.keepMounted.set(true); + openSheet(); + + overlayElement.querySelector(".field")!.value = "kept"; + host.open.set(false); + fixture.detectChanges(); + tick(300); + fixture.detectChanges(); + + expect( + overlayElement.querySelector(".tedi-sheet__overlay")!.hidden, + ).toBe(true); + expect( + overlayElement.querySelector(".field")!.value, + ).toBe("kept"); + flush(); + })); + + it("applies the card radius", fakeAsync(() => { + host.radius.set("card"); + openSheet(); + + expect(panel()!.classList).toContain("tedi-sheet__panel--radius-card"); + flush(); + })); + + it("applies square corners, small padding, size limits and a panel class", fakeAsync(() => { + host.radius.set("none"); + host.padding.set("small"); + host.minHeight.set("20rem"); + host.maxHeight.set("30rem"); + openSheet(); + + const element = panel()!; + expect(element.classList).toContain("tedi-sheet__panel--radius-none"); + expect(element.classList).toContain("tedi-sheet__panel--padding-small"); + expect(element.classList).toContain("custom-panel"); + expect(element.style.minHeight).toBe("20rem"); + expect(element.style.maxHeight).toBe("30rem"); + flush(); + })); + + it("drops the minimum height while collapsed", fakeAsync(() => { + host.minHeight.set("20rem"); + host.collapsed.set(true); + openSheet(); + + expect(panel()!.style.minHeight).toBe(""); + flush(); + })); + + it("sets the header variant and title position classes", fakeAsync(() => { + host.variant.set("default"); + host.titlePosition.set("center"); + openSheet(); + + expect(header().classList).toContain("tedi-sheet-header--default"); + expect(header().classList).toContain("tedi-sheet-header--center"); + flush(); + })); + + describe("docked", () => { + beforeEach(() => { + host.showOverlay.set(false); + host.trapFocus.set(false); + }); + + it("is not modal and has no dismiss buttons", fakeAsync(() => { + openSheet(); + + expect(panel()!.getAttribute("aria-modal")).toBe("false"); + expect(overlayElement.querySelector(".tedi-sheet__dismiss")).toBeNull(); + expect( + overlayElement.querySelector(".tedi-sheet__overlay--no-overlay"), + ).not.toBeNull(); + flush(); + })); + + it("closes on a click on the page, but not on the trigger", fakeAsync(() => { + openSheet(); + + trigger().dispatchEvent(new MouseEvent("click", { bubbles: true })); + fixture.detectChanges(); + // The trigger toggles the sheet itself; the outside-click handler leaves it alone. + expect(host.open()).toBe(false); + + openSheet(); + document.body.dispatchEvent(new MouseEvent("click", { bubbles: true })); + fixture.detectChanges(); + expect(host.open()).toBe(false); + flush(); + })); + + it("stays open on a page click with closeOnBackdropClick false", fakeAsync(() => { + host.closeOnBackdropClick.set(false); + openSheet(); + + document.body.dispatchEvent(new MouseEvent("click", { bubbles: true })); + fixture.detectChanges(); + expect(host.open()).toBe(true); + flush(); + })); + + it("doesn't pull focus back from the page when closed", fakeAsync(() => { + const outside = document.createElement("button"); + document.body.appendChild(outside); + trigger().focus(); + openSheet(); + + outside.focus(); + host.open.set(false); + fixture.detectChanges(); + + expect(document.activeElement).toBe(outside); + outside.remove(); + flush(); + })); + }); + + describe("collapsing", () => { + it("collapses to the header and hides the body and footer", fakeAsync(() => { + host.collapsible.set(true); + openSheet(); + + const toggle = overlayElement.querySelector( + ".tedi-sheet-header__collapse-toggle", + )!; + expect(toggle.getAttribute("aria-label")).toBe("sheet.collapse"); + + toggle.click(); + fixture.detectChanges(); + + expect(host.collapsed()).toBe(true); + expect(toggle.getAttribute("aria-label")).toBe("sheet.expand"); + expect( + overlayElement.querySelector(".tedi-sheet-body")!.hidden, + ).toBe(true); + expect( + overlayElement.querySelector(".tedi-sheet-footer")!.hidden, + ).toBe(true); + flush(); + })); + + it("has no collapse button unless collapsible", fakeAsync(() => { + openSheet(); + + expect( + overlayElement.querySelector(".tedi-sheet-header__collapse-toggle"), + ).toBeNull(); + flush(); + })); + + it("expands when the collapsed header is dragged up", fakeAsync(() => { + host.collapsed.set(true); + openSheet(); + + drag(header(), [{ y: -20 }, { y: -60 }]); + + expect(host.collapsed()).toBe(false); + flush(); + })); + + it("captures the pointer on press, so a quick flick off the header still drags", fakeAsync(() => { + host.collapsed.set(true); + openSheet(); + const capture = jest.spyOn(panel()!, "setPointerCapture"); + + header().dispatchEvent( + pointer("pointerdown", { y: 0, pointerType: "mouse" }), + ); + expect(capture).toHaveBeenCalledWith(1); + + // A captured pointer's events go to the panel wherever the pointer is. + panel()!.dispatchEvent( + pointer("pointermove", { y: -130, pointerType: "mouse" }), + ); + panel()!.dispatchEvent( + pointer("pointerup", { y: -130, pointerType: "mouse", buttons: 0 }), + ); + fixture.detectChanges(); + + expect(host.collapsed()).toBe(false); + flush(); + })); + }); + + describe("dragging", () => { + it("closes when the header is dragged down past the threshold", fakeAsync(() => { + openSheet(); + + drag(header(), [{ y: 20 }, { y: 140 }]); + + expect(host.open()).toBe(false); + flush(); + })); + + it("follows the pointer while dragging", fakeAsync(() => { + openSheet(); + + header().dispatchEvent(pointer("pointerdown", { y: 0 })); + header().dispatchEvent(pointer("pointermove", { y: 50 })); + fixture.detectChanges(); + + expect(panel()!.style.transform).toBe("translateY(50px)"); + flush(); + })); + + it("shows the grabbing cursor from the press until the release", fakeAsync(() => { + openSheet(); + + header().dispatchEvent(pointer("pointerdown", { y: 0 })); + fixture.detectChanges(); + expect(panel()!.classList).toContain("tedi-sheet__panel--grabbing"); + + header().dispatchEvent(pointer("pointerup", { y: 0, buttons: 0 })); + fixture.detectChanges(); + expect(panel()!.classList).not.toContain("tedi-sheet__panel--grabbing"); + flush(); + })); + + it("keeps the normal cursor on a press that can't drag", fakeAsync(() => { + openSheet(); + + overlayElement + .querySelector(".tedi-sheet-header__close")! + .dispatchEvent(pointer("pointerdown", { y: 0 })); + fixture.detectChanges(); + + expect(panel()!.classList).not.toContain("tedi-sheet__panel--grabbing"); + flush(); + })); + + it("springs back after a short drag", fakeAsync(() => { + openSheet(); + + drag(header(), [{ y: 20 }, { y: 60 }]); + + expect(host.open()).toBe(true); + expect(panel()!.style.transform).toBe(""); + flush(); + })); + + it("ignores a tap that doesn't pass the start threshold", fakeAsync(() => { + openSheet(); + + drag(header(), [{ y: 5 }]); + + expect(host.open()).toBe(true); + flush(); + })); + + it("ignores a mostly horizontal swipe", fakeAsync(() => { + openSheet(); + + drag(header(), [ + { x: 80, y: 20 }, + { x: 90, y: 200 }, + ]); + + expect(host.open()).toBe(true); + flush(); + })); + + it("doesn't drag from the close button", fakeAsync(() => { + openSheet(); + + drag(overlayElement.querySelector(".tedi-sheet-header__close")!, [ + { y: 20 }, + { y: 200 }, + ]); + + expect(host.open()).toBe(true); + flush(); + })); + + it("doesn't drag from the body", fakeAsync(() => { + openSheet(); + + drag(overlayElement.querySelector(".tedi-sheet-body")!, [ + { y: 20 }, + { y: 200 }, + ]); + + expect(host.open()).toBe(true); + flush(); + })); + + it("doesn't drag without the handle", fakeAsync(() => { + host.showHandle.set(false); + openSheet(); + + expect( + overlayElement.querySelector(".tedi-sheet__handle-zone"), + ).toBeNull(); + drag(header(), [{ y: 20 }, { y: 200 }]); + + expect(host.open()).toBe(true); + flush(); + })); + + it("cancels the drag when the mouse button is released outside", fakeAsync(() => { + openSheet(); + + header().dispatchEvent( + pointer("pointerdown", { y: 0, pointerType: "mouse" }), + ); + header().dispatchEvent( + pointer("pointermove", { y: 50, pointerType: "mouse" }), + ); + fixture.detectChanges(); + header().dispatchEvent( + pointer("pointermove", { y: 60, pointerType: "mouse", buttons: 0 }), + ); + fixture.detectChanges(); + + expect(panel()!.style.transform).toBe(""); + flush(); + })); + }); + + describe("snap points", () => { + // jsdom drops `dvh` lengths, so read the style the panel is bound to. + const panelStyle = () => { + const sheet = fixture.debugElement + .query(By.directive(SheetComponent)) + .injector.get(SheetComponent); + return sheet["panelStyle"](); + }; + + beforeEach(() => { + Object.defineProperty(window, "innerHeight", { + value: 1000, + configurable: true, + }); + }); + + it("opens at the tallest snap point", fakeAsync(() => { + host.snapPoints.set([0.9, 0.4]); + openSheet(); + + expect(panelStyle()["height"]).toBe("90dvh"); + flush(); + })); + + it("snaps to the nearest point after a drag", fakeAsync(() => { + host.snapPoints.set([0.4, 0.9]); + openSheet(); + + drag(header(), [{ y: 20 }, { y: 450 }]); + + expect(host.snapped).toBe(0.4); + expect(panelStyle()["height"]).toBe("40dvh"); + flush(); + })); + + it("closes when dragged below the lowest point", fakeAsync(() => { + host.snapPoints.set([0.4, 0.9]); + openSheet(); + + drag(header(), [{ y: 20 }, { y: 700 }]); + + expect(host.open()).toBe(false); + flush(); + })); + }); + + it("cleans up the overlay on destroy", fakeAsync(() => { + openSheet(); + fixture.destroy(); + + expect(panel()).toBeNull(); + discardPeriodicTasks(); + flush(); + })); +}); + +@Component({ + standalone: true, + imports: [ + SheetComponent, + SheetContentDirective, + SheetCloseDirective, + SheetHeaderComponent, + SheetHeaderContentDirective, + SheetBodyComponent, + ], + template: ` + + + +
+ Settings + +
+
+ Body +
+
+ `, +}) +class CustomHeaderHostComponent {} + +describe("SheetHeaderComponent with custom content", () => { + let fixture: ComponentFixture; + let overlayElement: HTMLElement; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [CustomHeaderHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + + fixture = TestBed.createComponent(CustomHeaderHostComponent); + overlayElement = TestBed.inject(OverlayContainer).getContainerElement(); + fixture.detectChanges(); + tick(16); + fixture.detectChanges(); + })); + + afterEach(() => fixture.destroy()); + + it("replaces the default layout with the custom content", fakeAsync(() => { + const header = overlayElement.querySelector(".tedi-sheet-header")!; + + expect(header.querySelector(".tedi-sheet-header__content")).not.toBeNull(); + expect(header.querySelector(".tedi-sheet-header__title")).toBeNull(); + expect(header.querySelector(".tedi-sheet-header__close")).toBeNull(); + expect( + header.querySelector(".tedi-sheet-header__collapse-toggle"), + ).toBeNull(); + flush(); + })); + + it("is labelled by the content's own element, not the ignored title", fakeAsync(() => { + const panel = overlayElement.querySelector(".tedi-sheet__panel")!; + const sheet = fixture.debugElement + .query(By.directive(SheetComponent)) + .injector.get(SheetComponent); + + expect(sheet.hasTitle()).toBe(false); + expect(panel.getAttribute("aria-labelledby")).toBe("custom-title"); + flush(); + })); + + it("keeps the sheet's own controls working inside the custom content", fakeAsync(() => { + const sheet = fixture.debugElement + .query(By.directive(SheetComponent)) + .injector.get(SheetComponent); + + overlayElement.querySelector(".custom-close")!.click(); + fixture.detectChanges(); + + expect(sheet.open()).toBe(false); + flush(); + })); +}); diff --git a/src/tedi/components/overlay/sheet/sheet.component.ts b/src/tedi/components/overlay/sheet/sheet.component.ts new file mode 100644 index 000000000..41dbd84b7 --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet.component.ts @@ -0,0 +1,542 @@ +import { + afterNextRender, + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + contentChild, + effect, + ElementRef, + inject, + Injector, + input, + model, + NgZone, + OnDestroy, + PLATFORM_ID, + signal, + TemplateRef, + untracked, + viewChild, + ViewContainerRef, + ViewEncapsulation, +} from "@angular/core"; +import { DOCUMENT, isPlatformBrowser, NgTemplateOutlet } from "@angular/common"; +import { _IdGenerator, FocusTrap, FocusTrapFactory } from "@angular/cdk/a11y"; +import { hasModifierKey } from "@angular/cdk/keycodes"; +import { Overlay, OverlayRef } from "@angular/cdk/overlay"; +import { TemplatePortal } from "@angular/cdk/portal"; +import { TediTranslationService } from "../../../services/translation/translation.service"; +import { SheetContentDirective } from "./sheet-content.directive"; +import { SheetTriggerDirective } from "./sheet-trigger.directive"; + +export type SheetRole = "dialog" | "alertdialog"; + +type SheetStatus = "closed" | "initial" | "open" | "close"; + +const CLOSE_DURATION = 300; +const DRAG_DISMISS_THRESHOLD = 120; +const DRAG_EXPAND_THRESHOLD = 40; +// A press only becomes a drag after moving this far, so a tap on the header stays a tap. +const DRAG_START_THRESHOLD_TOUCH = 10; +const DRAG_START_THRESHOLD_MOUSE = 2; +const DRAG_ZONE_SELECTOR = ".tedi-sheet__handle-zone, .tedi-sheet-header"; +const NO_DRAG_SELECTOR = [ + "a[href]", + "button", + "input", + "select", + "textarea", + "label", + '[contenteditable]:not([contenteditable="false"])', + '[role="button"]', + '[role="link"]', + '[tabindex]:not([tabindex="-1"])', +].join(", "); + +const closestInside = (target: Element, selector: string, panel: Element) => { + const match = target.closest(selector); + return match !== null && match !== panel && panel.contains(match); +}; + +interface DragState { + pointerId: number; + startX: number; + startY: number; + panel: HTMLElement; +} + +/** + * Bottom sheet dialog. Holds an optional `tediSheetTrigger` and the panel + * content in ``. + */ +@Component({ + standalone: true, + selector: "tedi-sheet", + imports: [NgTemplateOutlet], + templateUrl: "./sheet.component.html", + styleUrl: "./sheet.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-sheet", + "(window:blur)": "resetDrag()", + }, +}) +export class SheetComponent implements OnDestroy { + /** + * Whether the sheet is open. Use `[(open)]` to keep it in sync with closing + * by the close button, backdrop, Escape or dragging. + * @default false + */ + readonly open = model(false); + + /** + * Whether the sheet is collapsed to its header. The body and footer are + * hidden rather than removed, so their state survives. + * @default false + */ + readonly collapsed = model(false); + + /** + * Close the sheet on a click outside it. + * @default true + */ + readonly closeOnBackdropClick = input(true, { transform: booleanAttribute }); + + /** + * Close the sheet on Escape. + * @default true + */ + readonly closeOnEscape = input(true, { transform: booleanAttribute }); + + /** + * Use `alertdialog` for interruptive confirmations. + * @default dialog + */ + readonly role = input("dialog"); + + private readonly overlay = inject(Overlay); + private readonly viewContainerRef = inject(ViewContainerRef); + private readonly focusTrapFactory = inject(FocusTrapFactory); + private readonly injector = inject(Injector); + private readonly ngZone = inject(NgZone); + private readonly document = inject(DOCUMENT); + private readonly idGenerator = inject(_IdGenerator); + private readonly translationService = inject(TediTranslationService); + + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + protected readonly content = contentChild(SheetContentDirective); + private readonly trigger = contentChild(SheetTriggerDirective, { + read: ElementRef, + }); + private readonly panelTemplate = + viewChild.required>("panelTemplate"); + private readonly panel = viewChild>("panel"); + + /** @internal */ + readonly panelId = this.idGenerator.getId("tedi-sheet-"); + /** @internal */ + readonly titleId = this.idGenerator.getId("tedi-sheet-title-"); + /** @internal Set by `` while it renders a title. */ + readonly hasTitle = signal(false); + + protected readonly status = signal("closed"); + protected readonly closeLabel = this.translationService.track("close"); + + private overlayRef: OverlayRef | null = null; + private focusTrap: FocusTrap | null = null; + private previouslyFocused: HTMLElement | null = null; + private closeTimer: ReturnType | undefined; + private initialized = false; + + private dragState: DragState | null = null; + private readonly dragOffset = signal(0); + private readonly dragging = signal(false); + // From the press, not the drag threshold: the panel holds the pointer from then on, so its cursor shows. + protected readonly grabbing = signal(false); + private readonly snapIndex = signal(null); + + private readonly snaps = computed(() => + [...(this.content()?.snapPoints() ?? [])].sort((a, b) => a - b), + ); + + // Snap points are suspended while collapsed, so the panel shrinks to its header and an upward drag expands it. + private readonly hasSnaps = computed( + () => this.snaps().length > 0 && !this.collapsed(), + ); + + private readonly currentSnap = computed(() => { + const snaps = this.snaps(); + const index = + this.snapIndex() ?? + this.content()?.defaultSnapPoint() ?? + snaps.length - 1; + return snaps[Math.max(0, Math.min(index, snaps.length - 1))] ?? 1; + }); + + protected readonly labelledBy = computed( + () => + this.content()?.ariaLabelledBy() ?? + (this.hasTitle() ? this.titleId : null), + ); + + protected readonly ariaLabel = computed(() => + this.labelledBy() ? null : (this.content()?.ariaLabel() ?? null), + ); + + protected readonly panelStyle = computed(() => { + const content = this.content(); + const style: Record = {}; + const minHeight = content?.minHeight(); + + if (minHeight && !this.collapsed()) { + style["min-height"] = minHeight; + } + + if (this.hasSnaps()) { + const snaps = this.snaps(); + const maxSnap = snaps[snaps.length - 1]; + style["max-height"] = `${maxSnap * 100}dvh`; + + if (this.dragging()) { + const height = Math.min( + this.snapHeight(maxSnap), + Math.max(0, this.snapHeight(this.currentSnap()) - this.dragOffset()), + ); + style["height"] = `${height}px`; + style["transition"] = "none"; + } else { + style["height"] = `${this.currentSnap() * 100}dvh`; + } + } else { + const maxHeight = content?.maxHeight(); + if (maxHeight) { + style["max-height"] = maxHeight; + } + + if (this.dragOffset() > 0) { + style["transform"] = `translateY(${this.dragOffset()}px)`; + style["transition"] = "none"; + } + } + + return style; + }); + + constructor() { + effect(() => { + const open = this.open(); + untracked(() => (open ? this.show() : this.hide())); + this.initialized = true; + }); + + effect(() => { + this.open(); + this.collapsed(); + this.content()?.showHandle(); + untracked(() => this.resetDrag()); + }); + } + + ngOnDestroy(): void { + clearTimeout(this.closeTimer); + this.resetDrag(); + this.focusTrap?.destroy(); + this.overlayRef?.dispose(); + } + + /** Close the sheet. */ + close(): void { + this.open.set(false); + } + + /** Open the sheet if it's closed, and close it if it's open. */ + toggle(): void { + this.open.update((open) => !open); + } + + protected onOverlayClick(event: MouseEvent): void { + if (event.target === event.currentTarget && this.closeOnBackdropClick()) { + this.close(); + } + } + + private show(): void { + if (!this.isBrowser) return; + + const content = this.content(); + if (!content) return; + + clearTimeout(this.closeTimer); + + const autoFocus = this.initialized; + const active = this.document.activeElement; + this.previouslyFocused = + active instanceof HTMLElement && active !== this.document.body + ? active + : null; + + const overlayRef = this.getOverlayRef(); + if (!overlayRef.hasAttached()) { + overlayRef.attach( + new TemplatePortal(this.panelTemplate(), this.viewContainerRef), + ); + } + + overlayRef.updateScrollStrategy( + content.lockScroll() + ? this.overlay.scrollStrategies.block() + : this.overlay.scrollStrategies.noop(), + ); + + this.status.set("initial"); + + afterNextRender( + () => { + if (!this.open()) return; + + const panel = this.panel()?.nativeElement; + if (!panel) return; + + this.focusTrap ??= this.focusTrapFactory.create(panel); + this.focusTrap.enabled = content.trapFocus(); + + // `cdkFocusInitial`, else the first tabbable element, else the panel itself. + if ( + autoFocus && + !this.focusTrap.focusInitialElement({ preventScroll: true }) + ) { + panel.focus({ preventScroll: true }); + } + + // Lay the panel out at its closed position first, so the slide-in transition runs. + panel.getBoundingClientRect(); + this.status.set("open"); + }, + { injector: this.injector }, + ); + } + + private hide(): void { + if (!this.isBrowser || this.status() === "closed") return; + + this.status.set("close"); + this.overlayRef?.updateScrollStrategy(this.overlay.scrollStrategies.noop()); + + if (this.focusTrap) { + this.focusTrap.enabled = false; + } + + if (this.content()?.returnFocus() !== false) { + this.restoreFocus(); + } + + this.closeTimer = setTimeout(() => { + this.status.set("closed"); + + if (!this.content()?.keepMounted()) { + this.focusTrap?.destroy(); + this.focusTrap = null; + this.overlayRef?.detach(); + } + }, CLOSE_DURATION); + } + + /** Only while focus is still in the sheet (or lost), so closing a docked sheet doesn't pull focus back from the page. */ + private restoreFocus(): void { + const panel = this.panel()?.nativeElement; + const active = this.document.activeElement; + + if (active && active !== this.document.body && !panel?.contains(active)) { + return; + } + + (this.previouslyFocused ?? this.trigger()?.nativeElement)?.focus({ + preventScroll: true, + }); + } + + private getOverlayRef(): OverlayRef { + if (this.overlayRef) return this.overlayRef; + + const overlayRef = this.overlay.create({ + positionStrategy: this.overlay.position().global().top("0").left("0"), + scrollStrategy: this.overlay.scrollStrategies.noop(), + width: "100%", + height: "100%", + panelClass: "tedi-sheet-pane", + }); + + overlayRef.keydownEvents().subscribe((event) => { + if ( + event.key === "Escape" && + !hasModifierKey(event) && + this.open() && + this.closeOnEscape() + ) { + event.preventDefault(); + this.close(); + } + }); + + // Without a backdrop the page receives the clicks, so they arrive here as outside the pane. + overlayRef.outsidePointerEvents().subscribe((event) => { + const target = event.target; + if ( + !this.open() || + !this.closeOnBackdropClick() || + (target instanceof Node && + this.trigger()?.nativeElement.contains(target)) + ) { + return; + } + this.close(); + }); + + this.overlayRef = overlayRef; + return overlayRef; + } + + private snapHeight(snap: number): number { + return snap * (this.document.defaultView?.innerHeight ?? 0); + } + + private canStartDrag(event: PointerEvent): boolean { + const { target, currentTarget } = event; + + return ( + this.open() && + !!this.content()?.showHandle() && + event.button === 0 && + !event.ctrlKey && + this.dragState === null && + target instanceof Element && + currentTarget instanceof Element && + closestInside(target, DRAG_ZONE_SELECTOR, currentTarget) && + !closestInside(target, NO_DRAG_SELECTOR, currentTarget) + ); + } + + protected onDragStart(event: PointerEvent): void { + const panel = event.currentTarget; + if (!(panel instanceof HTMLElement) || !this.canStartDrag(event)) return; + + this.dragState = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + panel, + }; + this.grabbing.set(true); + + // Captured from the press, so a quick flick off the small collapsed header still reaches the panel. + panel.setPointerCapture(event.pointerId); + + // Outside Angular, so moves that don't become a drag don't run change detection. + this.ngZone.runOutsideAngular(() => { + panel.addEventListener("pointermove", this.onDragMove); + panel.addEventListener("pointerup", this.onDragEnd); + panel.addEventListener("pointercancel", this.onDragCancel); + panel.addEventListener("lostpointercapture", this.onDragCancel); + }); + } + + private onDragMove = (event: PointerEvent): void => { + const drag = this.dragState; + if (drag?.pointerId !== event.pointerId) return; + + if (event.pointerType === "mouse" && (event.buttons & 1) === 0) { + this.resetDrag(); + return; + } + + const delta = event.clientY - drag.startY; + + if (!this.dragging()) { + const deltaX = event.clientX - drag.startX; + const threshold = + event.pointerType === "touch" + ? DRAG_START_THRESHOLD_TOUCH + : DRAG_START_THRESHOLD_MOUSE; + + if (Math.max(Math.abs(delta), Math.abs(deltaX)) < threshold) return; + + if (Math.abs(deltaX) > Math.abs(delta)) { + this.resetDrag(); + return; + } + + this.dragging.set(true); + } + + this.dragOffset.set(this.hasSnaps() ? delta : Math.max(0, delta)); + }; + + private onDragEnd = (event: PointerEvent): void => { + const drag = this.dragState; + if (drag?.pointerId !== event.pointerId) return; + + const dragged = this.dragging(); + const delta = event.clientY - drag.startY; + this.resetDrag(); + if (!dragged) return; + + this.ngZone.run(() => { + if (this.hasSnaps()) { + this.settleOnSnap(this.snapHeight(this.currentSnap()) - delta); + } else if (this.collapsed() && delta < -DRAG_EXPAND_THRESHOLD) { + this.collapsed.set(false); + } else if (delta > DRAG_DISMISS_THRESHOLD) { + this.close(); + } + }); + }; + + private onDragCancel = (event: PointerEvent): void => { + if (this.dragState?.pointerId === event.pointerId) this.resetDrag(); + }; + + protected resetDrag(): void { + const drag = this.dragState; + if (!drag) return; + + this.dragState = null; + this.grabbing.set(false); + this.dragging.set(false); + this.dragOffset.set(0); + + const { panel, pointerId } = drag; + panel.removeEventListener("pointermove", this.onDragMove); + panel.removeEventListener("pointerup", this.onDragEnd); + panel.removeEventListener("pointercancel", this.onDragCancel); + panel.removeEventListener("lostpointercapture", this.onDragCancel); + + if (panel.hasPointerCapture(pointerId)) { + panel.releasePointerCapture(pointerId); + } + } + + private settleOnSnap(height: number): void { + const snaps = this.snaps(); + + if (height < this.snapHeight(snaps[0]) - DRAG_DISMISS_THRESHOLD) { + this.close(); + return; + } + + let nearest = 0; + let best = Infinity; + snaps.forEach((snap, index) => { + const distance = Math.abs(this.snapHeight(snap) - height); + if (distance < best) { + best = distance; + nearest = index; + } + }); + + this.snapIndex.set(nearest); + this.content()?.snapPointChange.emit(snaps[nearest]); + } +} diff --git a/src/tedi/components/overlay/sheet/sheet.stories.ts b/src/tedi/components/overlay/sheet/sheet.stories.ts new file mode 100644 index 000000000..36a6cd08f --- /dev/null +++ b/src/tedi/components/overlay/sheet/sheet.stories.ts @@ -0,0 +1,1557 @@ +import { + argsToTemplate, + type Meta, + moduleMetadata, + type StoryObj, +} from "@storybook/angular"; +import { expect, waitFor, within } from "storybook/test"; +import { SheetComponent } from "./sheet.component"; +import { SheetContentDirective } from "./sheet-content.directive"; +import { SheetTriggerDirective } from "./sheet-trigger.directive"; +import { SheetCloseDirective } from "./sheet-close.directive"; +import { SheetHeaderComponent } from "./sheet-header/sheet-header.component"; +import { SheetBodyComponent } from "./sheet-body/sheet-body.component"; +import { SheetFooterComponent } from "./sheet-footer/sheet-footer.component"; +import { SheetHeaderContentDirective } from "./sheet-header/sheet-header-content.directive"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { ClosingButtonComponent } from "../../buttons/closing-button/closing-button.component"; +import { IconComponent } from "../../base/icon/icon.component"; +import { TextComponent } from "../../base/text/text.component"; +import { TagComponent } from "../../tags/tag/tag.component"; +import { CheckboxComponent } from "../../form/checkbox/checkbox.component"; +import { RadioComponent } from "../../form/radio/radio.component"; +import { LabelComponent } from "../../content/label/label.component"; +import { SeparatorComponent } from "../../helpers/separator/separator.component"; +import { FormFieldComponent } from "../../form/form-field/form-field.component"; +import { TextFieldComponent } from "../../form/text-field/text-field.component"; +import { DateFieldComponent } from "../../form/date-field/date-field.component"; +import { TimeFieldComponent } from "../../form/time-field/time-field.component"; +import { VerticalSpacingDirective } from "../../../directives/vertical-spacing/vertical-spacing.directive"; + +const MAP_LAYERS = [ + "Hübriidkaart", + "Reljeef", + "Katastriüksused", + "Kitsendused", + "Aadressandmed", +]; + +const SCROLL_FIELDS = [ + "Eesnimi", + "Perekonnanimi", + "E-post", + "Telefon", + "Aadress", + "Sihtnumber", + "Linn", + "Maakond", + "Lisainfo", +]; + +const SENDERS = [ + "SK ID Solutions AS", + "Politsei- ja Piirivalveamet", + "Sihtasutus Kutsekoda", + "Siseministeerium", + "Riigi Infosüsteemide Amet", +]; + +const STORY_STYLES = ` +.tedi-sheet-story__section { + padding: var(--tedi-sheet-padding-x); +} + +/* A dropdown-style row without the dropdown's menu context. */ +.tedi-sheet-story__nav-row { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + justify-content: space-between; + width: calc(100% + 2 * var(--tedi-sheet-padding-x)); + margin-inline: calc(-1 * var(--tedi-sheet-padding-x)); + padding: var(--dropdown-item-padding-y) var(--tedi-sheet-padding-x); + font: inherit; + color: var(--dropdown-item-default-text); + text-align: left; + cursor: pointer; + background: none; + border: none; +} + +.tedi-sheet-story__nav-row:hover { + color: var(--dropdown-item-hover-text); + background: var(--dropdown-item-hover-background); +} +`; + +const visualTest: StoryObj = { + tags: ["!dev", "!autodocs"], + parameters: { chromatic: { disableSnapshot: false, viewports: [375, 1280] } }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await waitFor(() => + expect(body.getByRole("dialog")).toHaveAttribute("data-status", "open"), + ); + }, +}; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ +export default { + title: "TEDI-Ready/Components/Overlay/Sheet", + component: SheetComponent, + decorators: [ + moduleMetadata({ + imports: [ + SheetComponent, + SheetContentDirective, + SheetTriggerDirective, + SheetCloseDirective, + SheetHeaderComponent, + SheetBodyComponent, + SheetFooterComponent, + SheetHeaderContentDirective, + ButtonComponent, + ClosingButtonComponent, + IconComponent, + TextComponent, + TagComponent, + CheckboxComponent, + RadioComponent, + LabelComponent, + SeparatorComponent, + FormFieldComponent, + TextFieldComponent, + DateFieldComponent, + TimeFieldComponent, + VerticalSpacingDirective, + ], + }), + ], + parameters: { + chromatic: { disableSnapshot: true }, + status: { type: ["breakpointSupport"] }, + design: { + type: "figma", + url: "https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.102.102?node-id=65376-125297&m=dev", + }, + }, + argTypes: { + open: { + control: false, + description: + "Whether the sheet is open. Bind `[(open)]` to stay in sync when it closes itself.", + table: { + category: "tedi-sheet", + type: { summary: "boolean (model)" }, + defaultValue: { summary: "false" }, + }, + }, + collapsed: { + control: false, + description: + "Whether the sheet is collapsed to its header. Bind `[(collapsed)]` to stay in sync.", + table: { + category: "tedi-sheet", + type: { summary: "boolean (model)" }, + defaultValue: { summary: "false" }, + }, + }, + closeOnBackdropClick: { + control: "boolean", + description: "Close the sheet on a click outside it.", + table: { category: "tedi-sheet", defaultValue: { summary: "true" } }, + }, + closeOnEscape: { + control: "boolean", + description: "Close the sheet on Escape.", + table: { category: "tedi-sheet", defaultValue: { summary: "true" } }, + }, + role: { + control: "radio", + options: ["dialog", "alertdialog"], + description: "Use `alertdialog` for interruptive confirmations.", + table: { category: "tedi-sheet", defaultValue: { summary: "dialog" } }, + }, + showHandle: { + control: "boolean", + description: + "Show the drag handle and allow dragging from it and the header.", + table: { + category: "tediSheetContent", + defaultValue: { summary: "true" }, + }, + }, + padding: { + control: "radio", + options: ["default", "small"], + description: "Padding of the header, body and footer.", + table: { + category: "tediSheetContent", + defaultValue: { summary: "default" }, + }, + }, + radius: { + control: "radio", + options: ["default", "card", "none"], + description: "Top corner radius. Accepts a breakpoint object.", + table: { + category: "tediSheetContent", + type: { + summary: "BreakpointInput<'default' | 'card' | 'none'>", + detail: "'card' or { xs: 'none', md: 'card' }", + }, + defaultValue: { summary: "default" }, + }, + }, + trapFocus: { + control: false, + description: "Trap focus inside the sheet while it's open.", + table: { + category: "tediSheetContent", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + returnFocus: { + control: false, + description: + "Return focus to the element that had it before the sheet opened.", + table: { + category: "tediSheetContent", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + showOverlay: { + control: false, + description: "Show the dimmed backdrop.", + table: { + category: "tediSheetContent", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + lockScroll: { + control: false, + description: "Block page scrolling while the sheet is open.", + table: { + category: "tediSheetContent", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + keepMounted: { + control: false, + description: + "Keep the content rendered while closed, so its state survives reopening.", + table: { + category: "tediSheetContent", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + snapPoints: { + control: false, + description: + "Rest heights as fractions of the viewport height, e.g. `[0.4, 0.9]`.", + table: { + category: "tediSheetContent", + type: { summary: "number[]" }, + }, + }, + defaultSnapPoint: { + control: false, + description: "Index into the sorted `snapPoints` to open at.", + table: { + category: "tediSheetContent", + type: { summary: "number" }, + defaultValue: { summary: "the tallest" }, + }, + }, + snapPointChange: { + control: false, + description: "Emits the fraction the sheet snapped to after a drag.", + table: { + category: "tediSheetContent", + type: { summary: "EventEmitter" }, + }, + }, + visuallyHiddenDismiss: { + control: false, + description: + "Render hidden close buttons for touch screen reader users while focus is trapped.", + table: { + category: "tediSheetContent", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + ariaLabelledBy: { + control: false, + description: + "Id of the element that labels the sheet, when the label isn't the header `title`.", + table: { + category: "tediSheetContent", + type: { summary: "string" }, + }, + }, + ariaLabel: { + control: false, + description: "Accessible name for a sheet without a visible title.", + table: { + category: "tediSheetContent", + type: { summary: "string" }, + }, + }, + minHeight: { + control: false, + description: + "Minimum panel height (any CSS length), not applied while collapsed.", + table: { + category: "tediSheetContent", + type: { summary: "string" }, + }, + }, + maxHeight: { + control: false, + description: + "Maximum panel height (any CSS length); the body scrolls past it.", + table: { + category: "tediSheetContent", + type: { summary: "string" }, + defaultValue: { summary: "90dvh" }, + }, + }, + panelClass: { + control: false, + description: "Extra class on the sheet panel.", + table: { + category: "tediSheetContent", + type: { summary: "string" }, + }, + }, + title: { + control: "text", + description: "Title. Labels the sheet dialog.", + table: { category: "tedi-sheet-header" }, + }, + headingLevel: { + control: "select", + options: ["h1", "h2", "h3", "h4", "h5", "h6"], + description: "Heading level of the title. It always looks like an H6.", + table: { category: "tedi-sheet-header", defaultValue: { summary: "h3" } }, + }, + variant: { + control: "radio", + options: ["brand", "default"], + description: "`brand` is a brand-blue header with white content.", + table: { + category: "tedi-sheet-header", + defaultValue: { summary: "brand" }, + }, + }, + titlePosition: { + control: "radio", + options: ["start", "center"], + description: "Where the title sits in the header.", + table: { + category: "tedi-sheet-header", + defaultValue: { summary: "start" }, + }, + }, + collapsible: { + control: "boolean", + description: "Show a button that collapses the sheet to its header.", + table: { + category: "tedi-sheet-header", + defaultValue: { summary: "false" }, + }, + }, + closeButton: { + control: "boolean", + description: "Show the close button.", + table: { + category: "tedi-sheet-header", + defaultValue: { summary: "true" }, + }, + }, + align: { + control: "radio", + options: ["left", "center", "right"], + description: "Placement of the footer actions.", + table: { + category: "tedi-sheet-footer", + defaultValue: { summary: "left" }, + }, + }, + fullWidth: { + control: "boolean", + description: "Stretch the footer actions to share its width.", + table: { + category: "tedi-sheet-footer", + defaultValue: { summary: "false" }, + }, + }, + bodyPadding: { + name: "padding", + control: false, + description: "Use `none` for content that brings its own padding.", + table: { + category: "tedi-sheet-body", + type: { summary: "'default' | 'none'" }, + defaultValue: { summary: "default" }, + }, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + closeOnBackdropClick: true, + closeOnEscape: true, + role: "dialog", + showHandle: true, + padding: "default", + radius: "default", + title: "Filtreerimine", + headingLevel: "h3", + variant: "default", + titlePosition: "start", + collapsible: false, + closeButton: true, + align: "left", + fullWidth: true, + }, + render: (args) => ({ + styles: [STORY_STYLES], + props: args, + template: ` + + + + + +
+ + +
+ +
+ + +
+
+ + + + + +
+
+`, + }), +}; + +export const ActionsSlot: Story = { + name: "Actions slot", + render: () => ({ + template: ` +
+
+

With footer

+ + + + + +
+ + +
+
+ + + + + +
+
+
+
+

Without footer

+ + + + + +

+ Orav ehk harilik orav ehk punaorav on oravlaste sugukonda kuuluv näriline. See on ainus Eestis + pärismaisena elav orava perekonna liik. Orava karvastiku värvus varieerub ka Eesti ulatuses: Saaremaal + on see valdavalt hallikam, mandril punakam. +

+
+
+
+
+
+`, + }), +}; + +export const CornerRadius: Story = { + name: "Corner radius", + render: () => ({ + props: { + radii: [ + { caption: "24px", radius: "default" }, + { caption: "4px", radius: "card" }, + { caption: "0px", radius: "none" }, + ], + }, + template: ` +
+ @for (example of radii; track example.radius) { +
+

{{ example.caption }}

+ + + + + +
+ + +
+
+
+
+
+ } +
+

0px → 4px from md

+ + + + + +
+ + +
+
+
+
+
+
+`, + }), +}; + +export const HeaderVariant: Story = { + name: "Header variant", + render: () => ({ + props: { + variants: [ + { caption: "Default", variant: "default", trigger: "secondary" }, + { caption: "Brand", variant: "brand", trigger: "primary" }, + ], + }, + template: ` +
+ @for (example of variants; track example.variant) { +
+

{{ example.caption }}

+ + + + + +
+ + +
+
+
+
+
+ } +
+`, + }), +}; + +const HEADER_TYPE_SOURCE = ` + + + + + + + + + + + + +… + + + + + + + + + + 2 + + + + + +
+
+

Seaded

+

Muudatused salvestatakse automaatselt

+
+ +
+
+ … +
`; + +export const HeaderType: Story = { + name: "Header type", + parameters: { + docs: { source: { language: "html", code: HEADER_TYPE_SOURCE } }, + }, + render: () => ({ + props: { + groups: [ + { + label: "Default", + examples: [{ caption: "Default" }], + }, + { + label: "Collapsible", + examples: [ + { caption: "Heading left", collapsible: true }, + { + caption: "Heading center", + collapsible: true, + titlePosition: "center", + }, + ], + }, + { + label: "Heading center", + examples: [{ caption: "Heading center", titlePosition: "center" }], + }, + { + label: "Without closing button", + examples: [ + { caption: "Heading left", closeButton: false }, + { + caption: "Heading center", + closeButton: false, + titlePosition: "center", + }, + ], + }, + { + label: "Without handle", + examples: [ + { caption: "Heading left", showHandle: false }, + { + caption: "Heading center", + showHandle: false, + titlePosition: "center", + }, + ], + }, + ], + }, + template: ` +
+ @for (group of groups; track group.label) { +
+ @if (group.examples.length > 1 || group.examples[0].caption !== group.label) { +

{{ group.label }}

+ } +
+ @for (example of group.examples; track example.caption) { +
+

{{ example.caption }}

+ + + + + +
+ + +
+
+
+
+
+ } +
+
+ } + +
+

With custom button

+
+

Secondary closing button

+ + + + + + + +
+ + +
+
+
+
+
+
+ +
+

With slot

+
+
+

Right slot

+ + + + + 2 uut teadet + + +
+
+

Täna, 09:12

+

Teie taotlus on menetlusse võetud

+
+
+

Eile, 16:40

+

Uus dokument on allkirjastamiseks valmis

+
+
+
+
+
+
+
+

Left slot

+ + + + + 2 + + +
+
+

Täna, 09:12

+

Teie taotlus on menetlusse võetud

+
+
+

Eile, 16:40

+

Uus dokument on allkirjastamiseks valmis

+
+
+
+
+
+
+
+

Left and right slots

+ + + + + + 2 + + +
+
+

Täna, 09:12

+

Teie taotlus on menetlusse võetud

+
+
+

Eile, 16:40

+

Uus dokument on allkirjastamiseks valmis

+
+
+
+
+
+
+
+
+ +
+

Custom header

+
+

Title with description

+ + + + +
+
+

Seaded

+

Muudatused salvestatakse automaatselt

+
+ +
+
+ +
+ + +
+
+
+
+
+
+
+`, + }), +}; + +/** + * `padding` on `tediSheetContent` tightens the header, body and footer together. + * For edge-to-edge body content, use `padding="none"` on `` instead. + */ +export const Padding: Story = { + render: () => ({ + props: { paddings: ["default", "small"] }, + template: ` +
+ @for (padding of paddings; track padding) { +
+

padding="{{ padding }}"

+ + + + + +
+ + +
+
+ + + + + +
+
+
+ } +
+`, + }), +}; + +export const FooterAlignment: Story = { + name: "Footer alignment", + render: () => ({ + props: { + examples: [ + { caption: 'align="left"', align: "left" }, + { caption: 'align="center"', align: "center" }, + { caption: 'align="right"', align: "right" }, + { caption: "fullWidth", align: "left", fullWidth: true }, + { + caption: "fullWidth + end", + align: "left", + fullWidth: true, + end: true, + }, + ], + }, + template: ` +
+ @for (example of examples; track example.caption) { +
+

{{ example.caption }}

+ + + + + +
+ + +
+
+ + + + @if (example.end) { + + } + +
+
+
+ } +
+`, + }), +}; + +/** + * The sheet collapses to its header while the page stays usable: `collapsible` on the header, + * `showOverlay`, `trapFocus` and `lockScroll` off on `tediSheetContent`, and + * `closeOnBackdropClick` off on the sheet. + */ +export const DockedPeek: Story = { + name: "Docked peek", + render: () => ({ + template: ` + + + + + +
+ + + + + + + + + +
+
+ + + + +
+
+`, + }), +}; + +/** + * Drag the handle or header and release to snap to 40% or 90% of the viewport height. + * Dragging below the lowest point closes the sheet. + */ +export const SnapPoints: Story = { + name: "Snap points", + render: () => ({ + props: { layers: [...MAP_LAYERS, ...MAP_LAYERS] }, + template: ` + + + + + +
+ @for (layer of layers; track $index) { + + } +
+
+
+
+`, + }), +}; + +/** Type into the field, close the sheet and reopen it: the value is kept. */ +export const KeepMounted: Story = { + render: () => ({ + template: ` + + + + + + + + + + + + +`, + }), +}; + +export const Controlled: Story = { + render: () => ({ + props: { open: false }, + template: ` + + + + + +
+ + +
+
+ + + + +
+
+`, + }), +}; + +/** + * Collapse the sheet from the page, e.g. while the user draws on a map, and expand it + * once the drawing is confirmed. Collapsing keeps the form's values; closing removes them. + */ +export const ControlledCollapse: Story = { + name: "Controlled collapse", + render: () => ({ + props: { open: false, collapsed: false }, + template: ` +
+ + +
+ + + + + + + + + + + + + + + +`, + }), +}; + +/** + * Open **Saatja**, change the selection, then return with the back button. Both views stay + * mounted, so their form values survive moving between them. + */ +export const ContentNavigation: Story = { + name: "Content navigation", + render: () => ({ + styles: [STORY_STYLES], + props: { + step: "filters", + senders: SENDERS, + selected: SENDERS.slice(0, 2), + toggleSender(this: { selected: string[] }, sender: string) { + this.selected = this.selected.includes(sender) + ? this.selected.filter((value) => value !== sender) + : [...this.selected, sender]; + }, + }, + template: ` + + + + + @if (step === "senders") { + + } + + +
+
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ @for (sender of senders; track sender) { + + } +
+
+
+ @if (step === "filters") { + + + + + } @else { + + + + + } +
+
+`, + }), +}; + +/** `maxHeight` limits the sheet height; the body scrolls between the header and footer. */ +export const ScrollableContent: Story = { + name: "Scrollable content", + render: () => ({ + props: { fields: SCROLL_FIELDS }, + template: ` + + + + + +
+ @for (field of fields; track field) { + + + + + } +
+
+ + + + +
+
+`, + }), +}; + +export const OpenForVisualTest: Story = { + ...visualTest, + render: () => ({ + styles: [STORY_STYLES], + template: ` + + + + +
+ + +
+ +
+ + +
+
+ + + + + +
+
+`, + }), +}; + +export const OpenBrandForVisualTest: Story = { + ...visualTest, + render: () => ({ + template: ` + + + + +
+ + + + + + + + + +
+
+ + + + +
+
+`, + }), +}; + +export const OpenSmallForVisualTest: Story = { + ...visualTest, + render: () => ({ + template: ` + + + + 2 + + +
+
+

Täna, 09:12

+

Teie taotlus on menetlusse võetud

+
+
+

Eile, 16:40

+

Uus dokument on allkirjastamiseks valmis

+
+
+
+ + + + +
+
+`, + }), +}; + +export const OpenWithoutHandleForVisualTest: Story = { + ...visualTest, + render: () => ({ + template: ` + + + + + + +
+ + +
+
+
+
+`, + }), +}; + +export const CollapsedForVisualTest: Story = { + ...visualTest, + render: () => ({ + template: ` + + + + + + + + + + + + + + + +`, + }), +}; + +export const OpenSnapPointForVisualTest: Story = { + ...visualTest, + render: () => ({ + props: { layers: [...MAP_LAYERS, ...MAP_LAYERS] }, + template: ` + + + + +
+ @for (layer of layers; track $index) { + + } +
+
+
+
+`, + }), +}; + +export const OpenScrollableForVisualTest: Story = { + ...visualTest, + render: () => ({ + props: { fields: SCROLL_FIELDS }, + template: ` + + + + +
+ @for (field of fields; track field) { + + + + + } +
+
+ + + + +
+
+`, + }), +}; diff --git a/src/tedi/services/breakpoint/breakpoint.service.spec.ts b/src/tedi/services/breakpoint/breakpoint.service.spec.ts index 7b00b883e..8b5b65c63 100644 --- a/src/tedi/services/breakpoint/breakpoint.service.spec.ts +++ b/src/tedi/services/breakpoint/breakpoint.service.spec.ts @@ -59,4 +59,26 @@ describe("BreakpointService", () => { expect(service.isBelowBreakpoint("lg")()).toBe(true); expect(service.isBelowBreakpoint("md")()).toBe(false); }); + + it("resolves a breakpoint object to the nearest value at or below the current breakpoint", () => { + const service = TestBed.inject(BreakpointService); + const value = { xs: "a", md: "b", xl: "c" }; + + expect(service.resolveBreakpointInput(value)).toBe("a"); + + state$.next({ + matches: true, + breakpoints: { + "(min-width: 0rem)": true, + "(min-width: 36rem)": true, + "(min-width: 48rem)": true, + "(min-width: 62rem)": true, + "(min-width: 75rem)": false, + "(min-width: 87.5rem)": false, + }, + }); + + expect(service.resolveBreakpointInput(value)).toBe("b"); + expect(service.resolveBreakpointInput({ xs: 0, sm: undefined })).toBe(0); + }); }); diff --git a/src/tedi/services/breakpoint/breakpoint.service.ts b/src/tedi/services/breakpoint/breakpoint.service.ts index 4ccbbdd32..e9e50af19 100644 --- a/src/tedi/services/breakpoint/breakpoint.service.ts +++ b/src/tedi/services/breakpoint/breakpoint.service.ts @@ -118,6 +118,22 @@ export class BreakpointService { return resolvedInputs as TInputs; } + /** + * The value of a `breakpointInput()` object for the current breakpoint: the + * nearest breakpoint at or below it that sets a value, else `xs`. Reactive + * when read inside a `computed`. + */ + resolveBreakpointInput(value: BreakpointObject): T { + const current = this._currentBreakpoint(); + + for (let i = current ? breakpointsOrder.indexOf(current) : 0; i > 0; i--) { + const resolved = value[breakpointsOrder[i] as Exclude]; + if (resolved !== undefined) return resolved; + } + + return value.xs; + } + isBelowBreakpoint(breakpoint: Breakpoint | Signal) { return computed(() => { const current = this._currentBreakpoint(); diff --git a/src/tedi/services/translation/translations.ts b/src/tedi/services/translation/translations.ts index e2f64985a..1dd27fbdc 100644 --- a/src/tedi/services/translation/translations.ts +++ b/src/tedi/services/translation/translations.ts @@ -12,6 +12,7 @@ export const translationsMap = { "Dropdown", "Tooltip", "HeaderRole", + "Sheet", ], et: "Sulge", en: "Close", @@ -327,6 +328,22 @@ export const translationsMap = { en: "Close modal", ru: "Закрыть модальное окно", }, + "sheet.collapse": { + description: + "Label for the Sheet collapse toggle when the sheet is expanded", + components: ["Sheet"], + et: "Ahenda", + en: "Collapse", + ru: "Свернуть", + }, + "sheet.expand": { + description: + "Label for the Sheet collapse toggle when the sheet is collapsed", + components: ["Sheet"], + et: "Laienda", + en: "Expand", + ru: "Развернуть", + }, "select.loading": { description: "Text when select options are loading", components: ["Select"],