diff --git a/src/elements/calendar/calendar.scss b/src/elements/calendar/calendar.scss index c016075b1e..a71b66a954 100644 --- a/src/elements/calendar/calendar.scss +++ b/src/elements/calendar/calendar.scss @@ -28,6 +28,10 @@ --sbb-calendar-cell-transition-easing-function: var(--sbb-animation-easing); --sbb-calendar-tables-gap: var(--sbb-spacing-fixed-10x); --sbb-calendar-table-animation-shift: #{sbb.px-to-rem-build(0.1)}; + + // While changing views, there would be a few frames where the height of the calendar collapses to just + // the height of the controls and then grow back to the height of the next view. + // By using 0.1ms this can be avoided. --sbb-calendar-table-animation-duration: 0.1ms; --sbb-calendar-table-column-spaces: 12; --sbb-calendar-control-view-change-height: #{sbb.px-to-rem-build(44)}; diff --git a/src/elements/calendar/calendar.snapshot.spec.ts b/src/elements/calendar/calendar.snapshot.spec.ts index 0a13c064ea..4d43539140 100644 --- a/src/elements/calendar/calendar.snapshot.spec.ts +++ b/src/elements/calendar/calendar.snapshot.spec.ts @@ -24,7 +24,6 @@ describe(`sbb-calendar`, () => { await expect(element).shadowDom.to.be.equalSnapshot(); }); - // We skip safari because it has an inconsistent behavior on ci environment - testA11yTreeSnapshot(undefined, undefined, { safari: true }); + testA11yTreeSnapshot(); }); }); diff --git a/src/elements/calendar/calendar.spec.ts b/src/elements/calendar/calendar.spec.ts index 8a18db6250..f43ffa5ed9 100644 --- a/src/elements/calendar/calendar.spec.ts +++ b/src/elements/calendar/calendar.spec.ts @@ -4,7 +4,7 @@ import { html } from 'lit/static-html.js'; import type { SbbSecondaryButtonElement } from '../button/secondary-button.js'; import { fixture } from '../core/testing/private.js'; -import { waitForCondition, waitForLitRender, EventSpy } from '../core/testing.js'; +import { EventSpy, waitForCondition, waitForLitRender } from '../core/testing.js'; import { SbbCalendarElement } from './calendar.js'; @@ -245,6 +245,44 @@ describe(`sbb-calendar`, () => { expect(firstDisabledMaxDate).to.have.attribute('aria-disabled', 'true'); }); + it('opens year view', async () => { + element.view = 'year'; + await waitForLitRender(element); + + expect(element.shadowRoot!.querySelector('.sbb-calendar__table-year-view')).not.to.be.null; + }); + + it('opens month view', async () => { + element.view = 'month'; + await waitForLitRender(element); + + expect(element.shadowRoot!.querySelector('.sbb-calendar__table-month-view')).not.to.be.null; + expect( + element.shadowRoot!.querySelector('#sbb-calendar__month-selection')!.textContent!.trim(), + ).to.be.equal('2023'); + }); + + it('opens month view with selected date', async () => { + element.selected = '2017-01-22'; + element.view = 'month'; + await waitForLitRender(element); + + expect( + element.shadowRoot!.querySelector('#sbb-calendar__month-selection')!.textContent!.trim(), + ).to.be.equal('2017'); + }); + + it('opens month view with current date', async () => { + element.selected = undefined; + element.now = '2022-08-15'; + element.view = 'month'; + await waitForLitRender(element); + + expect( + element.shadowRoot!.querySelector('#sbb-calendar__month-selection')!.textContent!.trim(), + ).to.be.equal('2022'); + }); + describe('navigation', () => { it('navigates left via keyboard', async () => { element.focus(); diff --git a/src/elements/calendar/calendar.stories.ts b/src/elements/calendar/calendar.stories.ts index c8bd8658b7..266e70c3e7 100644 --- a/src/elements/calendar/calendar.stories.ts +++ b/src/elements/calendar/calendar.stories.ts @@ -87,6 +87,13 @@ const max: InputType = { }, }; +const view: InputType = { + control: { + type: 'inline-radio', + }, + options: ['day', 'month', 'year'], +}; + const now: InputType = { control: { type: 'date', @@ -127,6 +134,7 @@ const defaultArgTypes: ArgTypes = { min, max, dateFilter, + view: view, now, }; @@ -137,6 +145,7 @@ const defaultArgs: Args = { wide: false, selected: isChromatic() ? new Date(2023, 0, 20) : today, now: isChromatic() ? new Date(2023, 0, 12, 0, 0, 0).valueOf() : undefined, + view: view.options![0], }; export const Calendar: StoryObj = { @@ -186,6 +195,12 @@ export const CalendarWideDynamicWidth: StoryObj = { args: { ...defaultArgs, wide: true }, }; +export const CalendarWithInitialYearSelection: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, view: view.options![2] }, +}; + const meta: Meta = { excludeStories: /.*DynamicWidth$/, decorators: [withActions as Decorator], diff --git a/src/elements/calendar/calendar.ts b/src/elements/calendar/calendar.ts index 35d6133662..9ed1423f8d 100644 --- a/src/elements/calendar/calendar.ts +++ b/src/elements/calendar/calendar.ts @@ -101,6 +101,9 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) /** If set to true, two months are displayed */ @property({ type: Boolean }) public wide = false; + /** The initial view of the calendar which should be displayed on opening. */ + @property() public view: CalendarView = 'day'; + /** The minimum valid date. Takes T Object, ISOString, and Unix Timestamp (number of seconds since Jan 1, 1970). */ @property() public set min(value: SbbDateLike | null) { @@ -238,10 +241,7 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) /** Resets the active month according to the new state of the calendar. */ public resetPosition(): void { - if (this._calendarView !== 'day') { - this._resetToDayView(); - } - this._activeDate = this.selected ?? this.now; + this._resetCalendarView(); this._init(); } @@ -268,6 +268,12 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) if (changedProperties.has('wide')) { this.resetPosition(); } + + if (changedProperties.has('view')) { + this._setChosenYear(); + this._chosenMonth = undefined; + this._nextCalendarView = this._calendarView = this.view; + } } protected override updated(changedProperties: PropertyValues): void { @@ -496,13 +502,23 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) /** Emits the selected date and sets it internally. */ private _selectDate(day: string): void { this._chosenMonth = undefined; - this._chosenYear = undefined; + this._setChosenYear(); if (this._selected !== day) { this._selected = day; this._dateSelected.emit(this._dateAdapter.deserialize(day)!); } } + private _setChosenYear(): void { + if (this.view === 'month') { + this._chosenYear = this._dateAdapter.getYear( + this._dateAdapter.deserialize(this._selected) ?? this.selected ?? this.now, + ); + } else { + this._chosenYear = undefined; + } + } + private _assignActiveDate(date: T): void { if (this._min && this._dateAdapter.compareDate(this._min, date) > 0) { this._activeDate = this._min; @@ -806,13 +822,16 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) : this._findNext(days, nextIndex, -verticalOffset); } - private _resetToDayView(): void { + private _resetCalendarView(initTransition = false): void { this._resetFocus = true; this._activeDate = this.selected ?? this.now; - this._chosenYear = undefined; + this._setChosenYear(); this._chosenMonth = undefined; - this._nextCalendarView = 'day'; - this._removeTable(); + this._nextCalendarView = this._calendarView = this.view; + + if (initTransition) { + this._startTableTransition(); + } } /** Render the view for the day selection. */ @@ -863,7 +882,7 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) @click=${() => { this._resetFocus = true; this._nextCalendarView = 'year'; - this._removeTable(); + this._startTableTransition(); }} > ${monthLabel} @@ -1016,7 +1035,7 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) id="sbb-calendar__month-selection" class="sbb-calendar__controls-change-date" aria-label=${`${i18nCalendarDateSelection[this._language.current]} ${this._chosenYear}`} - @click=${() => this._resetToDayView()} + @click=${() => this._resetCalendarView(true)} > ${this._chosenYear} ${this._wide ? ` - ${this._chosenYear! + 1}` : nothing} @@ -1104,7 +1123,7 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) this._dateAdapter.getDate(this._activeDate), ), ); - this._removeTable(); + this._startTableTransition(); } /** Render the view for the year selection. */ @@ -1163,7 +1182,7 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) id="sbb-calendar__year-selection" class="sbb-calendar__controls-change-date" aria-label="${i18nCalendarDateSelection[this._language.current]} ${yearLabel}" - @click=${() => this._resetToDayView()} + @click=${() => this._resetCalendarView(true)} > ${yearLabel} @@ -1230,7 +1249,7 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) this._dateAdapter.getDate(this._activeDate), ), ); - this._removeTable(); + this._startTableTransition(); } private get _getView(): TemplateResult { @@ -1261,11 +1280,11 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) } } - private _removeTable(): void { + private _startTableTransition(): void { this.toggleAttribute('data-transition', true); - this.shadowRoot!.querySelectorAll('table').forEach((e) => - e.classList.toggle('sbb-calendar__table-hide'), - ); + this.shadowRoot + ?.querySelectorAll('table') + ?.forEach((e) => e.classList.toggle('sbb-calendar__table-hide')); } protected override render(): TemplateResult { diff --git a/src/elements/calendar/readme.md b/src/elements/calendar/readme.md index 2e59c5ba53..47829d412e 100644 --- a/src/elements/calendar/readme.md +++ b/src/elements/calendar/readme.md @@ -70,6 +70,7 @@ For accessibility purposes, the component is rendered as a native table element | `min` | `min` | public | `T \| null` | | The minimum valid date. Takes T Object, ISOString, and Unix Timestamp (number of seconds since Jan 1, 1970). | | `now` | `now` | public | `T` | `null` | A configured date which acts as the current date instead of the real current date. Recommended for testing purposes. | | `selected` | `selected` | public | `T \| null` | | The selected date. Takes T Object, ISOString, and Unix Timestamp (number of seconds since Jan 1, 1970). | +| `view` | `view` | public | `CalendarView` | `'day'` | The initial view of the calendar which should be displayed on opening. | | `wide` | `wide` | public | `boolean` | `false` | If set to true, two months are displayed | ## Methods diff --git a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.spec.ts b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.spec.ts index 71c00f375c..36c1b26310 100644 --- a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.spec.ts +++ b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.spec.ts @@ -1,7 +1,9 @@ import { assert, expect } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; import { html } from 'lit/static-html.js'; import type { SbbCalendarElement } from '../../calendar.js'; +import { defaultDateAdapter } from '../../core/datetime/native-date-adapter.js'; import { fixture } from '../../core/testing/private.js'; import { EventSpy, waitForCondition, waitForLitRender } from '../../core/testing.js'; import type { SbbFormFieldElement } from '../../form-field.js'; @@ -181,4 +183,76 @@ describe(`sbb-datepicker-toggle`, () => { expect(changeSpy.count).to.be.equal(1); expect(blurSpy.count).to.be.equal(1); }); + + it('handles view property', async () => { + const element: SbbDatepickerToggleElement = await fixture( + html` + + + + `, + ); + + const didOpenEventSpy = new EventSpy(SbbPopoverElement.events.didOpen, element); + const didCloseEventSpy = new EventSpy(SbbPopoverElement.events.didClose, element); + const datepickerToggle = + element.querySelector('sbb-datepicker-toggle')!; + + // Open calendar + datepickerToggle.open(); + await waitForCondition(() => didOpenEventSpy.events.length === 1); + + // Year view should be active + const calendar = datepickerToggle.shadowRoot!.querySelector('sbb-calendar')!; + expect(calendar.shadowRoot!.querySelector('.sbb-calendar__table-year-view')!).not.to.be.null; + + // Select year + calendar.shadowRoot!.querySelectorAll('button')[5].click(); + await waitForLitRender(element); + await waitForCondition(() => !calendar.hasAttribute('data-transition')); + + // Select month + calendar.shadowRoot!.querySelectorAll('button')[5].click(); + await waitForLitRender(element); + await waitForCondition(() => !calendar.hasAttribute('data-transition')); + + // Select day + calendar.shadowRoot!.querySelectorAll('button')[5].click(); + await waitForLitRender(element); + await waitForCondition(() => !calendar.hasAttribute('data-transition')); + + // Expect selected date and closed calendar + expect(defaultDateAdapter.toIso8601(calendar.selected!)).to.be.equal('2020-05-05'); + await waitForCondition(() => didCloseEventSpy.events.length === 1); + + // Open again + datepickerToggle.open(); + await waitForCondition(() => didOpenEventSpy.events.length === 2); + + // Should open with year view again + expect(calendar.shadowRoot!.querySelector('.sbb-calendar__table-year-view')!).not.to.be.null; + expect( + calendar.shadowRoot!.querySelector('.sbb-calendar__selected')!.textContent!.trim(), + ).to.be.equal('2020'); + + // Close again + await sendKeys({ press: 'Escape' }); + await waitForCondition(() => didCloseEventSpy.events.length === 2); + + // Changing to month view + datepickerToggle.view = 'month'; + await waitForLitRender(element); + + // Open again + datepickerToggle.open(); + await waitForCondition(() => didOpenEventSpy.events.length === 3); + + // Month view should be active and correct year preselected + expect(calendar.shadowRoot!.querySelector('.sbb-calendar__table-month-view')!).not.to.be.null; + expect( + calendar + .shadowRoot!.querySelector('.sbb-calendar__controls-change-date')! + .textContent!.trim(), + ).to.be.equal('2020'); + }); }); diff --git a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.stories.ts b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.stories.ts index 8396608b43..1aa450437e 100644 --- a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.stories.ts +++ b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.stories.ts @@ -31,12 +31,21 @@ const negative: InputType = { }, }; +const view: InputType = { + control: { + type: 'inline-radio', + }, + options: ['day', 'month', 'year'], +}; + const defaultArgTypes: ArgTypes = { negative, + view: view, }; const defaultArgs: Args = { negative: false, + view: view.options![0], }; // Story interaction executed after the story renders @@ -104,6 +113,13 @@ export const InFormFieldNegative: StoryObj = { play: isChromatic() ? playStory : undefined, }; +export const InitialYearSelection: StoryObj = { + render: FormFieldTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, view: view.options![2] }, + play: isChromatic() ? playStory : undefined, +}; + const meta: Meta = { decorators: [withActions as Decorator], parameters: { diff --git a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts index 5ecc8d5bdd..aa2cb37fe5 100644 --- a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts +++ b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts @@ -3,7 +3,7 @@ import { html, isServer, LitElement, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; -import type { SbbCalendarElement } from '../../calendar.js'; +import type { CalendarView, SbbCalendarElement } from '../../calendar.js'; import { sbbInputModalityDetector } from '../../core/a11y.js'; import { SbbLanguageController } from '../../core/controllers.js'; import { hostAttributes } from '../../core/decorators.js'; @@ -33,6 +33,9 @@ export class SbbDatepickerToggleElement extends SbbNegativeMixin( /** Datepicker reference. */ @property({ attribute: 'date-picker' }) public datePicker?: string | SbbDatepickerElement; + /** The initial view of calendar which should be displayed on opening. */ + @property() public view: CalendarView = 'day'; + @state() private _disabled = false; @state() private _min: string | number | null | undefined = null; @@ -204,6 +207,7 @@ export class SbbDatepickerToggleElement extends SbbNegativeMixin( > ${this._renderCalendar ? html`