From 669326d1333eabb20d047d242a6edcccd473e57d Mon Sep 17 00:00:00 2001 From: Rando Leppik Date: Mon, 14 Sep 2026 15:44:01 +0300 Subject: [PATCH 1/7] feat(print): add tediPrint directive #651 --- src/tedi/directives/index.ts | 1 + src/tedi/directives/print/index.ts | 1 + .../directives/print/print.directive.spec.ts | 118 ++++++++++++++++++ src/tedi/directives/print/print.directive.ts | 100 +++++++++++++++ src/tedi/directives/print/print.stories.ts | 108 ++++++++++++++++ 5 files changed, 328 insertions(+) create mode 100644 src/tedi/directives/print/index.ts create mode 100644 src/tedi/directives/print/print.directive.spec.ts create mode 100644 src/tedi/directives/print/print.directive.ts create mode 100644 src/tedi/directives/print/print.stories.ts diff --git a/src/tedi/directives/index.ts b/src/tedi/directives/index.ts index 350450751..0e86d3a17 100644 --- a/src/tedi/directives/index.ts +++ b/src/tedi/directives/index.ts @@ -1,3 +1,4 @@ export * from "./hide-at/hide-at.directive"; +export * from "./print"; export * from "./show-at/show-at.directive"; export * from "./vertical-spacing"; diff --git a/src/tedi/directives/print/index.ts b/src/tedi/directives/print/index.ts new file mode 100644 index 000000000..7e6df103b --- /dev/null +++ b/src/tedi/directives/print/index.ts @@ -0,0 +1 @@ +export * from "./print.directive"; diff --git a/src/tedi/directives/print/print.directive.spec.ts b/src/tedi/directives/print/print.directive.spec.ts new file mode 100644 index 000000000..43a56c9ee --- /dev/null +++ b/src/tedi/directives/print/print.directive.spec.ts @@ -0,0 +1,118 @@ +import { Component, input } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { PrintBreak, PrintDirective, PrintVisibility } from "./print.directive"; + +@Component({ + standalone: true, + imports: [PrintDirective], + template: ` +
+ content +
+ `, +}) +class HostComponent { + visibility = input(""); + breakBefore = input(); + breakAfter = input(); + breakInside = input(); +} + +describe("PrintDirective", () => { + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + element = fixture.debugElement.query(By.directive(PrintDirective)) + .nativeElement as HTMLElement; + }); + + it("applies no print classes by default", () => { + expect(element.classList).toContain("consumer-class"); + expect(element.classList).not.toContain("no-print"); + expect(element.classList).not.toContain("show-print"); + expect(element.className).not.toContain("break-"); + }); + + it("adds no-print when visibility is hide", () => { + fixture.componentRef.setInput("visibility", "hide"); + fixture.detectChanges(); + + expect(element.classList).toContain("no-print"); + expect(element.classList).not.toContain("show-print"); + }); + + it("adds show-print when visibility is show", () => { + fixture.componentRef.setInput("visibility", "show"); + fixture.detectChanges(); + + expect(element.classList).toContain("show-print"); + expect(element.classList).not.toContain("no-print"); + }); + + it("adds the break classes for each break input", () => { + fixture.componentRef.setInput("breakBefore", "avoid"); + fixture.componentRef.setInput("breakAfter", "auto"); + fixture.componentRef.setInput("breakInside", "avoid-page"); + fixture.detectChanges(); + + expect(element.classList).toContain("break-before-avoid"); + expect(element.classList).toContain("break-after-auto"); + expect(element.classList).toContain("break-inside-avoid-page"); + }); + + it("replaces the previous break class when the value changes", () => { + fixture.componentRef.setInput("breakInside", "avoid"); + fixture.detectChanges(); + expect(element.classList).toContain("break-inside-avoid"); + + fixture.componentRef.setInput("breakInside", "avoid-column"); + fixture.detectChanges(); + + expect(element.classList).not.toContain("break-inside-avoid"); + expect(element.classList).toContain("break-inside-avoid-column"); + }); + + it("removes the break class when the input is cleared", () => { + fixture.componentRef.setInput("breakBefore", "avoid-page"); + fixture.detectChanges(); + expect(element.classList).toContain("break-before-avoid-page"); + + fixture.componentRef.setInput("breakBefore", undefined); + fixture.detectChanges(); + + expect(element.classList).not.toContain("break-before-avoid-page"); + }); + + it("keeps the classes the consumer applied to the same element", () => { + fixture.componentRef.setInput("visibility", "hide"); + fixture.componentRef.setInput("breakInside", "avoid"); + fixture.detectChanges(); + + expect(element.classList).toContain("consumer-class"); + }); + + it("never removes a print class the element already had", () => { + element.classList.add("no-print"); + + fixture.componentRef.setInput("visibility", "hide"); + fixture.detectChanges(); + expect(element.classList).toContain("no-print"); + + fixture.componentRef.setInput("visibility", ""); + fixture.detectChanges(); + + expect(element.classList).toContain("no-print"); + }); +}); diff --git a/src/tedi/directives/print/print.directive.ts b/src/tedi/directives/print/print.directive.ts new file mode 100644 index 000000000..add35bffd --- /dev/null +++ b/src/tedi/directives/print/print.directive.ts @@ -0,0 +1,100 @@ +import { + Directive, + ElementRef, + Renderer2, + afterRenderEffect, + computed, + inject, + input, +} from "@angular/core"; + +export type PrintBreak = + "auto" | "avoid" | "avoid-column" | "avoid-page" | "avoid-region"; + +export type PrintVisibility = "show" | "hide"; + +/** + * Applies the print helper classes from `@tedi-design-system/core` to the host element. + */ +@Directive({ + selector: "[tediPrint]", + standalone: true, +}) +export class PrintDirective { + private readonly elementRef = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + /** + * Controls the visibility of the element when printing. + * - `'hide'` — the element is not printed. + * - `'show'` — the element opts out of a `no-print` applied to the same + * element. It cannot reveal an element that an ancestor hid. + * + * Leave empty when only the break inputs are needed — the `tediPrint` + * attribute still has to be present for the directive to apply. + */ + tediPrint = input(""); + /** + * Determines how page, column, or region breaks behave before the element. + * Uses the CSS `break-before` property values. + */ + breakBefore = input(); + /** + * Determines how page, column, or region breaks behave after the element. + * Uses the CSS `break-after` property values. + */ + breakAfter = input(); + /** + * Determines how page, column, or region breaks behave inside the element. + * Uses the CSS `break-inside` property values. + */ + breakInside = input(); + + private readonly classes = computed(() => { + const visibility = this.tediPrint(); + const breakBefore = this.breakBefore(); + const breakAfter = this.breakAfter(); + const breakInside = this.breakInside(); + + return [ + visibility === "hide" && "no-print", + visibility === "show" && "show-print", + breakBefore && `break-before-${breakBefore}`, + breakAfter && `break-after-${breakAfter}`, + breakInside && `break-inside-${breakInside}`, + ].filter((className): className is string => !!className); + }); + + constructor() { + /** Classes this directive added itself and is therefore allowed to remove. */ + let owned: string[] = []; + + /** + * Classes are synced through Renderer2 rather than host bindings so the + * directive never fights the element's own classes. A host binding takes + * precedence over a component's `[class]` binding and would strip classes + * that component set — e.g. the `no-print` that buttons and links apply to + * themselves. Here a class that is already on the element is left alone: + * the directive only removes what it added. + */ + afterRenderEffect(() => { + const next = this.classes(); + const element = this.elementRef.nativeElement; + + for (const className of owned) { + if (!next.includes(className)) { + this.renderer.removeClass(element, className); + } + } + + owned = next.filter((className) => { + if (element.classList.contains(className)) { + return owned.includes(className); + } + + this.renderer.addClass(element, className); + return true; + }); + }); + } +} diff --git a/src/tedi/directives/print/print.stories.ts b/src/tedi/directives/print/print.stories.ts new file mode 100644 index 000000000..44c2b2e2b --- /dev/null +++ b/src/tedi/directives/print/print.stories.ts @@ -0,0 +1,108 @@ +import { Meta, StoryObj } from "@storybook/angular"; +import { PrintDirective } from "./print.directive"; +import { ButtonComponent } from "../../components/buttons/button/button.component"; + +const BREAKS = ["auto", "avoid", "avoid-column", "avoid-page", "avoid-region"]; + +/** + * Zeroheight ↗ + * + * `tediPrint` applies the print helper classes from `@tedi-design-system/core` + * to the element it sits on. The classes only take effect inside `@media print`, + * so **open the browser's print preview** to see what any of these stories do. + * + * `tediPrint="show"` cancels a `no-print` on the same element — that is how you + * opt a component that hides itself when printing (buttons, links, header, + * sidenav, footer, breadcrumbs) back into the printed page. It cannot reveal an + * element that an ancestor hid. + * + * The break inputs need the attribute present, so use a bare `tediPrint` when + * visibility is not being changed: `
`. + **/ + +export default { + title: "TEDI-Ready/Components/Helpers/Print", + component: PrintDirective, + parameters: { + status: { + type: ["devComponent"], + }, + }, + argTypes: { + tediPrint: { + description: "Controls the visibility of the element when printing.", + control: { type: "radio", labels: { "": "none" } }, + options: ["", "show", "hide"], + table: { + type: { summary: "PrintVisibility | ''" }, + defaultValue: { summary: "''" }, + }, + }, + breakBefore: { + description: "Sets `break-before` on the element when printing.", + control: "select", + options: BREAKS, + table: { type: { summary: "PrintBreak" } }, + }, + breakAfter: { + description: "Sets `break-after` on the element when printing.", + control: "select", + options: BREAKS, + table: { type: { summary: "PrintBreak" } }, + }, + breakInside: { + description: "Sets `break-inside` on the element when printing.", + control: "select", + options: BREAKS, + table: { type: { summary: "PrintBreak" } }, + }, + }, +} as Meta; + +export const Default: StoryObj = { + args: { + tediPrint: "", + }, + render: (args) => ({ + props: args, + template: ` +
+

