From 340698e212d7ed2e77dc6ebb37d23e40121cfff4 Mon Sep 17 00:00:00 2001 From: Rando Leppik Date: Wed, 30 Sep 2026 12:36:08 +0300 Subject: [PATCH] feat(calendar,date-field): support full-width modal calendar #505 --- .../calendar-day-grid.component.scss | 44 ++++++++++++++++++- .../content/calendar/calendar.component.scss | 18 ++++++-- .../calendar/calendar.component.spec.ts | 8 ++++ .../content/calendar/calendar.component.ts | 3 ++ .../content/calendar/calendar.stories.ts | 11 +++++ .../date-field-modal.component.spec.ts | 9 ++++ .../date-field-modal.component.ts | 11 ++++- .../date-field/date-field.component.spec.ts | 15 +++++++ .../form/date-field/date-field.component.ts | 7 ++- .../form/date-field/date-field.stories.ts | 18 +++++++- 10 files changed, 135 insertions(+), 9 deletions(-) diff --git a/src/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss b/src/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss index fd6f17f4b..7f9266ca9 100644 --- a/src/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss +++ b/src/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss @@ -35,7 +35,6 @@ } &--with-week-numbers { - .tedi-calendar-day-grid__header, .tedi-calendar-day-grid__row { grid-template-columns: repeat(8, var(--form-calendar-date-width)); @@ -191,3 +190,46 @@ pointer-events: none; } } + +.tedi-calendar--full-width .tedi-calendar-day-grid { + width: 100%; + + &__row { + min-height: var(--form-calendar-date-width); + } + + &__header, + &__row { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } + + &--with-week-numbers { + .tedi-calendar-day-grid__header, + .tedi-calendar-day-grid__row { + grid-template-columns: repeat(8, minmax(0, 1fr)); + } + } + + &__weekday, + &__week-number-header, + &__week-number { + width: 100%; + } + + &__day { + width: min(100%, var(--form-calendar-date-width)); + height: auto; + aspect-ratio: 1; + + &--range-start, + &--range-end, + &--range-middle, + &--range-preview-start, + &--range-preview-end, + &--range-preview-middle { + width: 100%; + height: var(--form-calendar-date-width); + aspect-ratio: auto; + } + } +} diff --git a/src/tedi/components/content/calendar/calendar.component.scss b/src/tedi/components/content/calendar/calendar.component.scss index 8c82df23b..f0946ae2d 100644 --- a/src/tedi/components/content/calendar/calendar.component.scss +++ b/src/tedi/components/content/calendar/calendar.component.scss @@ -21,10 +21,8 @@ // --_tedi-calendar-month-count keeps it correct before/without the measurement // (e.g. SSR): all months side by side. width: calc( - var( - --_tedi-calendar-fit-columns, - var(--_tedi-calendar-month-count, 1) - ) * var(--_tedi-calendar-month-width) + var(--_tedi-calendar-fit-columns, var(--_tedi-calendar-month-count, 1)) * + var(--_tedi-calendar-month-width) ); min-width: var(--_tedi-calendar-month-width); height: fit-content; @@ -34,6 +32,18 @@ --_tedi-calendar-day-columns: 8; } + &--full-width { + box-sizing: border-box; + width: 100%; + min-width: 0; + + .tedi-calendar__month { + box-sizing: border-box; + flex: 1 1 var(--_tedi-calendar-month-width); + min-width: min(100%, var(--_tedi-calendar-month-width)); + } + } + &__months { display: flex; flex-wrap: wrap; diff --git a/src/tedi/components/content/calendar/calendar.component.spec.ts b/src/tedi/components/content/calendar/calendar.component.spec.ts index e790edd59..d75cc095f 100644 --- a/src/tedi/components/content/calendar/calendar.component.spec.ts +++ b/src/tedi/components/content/calendar/calendar.component.spec.ts @@ -121,6 +121,14 @@ describe("CalendarComponent", () => { expect(grids.length).toBe(1); }); + it("only applies the full-width layout when requested", () => { + const host = fixture.nativeElement as HTMLElement; + expect(host.classList).not.toContain("tedi-calendar--full-width"); + fixture.componentRef.setInput("fullWidth", true); + fixture.detectChanges(); + expect(host.classList).toContain("tedi-calendar--full-width"); + }); + it("renders the header", () => { const header = fixture.debugElement.query( By.css(".tedi-calendar-header"), diff --git a/src/tedi/components/content/calendar/calendar.component.ts b/src/tedi/components/content/calendar/calendar.component.ts index e18b05686..259c46872 100644 --- a/src/tedi/components/content/calendar/calendar.component.ts +++ b/src/tedi/components/content/calendar/calendar.component.ts @@ -74,6 +74,7 @@ const YEAR_PAGE_SIZE = 12; "[class.tedi-calendar--multi-month]": "numberOfMonths() > 1", "[class.tedi-calendar--with-week-numbers]": "showWeekNumbers()", "[class.tedi-calendar--bordered]": "bordered()", + "[class.tedi-calendar--full-width]": "fullWidth()", "[style.--_tedi-calendar-month-count]": "numberOfMonths()", }, }) @@ -125,6 +126,8 @@ export class CalendarComponent implements ControlValueAccessor { * DateField overlay). */ readonly bordered = input(true); + /** Fill the available container width, distributing day columns evenly. */ + readonly fullWidth = input(false); /** * Matchers that mark dates as disabled. Each matcher can be a `Date`, `Date[]`, * `{ before }`, `{ after }`, `{ before, after }`, `{ from, to? }`, diff --git a/src/tedi/components/content/calendar/calendar.stories.ts b/src/tedi/components/content/calendar/calendar.stories.ts index 5fb78ff6f..f524d6220 100644 --- a/src/tedi/components/content/calendar/calendar.stories.ts +++ b/src/tedi/components/content/calendar/calendar.stories.ts @@ -222,6 +222,16 @@ export default { defaultValue: { summary: "true" }, }, }, + fullWidth: { + description: + "Fill the parent width and distribute weekday/date columns evenly. Ordinary day buttons retain their usual size; range backgrounds span their columns.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, numberOfMonths: { description: "How many consecutive months to render side by side. Useful for date-range selection.", @@ -365,6 +375,7 @@ export const Default: Story = { showWeekNumbers: false, showNavigation: true, numberOfMonths: 1, + fullWidth: false, required: false, inputDisabled: false, }, diff --git a/src/tedi/components/form/date-field/date-field-modal/date-field-modal.component.spec.ts b/src/tedi/components/form/date-field/date-field-modal/date-field-modal.component.spec.ts index 1b749dfb5..00a164c20 100644 --- a/src/tedi/components/form/date-field/date-field-modal/date-field-modal.component.spec.ts +++ b/src/tedi/components/form/date-field/date-field-modal/date-field-modal.component.spec.ts @@ -29,6 +29,7 @@ function makeData( showOutsideDays: true, showWeekNumbers: false, numberOfMonths: 1, + calendarFullWidth: false, monthYearSelectType: "dropdown", required: false, disabledMatchers: [], @@ -70,6 +71,14 @@ describe("DateFieldModalComponent", () => { expect(component).toBeTruthy(); }); + it("enables the full-width layout on the modal calendar", () => { + setup(makeData({ calendarFullWidth: true })); + const modal = fixture.nativeElement.querySelector(".tedi-date-field-modal"); + const calendar = fixture.nativeElement.querySelector(".tedi-calendar"); + expect(modal.classList).toContain("tedi-date-field-modal--full-width"); + expect(calendar.classList).toContain("tedi-calendar--full-width"); + }); + it("seeds draft from data.value", () => { const value = new Date(2026, 4, 14); setup(makeData({ value })); diff --git a/src/tedi/components/form/date-field/date-field-modal/date-field-modal.component.ts b/src/tedi/components/form/date-field/date-field-modal/date-field-modal.component.ts index de99c7dd9..d340b12ac 100644 --- a/src/tedi/components/form/date-field/date-field-modal/date-field-modal.component.ts +++ b/src/tedi/components/form/date-field/date-field-modal/date-field-modal.component.ts @@ -35,6 +35,7 @@ export interface DateFieldModalData { showOutsideDays: boolean; showWeekNumbers: boolean; numberOfMonths: number; + calendarFullWidth: boolean; monthYearSelectType: "dropdown" | "grid"; required: boolean; disabledMatchers: Matcher[]; @@ -62,7 +63,10 @@ export interface DateFieldModalData { TediTranslationPipe, ], template: ` - +

