diff --git a/src/elements/calendar/calendar.ts b/src/elements/calendar/calendar.ts index f6363cf259c..35d61336624 100644 --- a/src/elements/calendar/calendar.ts +++ b/src/elements/calendar/calendar.ts @@ -11,6 +11,7 @@ import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { isArrowKeyOrPageKeysPressed, sbbInputModalityDetector } from '../core/a11y.js'; +import { readConfig } from '../core/config.js'; import { SbbConnectedAbortController, SbbLanguageController } from '../core/controllers.js'; import type { DateAdapter } from '../core/datetime.js'; import { @@ -152,7 +153,7 @@ export class SbbCalendarElement extends SbbHydrationMixin(LitElement) /** A function used to filter out dates. */ @property({ attribute: 'date-filter' }) public dateFilter?: (date: T | null) => boolean; - private _dateAdapter: DateAdapter = defaultDateAdapter as unknown as DateAdapter; + private _dateAdapter: DateAdapter = readConfig().datetime?.dateAdapter ?? defaultDateAdapter; /** Event emitted on date selection. */ private _dateSelected: EventEmitter = new EventEmitter( diff --git a/src/elements/core/datetime/date-adapter.ts b/src/elements/core/datetime/date-adapter.ts index 2fe93f34d7f..f37df0adb89 100644 --- a/src/elements/core/datetime/date-adapter.ts +++ b/src/elements/core/datetime/date-adapter.ts @@ -5,6 +5,8 @@ export const YEARS_PER_PAGE: number = 24; export const FORMAT_DATE = /(^0?[1-9]?|[12]?[0-9]?|3?[01]?)[.,\\/\-\s](0?[1-9]?|1?[0-2]?)?[.,\\/\-\s](\d{1,4}$)?/; +// TODO(breaking-change): Change undefined return types to null. + /** * Abstract date functionality. * @@ -36,14 +38,14 @@ export abstract class DateAdapter { */ public abstract getDayOfWeek(date: T): number; - /* Get the first day of the week (0: sunday; 1: monday; etc.). */ + /** Get the first day of the week (0: sunday; 1: monday; etc.). */ public abstract getFirstDayOfWeek(): number; /** * Get the number of days in a month. * @param year * @param month - * */ + */ public abstract getNumDaysInMonth(date: T): number; /** @@ -67,12 +69,13 @@ export abstract class DateAdapter { /** * Checks whether a given `date` is valid. * @param date - * */ + */ public abstract isValid(date: T | null | undefined): boolean; - /** Creates a new date by cloning the given one. + /** + * Creates a new date by cloning the given one. * @param date - * */ + */ public abstract clone(date: T): T; /** @@ -80,7 +83,7 @@ export abstract class DateAdapter { * @param year * @param month The month of the date (1-indexed, 1 = January). Must be an integer 1 - 12. * @param date - * */ + */ public abstract createDate(year: number, month: number, date: number): T; /** @@ -90,7 +93,7 @@ export abstract class DateAdapter { * the given value is already a valid date object or null. * @param value Either Date, ISOString, Unix Timestamp (number of seconds since Jan 1, 1970). * @returns The date if the input is valid, `null` otherwise. - * */ + */ public deserialize(value: T | string | number | null | undefined): T | null { if ( value == null || @@ -117,30 +120,60 @@ export abstract class DateAdapter { */ public abstract addCalendarMonths(date: T, months: number): T; - /** Creates a new date by adding the number of provided `days` to the provided `date`. + /** + * Creates a new date by adding the number of provided `days` to the provided `date`. * @param date The starting date. * @param days The number of days to add. */ public abstract addCalendarDays(date: T, days: number): T; - /** Get the date in the local format. + /** + * Get the date in the local format. * @param date The date to format * @returns The `date` in the local format as string. */ public abstract getAccessibilityFormatDate(date: T | string): string; - /** Get the given string as Date. + /** + * Get the given string as Date. * @param value The date in the format DD.MM.YYYY. * @param now The current date as Date. */ public abstract parse(value: string | null | undefined, now: T): T | undefined; - /** Format the given Date as string. + /** + * Format the given date as string. * @param date The date to format. */ - public abstract format(date: T | null | undefined): string; + public format( + date: T | null | undefined, + options?: { weekdayStyle?: 'long' | 'short' | 'narrow' }, + ): string { + if (!this.isValid(date)) { + return ''; + } + + const value = new Date(this.toIso8601(date!) + 'T00:00:00'); + const dateFormatter = new Intl.DateTimeFormat('de-CH', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + + const weekdayStyle = options?.weekdayStyle ?? 'short'; + let weekday = this.getDayOfWeekNames(weekdayStyle)[this.getDayOfWeek(date!)]; + weekday = weekday.charAt(0).toUpperCase() + weekday.substring(1); + // We have the special requirement for date formats at SBB, where the short form + // for weekdays should be two characters in length. + if (weekdayStyle === 'short') { + weekday = weekday.substring(0, 2); + } - /** Checks whether the given `obj` is a Date. + return `${weekday}, ${dateFormatter.format(value)}`; + } + + /** + * Checks whether the given `obj` is a Date. * @param obj The object to check. */ public abstract isDateInstance(obj: any): boolean; @@ -151,7 +184,8 @@ export abstract class DateAdapter { */ public abstract invalid(): T; - /** Get the given date as ISO String. + /** + * Get the given date as ISO String. * @param date The date to convert to ISO String. */ public toIso8601(date: T): string { diff --git a/src/elements/core/datetime/native-date-adapter.ts b/src/elements/core/datetime/native-date-adapter.ts index ae7e7b55e9f..1b07e2ff7e0 100644 --- a/src/elements/core/datetime/native-date-adapter.ts +++ b/src/elements/core/datetime/native-date-adapter.ts @@ -164,7 +164,7 @@ export class NativeDateAdapter extends DateAdapter { // The `Date` constructor accepts formats other than ISO 8601, so we need to make sure the // string is the right format first. } else if (ISO_8601_REGEX.test(date)) { - return this.getValidDateOrNull(new Date(date)); + return this.getValidDateOrNull(new Date(date.includes('T') ? date : date + 'T00:00:00')); } } else if (typeof date === 'number') { return this.getValidDateOrNull(new Date(date * 1000)); @@ -201,26 +201,6 @@ export class NativeDateAdapter extends DateAdapter { return new Date(year, +match[2] - 1, +match[1]); } - public format(value: Date | null | undefined): string { - if (!value) { - return ''; - } - const locale = `${SbbLanguageController.current}-CH`; - const dateFormatter = new Intl.DateTimeFormat('de-CH', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); - const dayFormatter = new Intl.DateTimeFormat(locale, { - weekday: 'short', - }); - - let weekday = dayFormatter.format(value); - weekday = weekday.charAt(0).toUpperCase() + weekday.charAt(1); - - return `${weekday}, ${dateFormatter.format(value)}`; - } - public override invalid(): Date { return new Date(NaN); } @@ -231,11 +211,7 @@ export class NativeDateAdapter extends DateAdapter { * @param valueFunction The function of array's index used to fill the array. */ private _range(length: number, valueFunction: (index: number) => T): T[] { - const valuesArray = Array(length); - for (let i = 0; i < length; i++) { - valuesArray[i] = valueFunction(i); - } - return valuesArray; + return Array.from({ length }).map((_, i) => valueFunction(i)); } /** Creates a date but allows the month and date to overflow. */ diff --git a/src/elements/datepicker/common/datepicker-button.ts b/src/elements/datepicker/common/datepicker-button.ts index 386859607ed..0775daffbf7 100644 --- a/src/elements/datepicker/common/datepicker-button.ts +++ b/src/elements/datepicker/common/datepicker-button.ts @@ -2,6 +2,7 @@ import { html, type PropertyValues, type TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; import { SbbButtonBaseElement } from '../../core/base-elements.js'; +import { readConfig } from '../../core/config.js'; import { SbbConnectedAbortController, SbbLanguageController } from '../../core/controllers.js'; import { type DateAdapter, defaultDateAdapter } from '../../core/datetime.js'; import { i18nToday } from '../../core/i18n.js'; @@ -12,11 +13,12 @@ import { type SbbDatepickerElement, type SbbInputUpdateEvent, } from '../datepicker.js'; + import '../../icon.js'; -export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBaseElement) { +export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBaseElement) { /** Datepicker reference. */ - @property({ attribute: 'date-picker' }) public datePicker?: string | SbbDatepickerElement; + @property({ attribute: 'date-picker' }) public datePicker?: string | SbbDatepickerElement; /** The boundary date (min/max) as set in the date-picker's input. */ @state() protected boundary: string | number | null = null; @@ -27,8 +29,8 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase /** Whether the component is disabled due date-picker's input disabled. */ private _inputDisabled = false; - protected datePickerElement?: SbbDatepickerElement | null = null; - private _dateAdapter: DateAdapter = defaultDateAdapter; + protected datePickerElement?: SbbDatepickerElement | null = null; + private _dateAdapter: DateAdapter = readConfig().datetime?.dateAdapter ?? defaultDateAdapter; private _datePickerController!: AbortController; private _abort = new SbbConnectedAbortController(this); private _language = new SbbLanguageController(this).withHandler(() => this._setAriaLabel()); @@ -37,11 +39,11 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase protected abstract i18nOffBoundaryDay: Record; protected abstract i18nSelectOffBoundaryDay: (_currentDate: string) => Record; protected abstract findAvailableDate: ( - _date: Date, - _dateFilter: ((date: Date) => boolean) | null, - _dateAdapter: DateAdapter, + _date: T, + _dateFilter: ((date: T) => boolean) | null, + _dateAdapter: DateAdapter, _boundary: string | number | null, - ) => Date; + ) => T; protected abstract onInputUpdated(event: CustomEvent): void; public override connectedCallback(): void { @@ -66,8 +68,8 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase this._datePickerController?.abort(); } - protected setDisabledState(datepicker: SbbDatepickerElement | null | undefined): void { - const pickerValueAsDate = datepicker?.getValueAsDate?.(); + protected setDisabledState(datepicker: SbbDatepickerElement | null | undefined): void { + const pickerValueAsDate = datepicker?.valueAsDate; if (!pickerValueAsDate) { this._disabled = true; @@ -75,7 +77,7 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase return; } - const availableDate: Date = this.findAvailableDate( + const availableDate: T = this.findAvailableDate( pickerValueAsDate, datepicker?.dateFilter || null, this._dateAdapter, @@ -89,16 +91,15 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase if (!this.datePickerElement || this.hasAttribute('data-disabled')) { return; } - const startingDate: Date = - this.datePickerElement.getValueAsDate() ?? this.datePickerElement.now; - const date: Date = this.findAvailableDate( + const startingDate: T = this.datePickerElement.valueAsDate ?? this.datePickerElement.now; + const date: T = this.findAvailableDate( startingDate, this.datePickerElement.dateFilter, this._dateAdapter, this.boundary, ); if (this._dateAdapter.compareDate(date, startingDate) !== 0) { - this.datePickerElement.setValueAsDate(date); + this.datePickerElement.valueAsDate = date; } } @@ -119,7 +120,7 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase } } - private _init(picker?: string | SbbDatepickerElement): void { + private _init(picker?: string | SbbDatepickerElement): void { this._datePickerController?.abort(); this._datePickerController = new AbortController(); this.datePickerElement = getDatePicker(this, picker); @@ -129,7 +130,7 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase // assuming that the two components share the same parent element. this.parentElement?.addEventListener( 'inputUpdated', - (e: CustomEvent) => this._init(e.target as SbbDatepickerElement), + (e: CustomEvent) => this._init(e.target as SbbDatepickerElement), { once: true, signal: this._datePickerController.signal }, ); return; @@ -139,7 +140,7 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase this.datePickerElement.addEventListener( 'change', (event: Event) => { - this.setDisabledState(event.target as SbbDatepickerElement); + this.setDisabledState(event.target as SbbDatepickerElement); this._setAriaLabel(); }, { signal: this._datePickerController.signal }, @@ -147,7 +148,7 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase this.datePickerElement.addEventListener( 'datePickerUpdated', (event: Event) => { - this.setDisabledState(event.target as SbbDatepickerElement); + this.setDisabledState(event.target as SbbDatepickerElement); this._setAriaLabel(); }, { signal: this._datePickerController.signal }, @@ -167,7 +168,7 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase } private _setAriaLabel(): void { - const currentDate = this.datePickerElement?.getValueAsDate?.(); + const currentDate = this.datePickerElement?.valueAsDate; if (!currentDate || !this._dateAdapter.isValid(currentDate)) { this.setAttribute('aria-label', this.i18nOffBoundaryDay[this._language.current]); @@ -176,7 +177,8 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase // TODO: use toIsoString() instead of toDateString() const currentDateString = - this.datePickerElement?.now.toDateString() === currentDate.toDateString() + this.datePickerElement && + this._dateAdapter.compareDate(this.datePickerElement.now, currentDate) === 0 ? i18nToday[this._language.current].toLowerCase() : this._dateAdapter.getAccessibilityFormatDate(currentDate); diff --git a/src/elements/datepicker/datepicker-next-day/datepicker-next-day.ts b/src/elements/datepicker/datepicker-next-day/datepicker-next-day.ts index 6626e4a4c38..cfd6f4af12c 100644 --- a/src/elements/datepicker/datepicker-next-day/datepicker-next-day.ts +++ b/src/elements/datepicker/datepicker-next-day/datepicker-next-day.ts @@ -16,7 +16,7 @@ import style from './datepicker-next-day.scss?lit&inline'; @hostAttributes({ slot: 'suffix', }) -export class SbbDatepickerNextDayElement extends SbbDatepickerButton { +export class SbbDatepickerNextDayElement extends SbbDatepickerButton { public static override styles: CSSResultGroup = style; protected iconName: string = 'chevron-small-right-small'; diff --git a/src/elements/datepicker/datepicker-previous-day/datepicker-previous-day.ts b/src/elements/datepicker/datepicker-previous-day/datepicker-previous-day.ts index fc0fe7e8193..30cba55bec3 100644 --- a/src/elements/datepicker/datepicker-previous-day/datepicker-previous-day.ts +++ b/src/elements/datepicker/datepicker-previous-day/datepicker-previous-day.ts @@ -16,7 +16,7 @@ import style from './datepicker-previous-day.scss?lit&inline'; @hostAttributes({ slot: 'prefix', }) -export class SbbDatepickerPreviousDayElement extends SbbDatepickerButton { +export class SbbDatepickerPreviousDayElement extends SbbDatepickerButton { public static override styles: CSSResultGroup = style; protected iconName: string = 'chevron-small-left-small'; diff --git a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts index 7db2a242499..537644e7f2d 100644 --- a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts +++ b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts @@ -25,7 +25,9 @@ import '../../popover.js'; @hostAttributes({ slot: 'prefix', }) -export class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydrationMixin(LitElement)) { +export class SbbDatepickerToggleElement extends SbbNegativeMixin( + SbbHydrationMixin(LitElement), +) { public static override styles: CSSResultGroup = style; /** Datepicker reference. */ @@ -39,9 +41,9 @@ export class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydrationMix @state() private _renderCalendar = false; - private _datePickerElement: SbbDatepickerElement | null | undefined; + private _datePickerElement: SbbDatepickerElement | null | undefined; - private _calendarElement!: SbbCalendarElement; + private _calendarElement!: SbbCalendarElement; private _triggerElement!: SbbPopoverTriggerElement; @@ -96,7 +98,7 @@ export class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydrationMix private _init(datePicker?: string | SbbDatepickerElement): void { this._datePickerController?.abort(); this._datePickerController = new AbortController(); - this._datePickerElement = getDatePicker(this, datePicker); + this._datePickerElement = getDatePicker(this, datePicker); if (!this._datePickerElement) { // If the component is attached to the DOM before the datepicker, it has to listen for the datepicker init, // assuming that the two components share the same parent element. @@ -111,7 +113,7 @@ export class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydrationMix this._datePickerElement?.addEventListener( 'inputUpdated', (event: CustomEvent) => { - this._datePickerElement = event.target as SbbDatepickerElement; + this._datePickerElement = event.target as SbbDatepickerElement; this._disabled = !!(event.detail.disabled || event.detail.readonly); this._min = event.detail.min; this._max = event.detail.max; @@ -128,13 +130,16 @@ export class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydrationMix this._datePickerElement?.addEventListener( 'datePickerUpdated', (event: Event) => - this._configureCalendar(this._calendarElement, event.target as SbbDatepickerElement), + this._configureCalendar(this._calendarElement, event.target as SbbDatepickerElement), { signal: this._datePickerController.signal }, ); this._datePickerElement.dispatchEvent(datepickerControlRegisteredEventFactory()); } - private _configureCalendar(calendar: SbbCalendarElement, datepicker: SbbDatepickerElement): void { + private _configureCalendar( + calendar: SbbCalendarElement, + datepicker: SbbDatepickerElement, + ): void { if (!calendar || !datepicker) { return; } @@ -144,23 +149,21 @@ export class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydrationMix } private _datePickerChanged(event: Event): void { - this._datePickerElement = event.target as SbbDatepickerElement; - this._calendarElement.selected = this._datePickerElement.getValueAsDate(); + this._datePickerElement = event.target as SbbDatepickerElement; + if (this._calendarElement && this._datePickerElement.valueAsDate) { + this._calendarElement.selected = this._datePickerElement.valueAsDate; + } } - private _assignCalendar(calendar: SbbCalendarElement): void { + private _assignCalendar(calendar: SbbCalendarElement): void { if (this._calendarElement && this._calendarElement === calendar) { return; } this._calendarElement = calendar; - if ( - !this._datePickerElement || - !this._datePickerElement.getValueAsDate || - !this._calendarElement?.resetPosition - ) { + if (!this._datePickerElement?.valueAsDate || !this._calendarElement?.resetPosition) { return; } - this._calendarElement.selected = this._datePickerElement.getValueAsDate(); + this._calendarElement.selected = this._datePickerElement.valueAsDate; this._configureCalendar(this._calendarElement, this._datePickerElement); this._calendarElement.resetPosition(); } @@ -171,7 +174,7 @@ export class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydrationMix this._popoverElement.trigger = this._triggerElement; } - private _nowOrUndefined(): Date | undefined { + private _nowOrUndefined(): T | undefined { return this._datePickerElement?.hasCustomNow() ? this._datePickerElement.now : undefined; } @@ -202,12 +205,15 @@ export class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydrationMix .now=${this._nowOrUndefined()} ?wide=${this._datePickerElement?.wide} .dateFilter=${this._datePickerElement?.dateFilter} - @dateSelected=${(d: CustomEvent) => { - const newDate = new Date(d.detail); - this._calendarElement.selected = newDate; - this._datePickerElement?.setValueAsDate(newDate); + @dateSelected=${(d: CustomEvent) => { + this._calendarElement.selected = d.detail; + if (this._datePickerElement) { + this._datePickerElement.valueAsDate = d.detail; + } }} - ${ref((calendar?: Element) => this._assignCalendar(calendar as SbbCalendarElement))} + ${ref((calendar?: Element) => + this._assignCalendar(calendar as SbbCalendarElement), + )} >` : nothing} diff --git a/src/elements/datepicker/datepicker/datepicker.spec.ts b/src/elements/datepicker/datepicker/datepicker.spec.ts index b3aed55320c..eb68c782b92 100644 --- a/src/elements/datepicker/datepicker/datepicker.spec.ts +++ b/src/elements/datepicker/datepicker/datepicker.spec.ts @@ -63,7 +63,7 @@ describe(`sbb-datepicker`, () => { it('renders and interprets timestamp', async () => { const element = await fixture(html`
- +
`); @@ -355,7 +355,7 @@ describe(`sbb-datepicker`, () => { page.querySelector('sbb-datepicker')!; const elementNext: SbbDatepickerNextDayElement = page.querySelector('sbb-datepicker-next-day')!; - expect(getDatePicker(elementNext)).to.equal(picker); + expect(getDatePicker(elementNext)).to.equal(picker); }); it('returns the datepicker if its id is passed as trigger', async () => { @@ -369,7 +369,7 @@ describe(`sbb-datepicker`, () => { const picker: SbbDatepickerElement = page.querySelector('#picker')!; const elementPrevious: SbbDatepickerPreviousDayElement = page.querySelector('sbb-datepicker-previous-day')!; - expect(getDatePicker(elementPrevious, 'picker')).to.equal(picker); + expect(getDatePicker(elementPrevious, 'picker')).to.equal(picker); }); }); diff --git a/src/elements/datepicker/datepicker/datepicker.ssr.spec.ts b/src/elements/datepicker/datepicker/datepicker.ssr.spec.ts index 0ca7c8d8a60..53493078266 100644 --- a/src/elements/datepicker/datepicker/datepicker.ssr.spec.ts +++ b/src/elements/datepicker/datepicker/datepicker.ssr.spec.ts @@ -43,7 +43,7 @@ describe(`sbb-datepicker ssr`, () => { }, ); const datepicker = root.querySelector('sbb-datepicker')!; - expect(asIso8601(datepicker.getValueAsDate()!)).to.equal(asIso8601(new Date(2023, 0, 1))); + expect(asIso8601(datepicker.valueAsDate!)).to.equal(asIso8601(new Date(2023, 0, 1))); const datepickerToggle = root.querySelector('sbb-datepicker-toggle')!; await datepickerToggle.hydrationComplete; diff --git a/src/elements/datepicker/datepicker/datepicker.stories.ts b/src/elements/datepicker/datepicker/datepicker.stories.ts index d3a16cf3a71..c3579c0bcd9 100644 --- a/src/elements/datepicker/datepicker/datepicker.stories.ts +++ b/src/elements/datepicker/datepicker/datepicker.stories.ts @@ -298,9 +298,7 @@ const playStory = async ({ canvasElement }: StoryContext): Promise => { const changeEventHandler = async (event: Event): Promise => { const div = document.createElement('div'); - div.innerText = `valueAsDate is: ${await ( - event.target as SbbDatepickerElement - ).getValueAsDate()}.`; + div.innerText = `valueAsDate is: ${await (event.target as SbbDatepickerElement).valueAsDate}.`; document.getElementById('container-value')?.append(div); }; diff --git a/src/elements/datepicker/datepicker/datepicker.ts b/src/elements/datepicker/datepicker/datepicker.ts index f2d08a2f18a..576c0072f92 100644 --- a/src/elements/datepicker/datepicker/datepicker.ts +++ b/src/elements/datepicker/datepicker/datepicker.ts @@ -15,9 +15,6 @@ import type { SbbDatepickerToggleElement } from '../datepicker-toggle.js'; import style from './datepicker.scss?lit&inline'; -const FORMAT_DATE = - /(^0?[1-9]?|[12]?[0-9]?|3?[01]?)[.,\\/\-\s](0?[1-9]?|1?[0-2]?)?[.,\\/\-\s](\d{1,4}$)?/; - export interface SbbInputUpdateEvent { disabled?: boolean; readonly?: boolean; @@ -25,22 +22,26 @@ export interface SbbInputUpdateEvent { max?: string | number; } +// TODO(breaking-change): Inline deprecated functions in SbbDatepickerElement as public methods +// where possible and use these methods where the functions are currently used. + /** * Given a SbbDatepickerPreviousDayElement, a SbbDatepickerNextDayElement or a SbbDatepickerToggleElement component, * it returns the related SbbDatepickerElement reference, if exists. * @param element The element potentially connected to the SbbDatepickerElement. * @param trigger The id or the reference of the SbbDatePicker. */ -export function getDatePicker( - element: SbbDatepickerButton | SbbDatepickerToggleElement, +export function getDatePicker( + element: SbbDatepickerButton | SbbDatepickerToggleElement, trigger?: string | HTMLElement, -): SbbDatepickerElement | null | undefined { +): SbbDatepickerElement | null | undefined { if (!trigger) { - const parent = element.closest?.('sbb-form-field'); - return parent?.querySelector('sbb-datepicker'); + return element + .closest?.('sbb-form-field') + ?.querySelector>('sbb-datepicker'); } - return findReferencedElement(trigger); + return findReferencedElement>(trigger); } /** @@ -49,13 +50,15 @@ export function getDatePicker( * @param delta The number of days to add/subtract from the starting one. * @param dateFilter The dateFilter function from the SbbDatepickerElement. * @param dateAdapter The adapter class. + * + * @deprecated Not intended as public API. */ -export function getAvailableDate( - date: Date, +export function getAvailableDate( + date: T, delta: number, - dateFilter: ((date: Date) => boolean) | null, - dateAdapter: DateAdapter, -): Date { + dateFilter: ((date: T) => boolean) | null, + dateAdapter: DateAdapter, +): T { let availableDate = dateAdapter.addCalendarDays(date, delta); if (dateFilter) { @@ -74,13 +77,15 @@ export function getAvailableDate( * @param dateFilter The dateFilter function from the SbbDatepickerElement. * @param dateAdapter The adapter class. * @param min The minimum value to consider in calculations. + * + * @deprecated Not intended as public API. */ -export function findPreviousAvailableDate( - date: Date, - dateFilter: ((date: Date) => boolean) | null, - dateAdapter: DateAdapter, +export function findPreviousAvailableDate( + date: T, + dateFilter: ((date: T) => boolean) | null, + dateAdapter: DateAdapter, min: string | number | null, -): Date { +): T { const previousDate = getAvailableDate(date, -1, dateFilter, dateAdapter); const dateMin = dateAdapter.deserialize(min); @@ -100,13 +105,15 @@ export function findPreviousAvailableDate( * @param dateFilter The dateFilter function from the SbbDatepickerElement. * @param dateAdapter The adapter class. * @param max The maximum value to consider in calculations. + * + * @deprecated Not intended as public API. */ -export function findNextAvailableDate( - date: Date, - dateFilter: ((date: Date) => boolean) | null, - dateAdapter: DateAdapter, +export function findNextAvailableDate( + date: T, + dateFilter: ((date: T) => boolean) | null, + dateAdapter: DateAdapter, max: string | number | null, -): Date { +): T { const nextDate = getAvailableDate(date, 1, dateFilter, dateAdapter); const dateMax = dateAdapter.deserialize(max); @@ -126,15 +133,17 @@ export function findNextAvailableDate( * @param dateFilter The dateFilter function from the SbbDatepickerElement. * @param min The minimum value to consider in calculations. * @param max The maximum value to consider in calculations. + * + * @deprecated Not intended as public API. */ -export function isDateAvailable( - date: Date, - dateFilter: ((date: Date) => boolean) | null, +export function isDateAvailable( + date: T, + dateFilter: ((date: T) => boolean) | null, min: string | number | null | undefined, max: string | number | null | undefined, ): boolean { // TODO: Get date adapter from config - const dateAdapter: DateAdapter = defaultDateAdapter; + const dateAdapter: DateAdapter = readConfig().datetime?.dateAdapter ?? defaultDateAdapter; const dateMin = dateAdapter.deserialize(min); const dateMax = dateAdapter.deserialize(max); @@ -164,7 +173,7 @@ export const datepickerControlRegisteredEventFactory = (): CustomEvent => * @event {CustomEvent} validationChange - Emits whenever the internal validation state changes. */ @customElement('sbb-datepicker') -export class SbbDatepickerElement extends LitElement { +export class SbbDatepickerElement extends LitElement { public static override styles: CSSResultGroup = style; public static readonly events = { didChange: 'didChange', @@ -178,27 +187,43 @@ export class SbbDatepickerElement extends LitElement { @property({ type: Boolean }) public wide = false; /** A function used to filter out dates. */ - @property({ attribute: 'date-filter' }) public dateFilter: (date: Date | null) => boolean = () => + @property({ attribute: 'date-filter' }) public dateFilter: (date: T | null) => boolean = () => true; /** A function used to parse string value into dates. */ - @property({ attribute: 'date-parser' }) public dateParser?: (value: string) => Date | undefined; + @property({ attribute: 'date-parser' }) public dateParser?: (value: string) => T | undefined; /** A function used to format dates into the preferred string format. */ - @property() public format?: (date: Date) => string; + @property() public format?: (date: T) => string; /** Reference of the native input connected to the datepicker. */ @property() public input?: string | HTMLElement; + // TODO: Change undefined to null as a breaking change. /** A configured date which acts as the current date instead of the real current date. Recommended for testing purposes. */ @property() - public set now(value: SbbDateLike | undefined) { + public set now(value: SbbDateLike | undefined) { this._now = this._dateAdapter.getValidDateOrNull(this._dateAdapter.deserialize(value)); } - public get now(): Date { + public get now(): T { return this._now ?? this._dateAdapter.today(); } - private _now: Date | null = null; + private _now?: T | null; + + /** The currently selected date as a Date or custom date provider instance. */ + @property() + public set valueAsDate(value: SbbDateLike | null) { + this._valueAsDate = this._dateAdapter.getValidDateOrNull(this._dateAdapter.deserialize(value)); + if (this._tryApplyFormatToInput()) { + /* Emit blur event when value is changed programmatically to notify + frameworks that rely on that event to update form status. */ + this._inputElement!.dispatchEvent(new Event('blur', { composed: true })); + } + } + public get valueAsDate(): T | null { + return this._valueAsDate ?? null; + } + private _valueAsDate?: T | null; /** * @deprecated only used for React. Will probably be removed once React 19 is available. @@ -236,145 +261,50 @@ export class SbbDatepickerElement extends LitElement { SbbDatepickerElement.events.validationChange, ); - @state() private get _inputElement(): HTMLInputElement | null { - return this._inputElementState; - } - - private set _inputElement(value) { - const oldValue = this._inputElementState; - this._inputElementState = value; - this._registerInputElement(this._inputElementState, oldValue); - } - - private _inputElementState: HTMLInputElement | null = null; - - private _findInput(newValue: string | HTMLElement, oldValue: string | HTMLElement): void { - if (newValue !== oldValue) { - this._inputElement = findInput(this, this.input); - } - } - private _registerInputElement( - newValue: HTMLInputElement | null, - oldValue: HTMLInputElement | null, - ): void { - if (newValue !== oldValue) { - this._datePickerController?.abort(); - this._datePickerController = new AbortController(); - - if (!this._inputElement) { - return; - } - - this._inputObserver?.disconnect(); - this._inputObserver.observe(this._inputElement, { - attributeFilter: ['disabled', 'readonly', 'min', 'max', 'value'], - }); - - this._inputElement.type = 'text'; - - if (!this._inputElement.placeholder) { - this._inputElement.placeholder = i18nDatePickerPlaceholder[this._language.current]; - } - - this._inputElement.addEventListener( - 'change', - (event: Event) => { - if (!(event instanceof CustomEvent)) { - this._valueChanged(event); - } - }, - { - signal: this._datePickerController.signal, - }, - ); - } - } - - /** Gets the input value with the correct date format. */ - public getValueAsDate(): Date | undefined { - if (this._inputElement && this._inputElement.value) { - return this._parse(this._inputElement.value); - } - return undefined; - } - - /** Set the input value to the correctly formatted value. */ - public setValueAsDate(date: SbbDateLike): void { - const parsedDate = date instanceof Date ? date : new Date(date); - if (this._inputElement) { - this._formatAndUpdateValue(this._inputElement.value, parsedDate); - /* Emit blur event when value is changed programmatically to notify - frameworks that rely on that event to update form status. */ - this._inputElement.dispatchEvent(new Event('blur', { composed: true })); - } - } - - /** - * @internal - * Whether a custom now is configured. - */ - public hasCustomNow(): boolean { - return !!this._now; - } - - private _onInputPropertiesChange(mutationsList?: MutationRecord[]): void { - this._inputUpdated.emit({ - disabled: this._inputElement?.disabled, - readonly: this._inputElement?.readOnly, - min: this._inputElement?.min, - max: this._inputElement?.max, - }); - - if ( - this._inputElement && - mutationsList && - Array.from(mutationsList).some((e) => e.attributeName === 'value') - ) { - this._inputElement.value = this._getValidValue(this._inputElement.getAttribute('value')!); - } - } + @state() + private _inputElement: HTMLInputElement | null = null; + private _inputElementPlaceholderMutable = false; private _datePickerController!: AbortController; - private _inputObserver = new AgnosticMutationObserver(this._onInputPropertiesChange.bind(this)); + private _inputObserver = new AgnosticMutationObserver((mutationsList) => { + this._emitInputUpdated(); + if (this._inputElement && mutationsList?.some((e) => e.attributeName === 'value')) { + const value = this._inputElement.getAttribute('value'); + this.valueAsDate = this._dateAdapter.parse(value, this.now) ?? value; + } + }); - private _dateAdapter: DateAdapter = - readConfig().datetime?.dateAdapter ?? defaultDateAdapter; + private _dateAdapter: DateAdapter = readConfig().datetime?.dateAdapter ?? defaultDateAdapter; private _abort = new SbbConnectedAbortController(this); private _language = new SbbLanguageController(this).withHandler(() => { if (this._inputElement) { - this._inputElement.placeholder = i18nDatePickerPlaceholder[this._language.current]; - const valueAsDate = this.getValueAsDate(); - if (valueAsDate) { - this._inputElement.value = this._format(valueAsDate); + if (this._inputElementPlaceholderMutable) { + this._inputElement.placeholder = i18nDatePickerPlaceholder[this._language.current]; + } + if (this.valueAsDate) { + this._inputElement.value = this._format(this.valueAsDate); } } }); public override connectedCallback(): void { super.connectedCallback(); - const signal = this._abort.signal; - this.addEventListener('datepickerControlRegistered', () => this._onInputPropertiesChange(), { - signal, + this.addEventListener('datepickerControlRegistered', () => this._emitInputUpdated(), { + signal: this._abort.signal, }); - this._inputElement = findInput(this, this.input); + this._attachInput(); if (this._inputElement) { - this._inputElement.value = this._getValidValue(this._inputElement.value); - this._inputUpdated.emit({ - disabled: this._inputElement.disabled, - readonly: this._inputElement.readOnly, - min: this._inputElement.min, - max: this._inputElement.max, - }); + this._emitInputUpdated(); } } public override willUpdate(changedProperties: PropertyValues): void { super.willUpdate(changedProperties); - if (changedProperties.has('input')) { - this._findInput(this.input!, changedProperties.get('input')!); + if (changedProperties.has('input') && this.input! !== changedProperties.get('input')!) { + this._attachInput(); } if ( changedProperties.has('wide') || @@ -383,6 +313,9 @@ export class SbbDatepickerElement extends LitElement { ) { this._datePickerUpdated.emit(); } + if (changedProperties.has('valueAsDate')) { + this._setAriaLiveMessage(); + } } public override disconnectedCallback(): void { @@ -393,110 +326,141 @@ export class SbbDatepickerElement extends LitElement { protected override firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); - this._setAriaLiveMessage(this.getValueAsDate()); + this._setAriaLiveMessage(); } - private _parseAndFormatValue(value: string): string { - const d = this._parse(value); - return !this._dateAdapter.isValid(d) ? value : this._format(d!); + /** + * Gets the input value with the correct date format. + * @deprecated Use property valueAsDate instead. + */ + public getValueAsDate(): T | undefined { + return this.valueAsDate ?? undefined; } - private _createAndComposeDate(value: SbbDateLike): string { - const date = new Date(value); - return this._format(date); + /** + * Set the input value to the correctly formatted value. + * @deprecated Use property valueAsDate instead. + */ + public setValueAsDate(date: SbbDateLike): void { + this.valueAsDate = date; } - private _valueChanged(event: Event): void { - const value: string = (event.target as HTMLInputElement).value; - this._formatAndUpdateValue(value, this._parse(value)); + /** + * @internal + * Whether a custom now is configured. + */ + public hasCustomNow(): boolean { + return !!this._now; } - /** Applies the correct format to values and triggers event dispatch. */ - private _formatAndUpdateValue(value: string, valueAsDate: Date | null | undefined): void { - if (this._inputElement) { - this._inputElement.value = !this._dateAdapter.isValid(valueAsDate) - ? value - : this._format(valueAsDate!); - - const isEmptyOrValid = - !value || - (!!valueAsDate && - isDateAvailable( - valueAsDate, - this.dateFilter, - this._inputElement?.min, - this._inputElement?.max, - )); - const wasValid = !this._inputElement.hasAttribute('data-sbb-invalid'); - this._inputElement.toggleAttribute('data-sbb-invalid', !isEmptyOrValid); - if (wasValid !== isEmptyOrValid) { - this._validationChange.emit({ valid: isEmptyOrValid }); + private _attachInput(): void { + const input = findInput(this, this.input); + if (this._inputElement === input) { + return; + } else if (this._inputElement) { + this._datePickerController?.abort(); + this._inputObserver?.disconnect(); + } + + this._inputElement = input; + if (input) { + this._datePickerController = new AbortController(); + this._inputObserver.observe(input, { + attributeFilter: ['disabled', 'readonly', 'min', 'max', 'value'], + }); + + this._inputElementPlaceholderMutable = !input.placeholder; + input.type = 'text'; + if (this._inputElementPlaceholderMutable) { + input.placeholder = i18nDatePickerPlaceholder[this._language.current]; } - this._emitChange(valueAsDate!); + + const options: AddEventListenerOptions = { signal: this._datePickerController.signal }; + input.addEventListener('input', () => this._parseInput(), options); + input.addEventListener('change', () => this._handleInputChange(), options); + this._parseInput(true); + this._tryApplyFormatToInput(); + this._validateDate(); } } - /** Emits the change event. */ - private _emitChange(date: Date): void { - this._setAriaLiveMessage(date); + private _emitInputUpdated(): void { + const { disabled, readOnly: readonly, min, max } = this._inputElement ?? {}; + this._inputUpdated.emit({ disabled, readonly, min, max }); + } + private _handleInputChange(): void { + if (this._tryApplyFormatToInput()) { + return; + } + this._validateDate(); + this._setAriaLiveMessage(); this._change.emit(); this._didChange.emit(); + } - if (this._inputElement) { - this._inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); - this._inputElement.dispatchEvent( - new CustomEvent('change', { bubbles: true, composed: true }), - ); + private _tryApplyFormatToInput(): boolean { + if (!this._inputElement) { + return false; } - } - private _getValidValue(value: string): string { - if (!value) { - return ''; + const formattedDate = this.valueAsDate ? this._format(this.valueAsDate!) : ''; + if (formattedDate && this._inputElement.value !== formattedDate) { + this._inputElement.value = formattedDate; + this._inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + this._inputElement.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + return true; } - const match: RegExpMatchArray | null = value.match(FORMAT_DATE); + return false; + } - if (match?.index === 0) { - return this._parseAndFormatValue(value); - } else if (Number.isInteger(+value)) { - return this._createAndComposeDate(+value); - } else if (this._dateAdapter.isValid(new Date(value))) { - return this._createAndComposeDate(value); + private _validateDate(): void { + if (!this._inputElement) { + return; } - return value; + const isEmptyOrValid = + !this._inputElement.value || + (!!this.valueAsDate && + isDateAvailable( + this.valueAsDate, + this.dateFilter, + this._inputElement?.min, + this._inputElement?.max, + )); + const wasValid = !this._inputElement.hasAttribute('data-sbb-invalid'); + this._inputElement.toggleAttribute('data-sbb-invalid', !isEmptyOrValid); + if (wasValid !== isEmptyOrValid) { + this._validationChange.emit({ valid: isEmptyOrValid }); + } } - private _parse(value: string): Date | undefined { - return this.dateParser ? this.dateParser(value) : this._dateAdapter.parse(value, this.now); + private _parseInput(deserializeAsFallback = false): void { + const value = this._inputElement?.value ?? ''; + this._valueAsDate = this._dateAdapter.getValidDateOrNull( + this.dateParser + ? this.dateParser(value) + : this._dateAdapter.parse(value, this.now) ?? + (deserializeAsFallback ? this._dateAdapter.deserialize(value) : null), + ); } - private _format(date: Date): string { + private _format(date: T): string { return this.format ? this.format(date) : this._dateAdapter.format(date); } - private _setAriaLiveMessage(date?: Date): void { - const ariaLiveFormatter = new Intl.DateTimeFormat(`${this._language.current}-CH`, { - weekday: 'long', - }); - - const dateFormatter = new Intl.DateTimeFormat('de-CH', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); - + private _setAriaLiveMessage(): void { const containerElement: HTMLParagraphElement | null | undefined = this.shadowRoot?.querySelector?.('#status-container'); - if (containerElement) { - containerElement.innerText = date - ? `${i18nDateChangedTo[this._language.current]} ${ariaLiveFormatter.format( - date, - )}, ${dateFormatter.format(date)}` - : ''; + if (!containerElement) { + return; + } else if (!this.valueAsDate) { + containerElement.innerText = ''; + } else { + const date = this._dateAdapter.format(this.valueAsDate, { weekdayStyle: 'long' }); + containerElement.innerText = `${i18nDateChangedTo[this._language.current]} ${date}`; } }