This section is controlled by the tediPrint directive.

+
+

This section is always printed.

+ `, + moduleMetadata: { + imports: [PrintDirective], + }, + }), +}; + +/** + * Buttons, links and the layout components hide themselves when printing. + * `tediPrint="show"` puts one back on the page. + */ +export const Visibility: StoryObj = { + render: () => ({ + template: ` +
+
+ + +
+

This paragraph prints on screen only.

+

+ This paragraph has no no-print to cancel, so + show changes nothing for it. +

+
+ `, + moduleMetadata: { + imports: [PrintDirective, ButtonComponent], + }, + }), +}; From 82b80c7542505f9e8083a3e8b8f74e9c57ecadf2 Mon Sep 17 00:00:00 2001 From: Rando Leppik Date: Mon, 14 Sep 2026 15:45:17 +0300 Subject: [PATCH 2/7] feat(print): hide header, sidenav, footer and breadcrumbs on print #651 --- .../layout/footer/footer.component.spec.ts | 8 +++++ .../layout/footer/footer.component.ts | 3 ++ .../layout/header/header.component.spec.ts | 35 +++++++++++++++++++ .../layout/header/header.component.ts | 2 +- .../layout/sidenav/sidenav.component.spec.ts | 4 +++ .../layout/sidenav/sidenav.component.ts | 6 +++- .../breadcrumbs/breadcrumbs.component.spec.ts | 8 +++++ .../breadcrumbs/breadcrumbs.component.ts | 2 +- 8 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 src/tedi/components/layout/header/header.component.spec.ts diff --git a/src/tedi/components/layout/footer/footer.component.spec.ts b/src/tedi/components/layout/footer/footer.component.spec.ts index 1bd6afc74..21b9a482b 100644 --- a/src/tedi/components/layout/footer/footer.component.spec.ts +++ b/src/tedi/components/layout/footer/footer.component.spec.ts @@ -52,4 +52,12 @@ describe("FooterComponent", () => { ); expect(containerElement.classList).toContain("tedi-footer--mobile"); }); + + it("should hide the footer when printing", () => { + mockBreakpointService.isBelowBreakpoint.mockReturnValue(signal(false)); + const fixture = TestBed.createComponent(FooterComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).toContain("no-print"); + }); }); diff --git a/src/tedi/components/layout/footer/footer.component.ts b/src/tedi/components/layout/footer/footer.component.ts index 656aec52c..f1686fc8e 100644 --- a/src/tedi/components/layout/footer/footer.component.ts +++ b/src/tedi/components/layout/footer/footer.component.ts @@ -13,6 +13,9 @@ import { BreakpointService } from "../../../services/breakpoint/breakpoint.servi selector: "tedi-footer", templateUrl: "./footer.component.html", styleUrl: "./footer.component.scss", + host: { + class: "no-print", + }, }) export class FooterComponent { breakpointService = inject(BreakpointService); diff --git a/src/tedi/components/layout/header/header.component.spec.ts b/src/tedi/components/layout/header/header.component.spec.ts new file mode 100644 index 000000000..c878bca4f --- /dev/null +++ b/src/tedi/components/layout/header/header.component.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from "@angular/core/testing"; +import { Component } from "@angular/core"; +import { By } from "@angular/platform-browser"; +import { HeaderComponent } from "./header.component"; + +@Component({ + standalone: true, + imports: [HeaderComponent], + template: `
`, +}) +class HostComponent {} + +describe("HeaderComponent", () => { + it("should apply the base class", () => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + const header = fixture.debugElement.query(By.directive(HeaderComponent)) + .nativeElement as HTMLElement; + + expect(header.classList).toContain("tedi-header"); + }); + + it("should hide the header when printing", () => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + const header = fixture.debugElement.query(By.directive(HeaderComponent)) + .nativeElement as HTMLElement; + + expect(header.classList).toContain("no-print"); + }); +}); diff --git a/src/tedi/components/layout/header/header.component.ts b/src/tedi/components/layout/header/header.component.ts index d1f12e036..e49bcbe1d 100644 --- a/src/tedi/components/layout/header/header.component.ts +++ b/src/tedi/components/layout/header/header.component.ts @@ -12,7 +12,7 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { - class: "tedi-header", + class: "tedi-header no-print", }, }) export class HeaderComponent {} diff --git a/src/tedi/components/layout/sidenav/sidenav.component.spec.ts b/src/tedi/components/layout/sidenav/sidenav.component.spec.ts index 5217f8c8c..a46153993 100644 --- a/src/tedi/components/layout/sidenav/sidenav.component.spec.ts +++ b/src/tedi/components/layout/sidenav/sidenav.component.spec.ts @@ -238,4 +238,8 @@ describe("SideNavComponent", () => { expect(document.body.style.overflow).toBe(""); }); }); + + it("should hide the sidenav when printing", () => { + expect(sidenavElement.classList).toContain("no-print"); + }); }); diff --git a/src/tedi/components/layout/sidenav/sidenav.component.ts b/src/tedi/components/layout/sidenav/sidenav.component.ts index 7c9acd19e..bcde91b1b 100644 --- a/src/tedi/components/layout/sidenav/sidenav.component.ts +++ b/src/tedi/components/layout/sidenav/sidenav.component.ts @@ -105,7 +105,11 @@ export class SideNavComponent { } classes = computed(() => { - const classList = ["tedi-sidenav", `tedi-sidenav--${this.size()}`]; + const classList = [ + "tedi-sidenav", + "no-print", + `tedi-sidenav--${this.size()}`, + ]; if (this.dividers()) { classList.push("tedi-sidenav--dividers"); diff --git a/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.spec.ts b/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.spec.ts index 0129dfcd7..7c8068426 100644 --- a/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.spec.ts +++ b/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.spec.ts @@ -269,4 +269,12 @@ describe("Breadcrumbs", () => { expect(separators(fixture)).toHaveLength(2); }); }); + + it("hides the breadcrumbs when printing", () => { + const fixture = setup(); + const breadcrumbs = fixture.debugElement.query(By.css("tedi-breadcrumbs")) + .nativeElement as HTMLElement; + + expect(breadcrumbs.classList).toContain("no-print"); + }); }); diff --git a/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.ts b/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.ts index 3eddbdfca..91faabfc5 100644 --- a/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.ts +++ b/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.ts @@ -69,7 +69,7 @@ type RenderToken = encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, host: { - class: "tedi-breadcrumbs", + class: "tedi-breadcrumbs no-print", }, }) export class BreadcrumbsComponent implements BreakpointInputs { From 4a8c724ff8aa4b442516d17480efbb0c3dd98ba4 Mon Sep 17 00:00:00 2001 From: Rando Leppik Date: Mon, 14 Sep 2026 15:48:33 +0300 Subject: [PATCH 3/7] feat(print): hide buttons on print #651 --- .../button-group-button.directive.ts | 1 + .../button-group.component.spec.ts | 4 + .../buttons/button/base-button.directive.ts | 2 +- .../buttons/button/button.component.spec.ts | 77 +++++++++++++++++++ .../card-button/card-button.component.spec.ts | 5 ++ .../closing-button.component.ts | 1 + .../closing-button/closing-button.spec.ts | 4 + .../collapse-button.component.spec.ts | 5 ++ .../collapse-button.component.ts | 2 +- .../info-button/info-button.component.spec.ts | 4 + .../info-button/info-button.component.ts | 2 +- .../navigation/link/link.component.spec.ts | 4 + src/tedi/directives/print/print.directive.ts | 2 +- src/tedi/directives/print/print.stories.ts | 6 +- 14 files changed, 112 insertions(+), 7 deletions(-) diff --git a/src/tedi/components/buttons/button-group/button-group-button/button-group-button.directive.ts b/src/tedi/components/buttons/button-group/button-group-button/button-group-button.directive.ts index 71f18fae4..6282f0f25 100644 --- a/src/tedi/components/buttons/button-group/button-group-button/button-group-button.directive.ts +++ b/src/tedi/components/buttons/button-group/button-group-button/button-group-button.directive.ts @@ -84,6 +84,7 @@ export class ButtonGroupButtonDirective implements AfterViewInit { [ "tedi-button", "tedi-button-group-button", + "no-print", `tedi-button--${this.effectiveVariant()}`, `tedi-button--${this.effectiveSize()}`, ].join(" "), diff --git a/src/tedi/components/buttons/button-group/button-group.component.spec.ts b/src/tedi/components/buttons/button-group/button-group.component.spec.ts index 8c1d6bd6b..8a41b8a8f 100644 --- a/src/tedi/components/buttons/button-group/button-group.component.spec.ts +++ b/src/tedi/components/buttons/button-group/button-group.component.spec.ts @@ -431,4 +431,8 @@ describe("ButtonGroupComponent", () => { expect(getRoot().getAttribute("role")).toBe("group"); })); }); + + it("should hide button group buttons when printing", () => { + getItems().forEach((item) => expect(item.classList).toContain("no-print")); + }); }); diff --git a/src/tedi/components/buttons/button/base-button.directive.ts b/src/tedi/components/buttons/button/base-button.directive.ts index b85ba5d97..13af1a615 100644 --- a/src/tedi/components/buttons/button/base-button.directive.ts +++ b/src/tedi/components/buttons/button/base-button.directive.ts @@ -47,7 +47,7 @@ export class BaseButtonDirective implements AfterContentChecked { } classes = computed(() => { - const classList = [this.classNamePrefix()]; + const classList = [this.classNamePrefix(), "no-print"]; if (this.iconOnly()) { classList.push(`${this.classNamePrefix()}--icon-only`); diff --git a/src/tedi/components/buttons/button/button.component.spec.ts b/src/tedi/components/buttons/button/button.component.spec.ts index 56886fdfe..8d312aa47 100644 --- a/src/tedi/components/buttons/button/button.component.spec.ts +++ b/src/tedi/components/buttons/button/button.component.spec.ts @@ -1,6 +1,22 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { IconComponent } from "../../base/icon/icon.component"; import { ButtonComponent, ButtonSize, ButtonVariant } from "./button.component"; +import { Component, input } from "@angular/core"; +import { By } from "@angular/platform-browser"; +import { PrintDirective, PrintVisibility } from "../../../directives/print"; + +@Component({ + standalone: true, + imports: [ButtonComponent, PrintDirective], + template: ` + + + + `, +}) +class PrintOverrideHost { + visibility = input("hide"); +} describe("ButtonComponent", () => { let fixture: ComponentFixture; @@ -63,4 +79,65 @@ describe("ButtonComponent", () => { it("should not contain 'undefined' in class list", () => { expect(buttonElement.classList).not.toContain("undefined"); }); + + it("should hide the button when printing", () => { + expect(buttonElement.classList).toContain("no-print"); + }); + + it("should keep show-print when the consumer opts back into printing", () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [PrintOverrideHost], + }); + + const hostFixture = TestBed.createComponent(PrintOverrideHost); + hostFixture.detectChanges(); + + const button = hostFixture.debugElement.queryAll( + By.directive(ButtonComponent), + )[0].nativeElement as HTMLElement; + + expect(button.classList).toContain("tedi-button"); + expect(button.classList).toContain("no-print"); + expect(button.classList).toContain("show-print"); + }); + + it("should keep the button hidden when only break inputs are set", () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [PrintOverrideHost], + }); + + const hostFixture = TestBed.createComponent(PrintOverrideHost); + hostFixture.detectChanges(); + + const button = hostFixture.debugElement.queryAll( + By.directive(ButtonComponent), + )[1].nativeElement as HTMLElement; + + expect(button.classList).toContain("no-print"); + expect(button.classList).toContain("break-inside-avoid"); + expect(button.classList).not.toContain("show-print"); + }); + + it("should keep the button's own no-print when tediPrint is cleared", () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [PrintOverrideHost], + }); + + const hostFixture = TestBed.createComponent(PrintOverrideHost); + hostFixture.detectChanges(); + + const button = hostFixture.debugElement.queryAll( + By.directive(ButtonComponent), + )[2].nativeElement as HTMLElement; + + expect(button.classList).toContain("no-print"); + + hostFixture.componentRef.setInput("visibility", ""); + hostFixture.detectChanges(); + + expect(button.classList).toContain("no-print"); + }); }); diff --git a/src/tedi/components/buttons/card-button/card-button.component.spec.ts b/src/tedi/components/buttons/card-button/card-button.component.spec.ts index af0e36802..dc031472e 100644 --- a/src/tedi/components/buttons/card-button/card-button.component.spec.ts +++ b/src/tedi/components/buttons/card-button/card-button.component.spec.ts @@ -97,4 +97,9 @@ describe("CardButtonComponent", () => { fixture.detectChanges(); expect(button().disabled).toBe(true); }); + + it("should stay printable, matching React's CardButton", () => { + expect(anchor().classList).not.toContain("no-print"); + expect(button().classList).not.toContain("no-print"); + }); }); diff --git a/src/tedi/components/buttons/closing-button/closing-button.component.ts b/src/tedi/components/buttons/closing-button/closing-button.component.ts index 12696783a..da1113cea 100644 --- a/src/tedi/components/buttons/closing-button/closing-button.component.ts +++ b/src/tedi/components/buttons/closing-button/closing-button.component.ts @@ -22,6 +22,7 @@ export type ClosingButtonIconSize = 18 | 24; host: { "[attr.title]": "showTitle() ? ariaLabel() || _defaultLabel() : null", "[attr.aria-label]": "ariaLabel() || _defaultLabel()", + class: "no-print", "[class.tedi-closing-button]": "true", "[class.tedi-closing-button--small]": "size() === 'small'", }, diff --git a/src/tedi/components/buttons/closing-button/closing-button.spec.ts b/src/tedi/components/buttons/closing-button/closing-button.spec.ts index 632b25a08..ba3e15d30 100644 --- a/src/tedi/components/buttons/closing-button/closing-button.spec.ts +++ b/src/tedi/components/buttons/closing-button/closing-button.spec.ts @@ -75,4 +75,8 @@ describe("ClosingButtonComponent", () => { expect(buttonElement.getAttribute("title")).toBeNull(); expect(buttonElement.getAttribute("aria-label")).toBe("Sulge"); }); + + it("should hide the closing button when printing", () => { + expect(buttonElement.classList).toContain("no-print"); + }); }); diff --git a/src/tedi/components/buttons/collapse-button/collapse-button.component.spec.ts b/src/tedi/components/buttons/collapse-button/collapse-button.component.spec.ts index f6cc0dd2c..1535f66b0 100644 --- a/src/tedi/components/buttons/collapse-button/collapse-button.component.spec.ts +++ b/src/tedi/components/buttons/collapse-button/collapse-button.component.spec.ts @@ -168,4 +168,9 @@ describe("CollapseButtonComponent", () => { const button: HTMLButtonElement = fixture.nativeElement; expect(button.getAttribute("type")).toBe("button"); }); + + it("should hide the collapse button when printing", () => { + const button: HTMLButtonElement = fixture.nativeElement; + expect(button.classList).toContain("no-print"); + }); }); diff --git a/src/tedi/components/buttons/collapse-button/collapse-button.component.ts b/src/tedi/components/buttons/collapse-button/collapse-button.component.ts index 62cfe747b..7509da189 100644 --- a/src/tedi/components/buttons/collapse-button/collapse-button.component.ts +++ b/src/tedi/components/buttons/collapse-button/collapse-button.component.ts @@ -121,7 +121,7 @@ export class CollapseButtonComponent { }); protected readonly hostClasses = computed(() => { - const classes = ["tedi-collapse-button"]; + const classes = ["tedi-collapse-button", "no-print"]; if (this.open()) classes.push("tedi-collapse-button--open"); if (this.size() === "small") classes.push("tedi-collapse-button--small"); if (this.inverted() && this.arrowType() !== "secondary") { diff --git a/src/tedi/components/buttons/info-button/info-button.component.spec.ts b/src/tedi/components/buttons/info-button/info-button.component.spec.ts index 480cf8377..795bd36e1 100644 --- a/src/tedi/components/buttons/info-button/info-button.component.spec.ts +++ b/src/tedi/components/buttons/info-button/info-button.component.spec.ts @@ -55,4 +55,8 @@ describe("InfoButtonComponent", () => { "tedi-info-button--inverted", ); }); + + it("should hide the info button when printing", () => { + expect(fixture.nativeElement.classList).toContain("no-print"); + }); }); diff --git a/src/tedi/components/buttons/info-button/info-button.component.ts b/src/tedi/components/buttons/info-button/info-button.component.ts index 954c9a7d2..3f10471d2 100644 --- a/src/tedi/components/buttons/info-button/info-button.component.ts +++ b/src/tedi/components/buttons/info-button/info-button.component.ts @@ -18,7 +18,7 @@ import { TediTranslationService } from "../../../services"; encapsulation: ViewEncapsulation.None, host: { type: "button", - class: "tedi-info-button", + class: "tedi-info-button no-print", "[class.tedi-info-button--inverted]": "color() === 'inverted'", "[attr.aria-label]": "ariaLabel() || _defaultLabel()", }, diff --git a/src/tedi/components/navigation/link/link.component.spec.ts b/src/tedi/components/navigation/link/link.component.spec.ts index 3554b858c..8f6bf4f63 100644 --- a/src/tedi/components/navigation/link/link.component.spec.ts +++ b/src/tedi/components/navigation/link/link.component.spec.ts @@ -69,4 +69,8 @@ describe("Link Component", () => { expect(linkElement.classList).toContain("tedi-link--no-underline"); }); + + it("should stay printable, unlike buttons", () => { + expect(linkElement.classList).not.toContain("no-print"); + }); }); diff --git a/src/tedi/directives/print/print.directive.ts b/src/tedi/directives/print/print.directive.ts index add35bffd..3a83842b7 100644 --- a/src/tedi/directives/print/print.directive.ts +++ b/src/tedi/directives/print/print.directive.ts @@ -73,7 +73,7 @@ export class PrintDirective { * Classes are synced through Renderer2 rather than host bindings so the * directive never fights the element's own classes. A host binding takes * precedence over a component's `[class]` binding and would strip classes - * that component set — e.g. the `no-print` that buttons and links apply to + * that component set — e.g. the `no-print` that buttons apply to * themselves. Here a class that is already on the element is left alone: * the directive only removes what it added. */ diff --git a/src/tedi/directives/print/print.stories.ts b/src/tedi/directives/print/print.stories.ts index 44c2b2e2b..fbb28ff9d 100644 --- a/src/tedi/directives/print/print.stories.ts +++ b/src/tedi/directives/print/print.stories.ts @@ -12,8 +12,8 @@ const BREAKS = ["auto", "avoid", "avoid-column", "avoid-page", "avoid-region"]; * so **open the browser's print preview** to see what any of these stories do. * * `tediPrint="show"` cancels a `no-print` on the same element — that is how you - * opt a component that hides itself when printing (buttons, links, header, - * sidenav, footer, breadcrumbs) back into the printed page. It cannot reveal an + * opt a component that hides itself when printing (buttons, header, sidenav, + * footer, breadcrumbs) back into the printed page. It cannot reveal an * element that an ancestor hid. * * The break inputs need the attribute present, so use a bare `tediPrint` when @@ -83,7 +83,7 @@ export const Default: StoryObj = { }; /** - * Buttons, links and the layout components hide themselves when printing. + * Buttons and the layout components hide themselves when printing. * `tediPrint="show"` puts one back on the page. */ export const Visibility: StoryObj = { From bc00203eb30ac983d795581a8b502c6ab7a8b90c Mon Sep 17 00:00:00 2001 From: Rando Leppik Date: Mon, 14 Sep 2026 15:49:27 +0300 Subject: [PATCH 4/7] feat(collapse): expand content when printing #651 --- .../buttons/collapse/collapse.component.scss | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/tedi/components/buttons/collapse/collapse.component.scss b/src/tedi/components/buttons/collapse/collapse.component.scss index 9e67c3c7d..84bc106d2 100644 --- a/src/tedi/components/buttons/collapse/collapse.component.scss +++ b/src/tedi/components/buttons/collapse/collapse.component.scss @@ -31,3 +31,19 @@ $transition-duration: 300ms; } } } + +@media print { + .tedi-collapse__content, + .tedi-collapse__extender { + transition: none; + } + + .tedi-collapse__content { + grid-template-rows: 1fr; + overflow: visible; + } + + .tedi-collapse__extender { + visibility: visible; + } +} From 812b10b9801450f83caaa1b28fe2b051f7c88924 Mon Sep 17 00:00:00 2001 From: Rando Leppik Date: Mon, 14 Sep 2026 15:50:23 +0300 Subject: [PATCH 5/7] feat(tabs): add printVisibility input to tabs list #651 --- .../tabs/tabs-list/tabs-list.component.ts | 11 +++ .../components/navigation/tabs/tabs.spec.ts | 68 ++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/tedi/components/navigation/tabs/tabs-list/tabs-list.component.ts b/src/tedi/components/navigation/tabs/tabs-list/tabs-list.component.ts index 810d8c0dd..36707b04d 100644 --- a/src/tedi/components/navigation/tabs/tabs-list/tabs-list.component.ts +++ b/src/tedi/components/navigation/tabs/tabs-list/tabs-list.component.ts @@ -26,6 +26,7 @@ import { TediTranslationPipe } from "../../../../services/translation/translatio import { TabsComponent } from "../tabs.component"; import { TabsTriggerComponent } from "../tabs-trigger/tabs-trigger.component"; import { TabsOverflowContentDirective } from "./tabs-overflow-content.directive"; +import { PrintVisibility } from "../../../../directives/print"; export type TabsOverflowMode = "dropdown" | "scroll"; @@ -57,6 +58,7 @@ interface OverflowItem { changeDetection: ChangeDetectionStrategy.OnPush, host: { class: "tedi-tabs-list", + "[class.no-print]": "printVisibility() === 'hide'", "[class.tedi-tabs-list--fade-start]": "overflowMode() === 'scroll' && canScrollStart()", "[class.tedi-tabs-list--fade-end]": @@ -83,6 +85,15 @@ export class TabsListComponent implements AfterViewInit, OnDestroy { readonly overflowMode = input("dropdown"); /** Label for the overflow dropdown trigger. Defaults to the `more` translation. */ readonly dropdownLabel = input(); + /** + * Controls the visibility of the tablist when printing. + * - `show`: the tablist is printed (default), no print class is applied + * - `hide`: the tablist is left out of the printed output + * + * `tediPrint` on the same element composes with this: `tediPrint="show"` + * wins over `printVisibility="hide"`, matching the core `show-print` rule. + */ + readonly printVisibility = input("show"); private readonly listRef = viewChild.required>("list"); diff --git a/src/tedi/components/navigation/tabs/tabs.spec.ts b/src/tedi/components/navigation/tabs/tabs.spec.ts index d2151f223..cac66e429 100644 --- a/src/tedi/components/navigation/tabs/tabs.spec.ts +++ b/src/tedi/components/navigation/tabs/tabs.spec.ts @@ -10,6 +10,7 @@ import { } from "./tabs-list/tabs-list.component"; import { TabsTriggerComponent } from "./tabs-trigger/tabs-trigger.component"; import { TabsContentComponent } from "./tabs-content/tabs-content.component"; +import { PrintDirective, PrintVisibility } from "../../../directives/print"; const mockAfterNextRender: { callback: (() => void) | null } = { callback: null, @@ -74,7 +75,11 @@ afterAll(() => { [defaultValue]="defaultValue()" (valueChange)="onChange($event)" > - + @@ -89,9 +94,28 @@ class HostComponent { value = input(); defaultValue = input("tab-1"); overflowMode = input("dropdown"); + printVisibility = input("show"); onChange = jest.fn(); } +@Component({ + standalone: true, + imports: [ + TabsComponent, + TabsListComponent, + TabsTriggerComponent, + PrintDirective, + ], + template: ` + + + + + + `, +}) +class PrintHost {} + const setup = () => { TestBed.configureTestingModule({ imports: [HostComponent], @@ -695,4 +719,46 @@ describe("Tabs", () => { expect(wrapper.classList.contains("tedi-tabs-list--fade-end")).toBe(true); }); }); + + describe("printing", () => { + it("keeps the tablist in the printed output by default", () => { + const fixture = setup(); + const list = fixture.debugElement.query(By.css("tedi-tabs-list")) + .nativeElement as HTMLElement; + + expect(list.classList).not.toContain("no-print"); + expect(list.classList).not.toContain("show-print"); + }); + + it("hides the tablist when printVisibility is hide", () => { + const fixture = setup(); + fixture.componentRef.setInput("printVisibility", "hide"); + fixture.detectChanges(); + + const list = fixture.debugElement.query(By.css("tedi-tabs-list")) + .nativeElement as HTMLElement; + + expect(list.classList).toContain("no-print"); + expect(list.classList).not.toContain("show-print"); + }); + + it("hides the tablist when tediPrint is set to hide", () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [PrintHost], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + const fixture = TestBed.createComponent(PrintHost); + fixture.detectChanges(); + + const list = fixture.debugElement.query(By.css("tedi-tabs-list")) + .nativeElement as HTMLElement; + + expect(list.classList).toContain("no-print"); + expect(list.classList).not.toContain("show-print"); + }); + }); }); From 6e089cd2ab89c71d67628191fe99675e2a9a67be Mon Sep 17 00:00:00 2001 From: Rando Leppik Date: Tue, 22 Sep 2026 10:55:52 +0300 Subject: [PATCH 6/7] docs(print): add printing guide #651 --- src/tedi/directives/print/printing.mdx | 81 ++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/tedi/directives/print/printing.mdx diff --git a/src/tedi/directives/print/printing.mdx b/src/tedi/directives/print/printing.mdx new file mode 100644 index 000000000..091a028bd --- /dev/null +++ b/src/tedi/directives/print/printing.mdx @@ -0,0 +1,81 @@ +import { Meta } from "@storybook/addon-docs/blocks"; +import * as PrintStories from "./print.stories"; + + + +# Printing in TEDI-Ready + +TEDI components include print styles for browser printing and saving as PDF. +These styles apply automatically. Use the `tediPrint` directive to customise +print visibility and page breaks. + +## What happens automatically + +Global, from `@tedi-design-system/core`: + +- Base font size is set to **14px**, with headings, spacing and line heights + scaling with it. +- The page uses a light neutral background. Core sets `print-color-adjust: exact` + to ask browsers to preserve the colours defined by the print styles. +- Links and body text are greyscaled. Links are still printed: they usually carry + the content itself, and a page saved as PDF keeps them clickable. + +**Hidden when printing** — action controls and page navigation: + +- Buttons: `tedi-button`, button group buttons, `tedi-collapse-button`, + `tedi-closing-button`, `tedi-info-button` +- `header[tedi-header]`, `tedi-footer`, `nav[tedi-sidenav]`, `tedi-breadcrumbs` + +**Visible when printing** — components whose content remains useful in print: + +- `tedi-card-button` — keeps the card content visible. +- `a[tedi-link]` — preserves linked text within the content. +- Tabs, including the tab list. Hide it with `printVisibility="hide"` on + `tedi-tabs-list` if the tabs are pure navigation. + +**Expanded when printing** — collapsed content is shown automatically: + +- `tedi-accordion` items +- `tedi-collapse` — the content prints, the toggle button does not + +Cards, card headers and alerts print, switching to neutral borders and +backgrounds rather than disappearing. + +**Browser support and page breaks** + +Modern browsers support CSS page-break controls, but complex layouts can still +paginate differently. Firefox continues to track +[flex layout pagination issues](https://bugzilla.mozilla.org/show_bug.cgi?id=939897). +Check multi-page content in both Firefox and Chrome print preview, especially +long flex layouts and tables. If content is clipped or breaks in an awkward +place, consider simplifying the layout for print or applying break controls to +smaller sections. See [MDN's `break-inside` reference](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/break-inside) +for how break rules are resolved. + +## Overriding: the `tediPrint` directive + +Apply `tediPrint` directly to an element. It adds the core print classes without +rendering an additional element. + +```html + +
Filters, toolbars, anything interactive
+ + + + + +
+