{{ "date-field.modal-title" | tediTranslate }}

@@ -77,6 +81,7 @@ export interface DateFieldModalData { [showOutsideDays]="data.showOutsideDays" [showWeekNumbers]="data.showWeekNumbers" [numberOfMonths]="data.numberOfMonths" + [fullWidth]="data.calendarFullWidth" [monthYearSelectType]="data.monthYearSelectType" [required]="data.required" [disabledMatchers]="data.disabledMatchers" @@ -116,6 +121,10 @@ export interface DateFieldModalData { align-items: center; justify-content: center; } + .tedi-date-field-modal--full-width .tedi-date-field-modal__content { + align-items: stretch; + justify-content: flex-start; + } `, ], }) diff --git a/src/tedi/components/form/date-field/date-field.component.spec.ts b/src/tedi/components/form/date-field/date-field.component.spec.ts index 90a77b01b..81dde7f31 100644 --- a/src/tedi/components/form/date-field/date-field.component.spec.ts +++ b/src/tedi/components/form/date-field/date-field.component.spec.ts @@ -717,6 +717,21 @@ describe("DateFieldComponent", () => { }); describe("modal-below-breakpoint", () => { + it("passes the opt-in full-width layout to the modal and removes the compact cap", () => { + const { component, modalService } = createField({ + modal: true, + calendarFullWidth: true, + }); + modalService.open.mockReturnValue({ + closed: { subscribe: jest.fn() }, + }); + + component.handleIconClick(); + const config = modalService.open.mock.calls[0][1]; + expect(config.data.calendarFullWidth).toBe(true); + expect(config.maxWidth).toBeUndefined(); + }); + it("opens a modal when below the configured breakpoint", () => { const { component, modalService } = createField({ modal: "md" }); const bp = TestBed.inject( diff --git a/src/tedi/components/form/date-field/date-field.component.ts b/src/tedi/components/form/date-field/date-field.component.ts index 8da8186ed..efc54ed5e 100644 --- a/src/tedi/components/form/date-field/date-field.component.ts +++ b/src/tedi/components/form/date-field/date-field.component.ts @@ -325,6 +325,8 @@ export class DateFieldComponent readonly modal = input(false); /** Make the modal fullscreen: `true` always, `false` never, breakpoint name → fullscreen below that breakpoint. Only applies when the calendar actually opens as a modal. */ readonly fullscreen = input(false); + /** Stretch the calendar across the modal body. Only applies in modal mode. */ + readonly calendarFullWidth = input(false); /** * Custom formatter for the input's display string. Overrides the locale-aware * default. Receives the `DateFieldValue` and returns a string. @@ -849,6 +851,7 @@ export class DateFieldComponent showOutsideDays: this.showOutsideDays(), showWeekNumbers: this.showWeekNumbers(), numberOfMonths: this.numberOfMonthsResolved(), + calendarFullWidth: this.calendarFullWidth(), monthYearSelectType: this.monthYearSelectType(), required: this.required(), disabledMatchers: this.resolvedDisabledMatchers(), @@ -873,7 +876,9 @@ export class DateFieldComponent // month count so a multi-month calendar fits side by side, and add the // modal's two outer borders so the border-box content leaves room for // it without a 2px horizontal scroll. - maxWidth: `calc(${this.numberOfMonthsResolved()} * var(--tedi-containers-03) + 2 * var(--tedi-borders-01))`, + maxWidth: this.calendarFullWidth() + ? undefined + : `calc(${this.numberOfMonthsResolved()} * var(--tedi-containers-03) + 2 * var(--tedi-borders-01))`, }, ); this.modalRef = ref; diff --git a/src/tedi/components/form/date-field/date-field.stories.ts b/src/tedi/components/form/date-field/date-field.stories.ts index afb4d60ce..db287a9a2 100644 --- a/src/tedi/components/form/date-field/date-field.stories.ts +++ b/src/tedi/components/form/date-field/date-field.stories.ts @@ -95,6 +95,7 @@ const COMMON_INPUTS = [ "hideOnScroll", "modal", "fullscreen", + "calendarFullWidth", "calendarTrigger", "numberOfMonths", ]; @@ -160,6 +161,7 @@ const renderSingle: NonNullable["render"]> = ( [hideOnScroll]="hideOnScroll" [modal]="modal" [fullscreen]="fullscreen" + [calendarFullWidth]="calendarFullWidth" [calendarTrigger]="calendarTrigger" [numberOfMonths]="numberOfMonths" [minDate]="minDate" @@ -244,6 +246,7 @@ export default { hideOnScroll: false, modal: false, fullscreen: false, + calendarFullWidth: false, calendarTrigger: "button", numberOfMonths: 1, minYear: null, @@ -616,6 +619,16 @@ export default { defaultValue: { summary: "false" }, }, }, + calendarFullWidth: { + description: + "Stretch the calendar across the modal body while keeping ordinary date buttons at their usual size. Only applies in modal mode.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, calendarTrigger: { description: "What opens the calendar. `button` opens it from the icon button; `input` also opens it when the text input is focused. Accepts a `BreakpointInput` for per-breakpoint behaviour.", @@ -1193,7 +1206,7 @@ export const MobileModal: Story = { -

Fullscreen modal (modal=true, fullscreen=true)

+

Fullscreen modal with full-width calendar

@@ -1214,7 +1228,7 @@ export const MobileModal: Story = { docs: { description: { story: - '`[modal]="true"` opens the calendar in a modal (with explicit Cancel/Confirm) instead of the popover, holding the selection as a draft until Confirm. By default the modal is centered; add `[fullscreen]="true"` to make it fill the screen — useful on small phones where vertical space is tight. Both `modal` and `fullscreen` accept the same union (`true | false | sm | md | lg | xl`), so e.g. `fullscreen="md"` only goes fullscreen below `md`.', + '`[modal]="true"` opens the calendar in a modal with Cancel/Confirm. The first field uses the compact centered layout; the second combines `[fullscreen]="true"` with `[calendarFullWidth]="true"` to spread the calendar across the fullscreen modal. Both `modal` and `fullscreen` accept `true | false | sm | md | lg | xl`.', }, }, },