Stays on one page

+
+``` + +Note the bare `tediPrint` in the last example: the break inputs only apply when +the attribute is present. + +- `tediPrint`: `show` or `hide` adds `show-print` or `no-print` to the element. + `show` overrides `no-print` on the same element; it cannot reveal content inside + a hidden parent or make content visible only when printing. +- `breakBefore`, `breakAfter` and `breakInside` control breaks before, after and + inside the element. Supported values are `auto`, `avoid`, `avoid-column`, + `avoid-page` and `avoid-region`. These inputs cannot force a new page. From 2d96c5da31529ca8a089cbd69a5eb1fcb47cf450 Mon Sep 17 00:00:00 2001 From: Rando Leppik Date: Thu, 24 Sep 2026 14:06:48 +0300 Subject: [PATCH 7/7] fix(print): print breadcrumbs instead of hiding them #651 --- .../navigation/breadcrumbs/breadcrumbs.component.spec.ts | 4 ++-- .../navigation/breadcrumbs/breadcrumbs.component.ts | 2 +- src/tedi/directives/print/printing.mdx | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.spec.ts b/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.spec.ts index 7c8068426..f7e609c32 100644 --- a/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.spec.ts +++ b/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.spec.ts @@ -270,11 +270,11 @@ describe("Breadcrumbs", () => { }); }); - it("hides the breadcrumbs when printing", () => { + it("stays printable — breadcrumbs are context, not a control", () => { const fixture = setup(); const breadcrumbs = fixture.debugElement.query(By.css("tedi-breadcrumbs")) .nativeElement as HTMLElement; - expect(breadcrumbs.classList).toContain("no-print"); + expect(breadcrumbs.classList).not.toContain("no-print"); }); }); diff --git a/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.ts b/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.ts index 91faabfc5..3eddbdfca 100644 --- a/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.ts +++ b/src/tedi/components/navigation/breadcrumbs/breadcrumbs.component.ts @@ -69,7 +69,7 @@ type RenderToken = encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, host: { - class: "tedi-breadcrumbs no-print", + class: "tedi-breadcrumbs", }, }) export class BreadcrumbsComponent implements BreakpointInputs { diff --git a/src/tedi/directives/print/printing.mdx b/src/tedi/directives/print/printing.mdx index 091a028bd..24441b4d1 100644 --- a/src/tedi/directives/print/printing.mdx +++ b/src/tedi/directives/print/printing.mdx @@ -24,12 +24,13 @@ Global, from `@tedi-design-system/core`: - Buttons: `tedi-button`, button group buttons, `tedi-collapse-button`, `tedi-closing-button`, `tedi-info-button` -- `header[tedi-header]`, `tedi-footer`, `nav[tedi-sidenav]`, `tedi-breadcrumbs` +- `header[tedi-header]`, `tedi-footer`, `nav[tedi-sidenav]` **Visible when printing** — components whose content remains useful in print: - `tedi-card-button` — keeps the card content visible. - `a[tedi-link]` — preserves linked text within the content. +- `tedi-breadcrumbs` — the trail shows where the printed page came from. - Tabs, including the tab list. Hide it with `printVisibility="hide"` on `tedi-tabs-list` if the tabs are pure navigation.