Skip to content

Commit

Permalink
feat(sbb-datepicker): add support for DateAdapter
Browse files Browse the repository at this point in the history
This change adds support for the `DateAdapter` to all datepicker
related components (`<sbb-datepicker>`, `<sbb-datepicker-toggle>`,
`<sbb-datepicker-previous-day>` and `<sbb-datepicker-next-day>`).

Closes #2865
  • Loading branch information
kyubisation committed Jul 4, 2024
1 parent ce405e3 commit 9c54f9a
Show file tree
Hide file tree
Showing 11 changed files with 297 additions and 316 deletions.
3 changes: 2 additions & 1 deletion src/elements/calendar/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -152,7 +153,7 @@ export class SbbCalendarElement<T = Date> extends SbbHydrationMixin(LitElement)
/** A function used to filter out dates. */
@property({ attribute: 'date-filter' }) public dateFilter?: (date: T | null) => boolean;

private _dateAdapter: DateAdapter<T> = defaultDateAdapter as unknown as DateAdapter<T>;
private _dateAdapter: DateAdapter<T> = readConfig().datetime?.dateAdapter ?? defaultDateAdapter;

/** Event emitted on date selection. */
private _dateSelected: EventEmitter<T> = new EventEmitter(
Expand Down
62 changes: 48 additions & 14 deletions src/elements/core/datetime/date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -36,14 +38,14 @@ export abstract class DateAdapter<T = any> {
*/
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;

/**
Expand All @@ -67,20 +69,21 @@ export abstract class DateAdapter<T = any> {
/**
* 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;

/**
* Creates a new date, given day, month and year; without date's overflow.
* @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;

/**
Expand All @@ -90,7 +93,7 @@ export abstract class DateAdapter<T = any> {
* 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 ||
Expand All @@ -117,30 +120,60 @@ export abstract class DateAdapter<T = any> {
*/
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;
Expand All @@ -151,7 +184,8 @@ export abstract class DateAdapter<T = any> {
*/
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 {
Expand Down
28 changes: 2 additions & 26 deletions src/elements/core/datetime/native-date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export class NativeDateAdapter extends DateAdapter<Date> {
// 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));
Expand Down Expand Up @@ -201,26 +201,6 @@ export class NativeDateAdapter extends DateAdapter<Date> {
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);
}
Expand All @@ -231,11 +211,7 @@ export class NativeDateAdapter extends DateAdapter<Date> {
* @param valueFunction The function of array's index used to fill the array.
*/
private _range<T>(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. */
Expand Down
44 changes: 23 additions & 21 deletions src/elements/datepicker/common/datepicker-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<T = Date> extends SbbNegativeMixin(SbbButtonBaseElement) {
/** Datepicker reference. */
@property({ attribute: 'date-picker' }) public datePicker?: string | SbbDatepickerElement;
@property({ attribute: 'date-picker' }) public datePicker?: string | SbbDatepickerElement<T>;

/** The boundary date (min/max) as set in the date-picker's input. */
@state() protected boundary: string | number | null = null;
Expand All @@ -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<Date> = defaultDateAdapter;
protected datePickerElement?: SbbDatepickerElement<T> | null = null;
private _dateAdapter: DateAdapter<T> = readConfig().datetime?.dateAdapter ?? defaultDateAdapter;
private _datePickerController!: AbortController;
private _abort = new SbbConnectedAbortController(this);
private _language = new SbbLanguageController(this).withHandler(() => this._setAriaLabel());
Expand All @@ -37,11 +39,11 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase
protected abstract i18nOffBoundaryDay: Record<string, string>;
protected abstract i18nSelectOffBoundaryDay: (_currentDate: string) => Record<string, string>;
protected abstract findAvailableDate: (
_date: Date,
_dateFilter: ((date: Date) => boolean) | null,
_dateAdapter: DateAdapter<Date>,
_date: T,
_dateFilter: ((date: T) => boolean) | null,
_dateAdapter: DateAdapter<T>,
_boundary: string | number | null,
) => Date;
) => T;
protected abstract onInputUpdated(event: CustomEvent<SbbInputUpdateEvent>): void;

public override connectedCallback(): void {
Expand All @@ -66,16 +68,16 @@ 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<T> | null | undefined): void {
const pickerValueAsDate = datepicker?.valueAsDate;

if (!pickerValueAsDate) {
this._disabled = true;
this._setDisabledRenderAttributes(true);
return;
}

const availableDate: Date = this.findAvailableDate(
const availableDate: T = this.findAvailableDate(
pickerValueAsDate,
datepicker?.dateFilter || null,
this._dateAdapter,
Expand All @@ -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;
}
}

Expand All @@ -119,7 +120,7 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(SbbButtonBase
}
}

private _init(picker?: string | SbbDatepickerElement): void {
private _init(picker?: string | SbbDatepickerElement<T>): void {
this._datePickerController?.abort();
this._datePickerController = new AbortController();
this.datePickerElement = getDatePicker(this, picker);
Expand All @@ -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<SbbInputUpdateEvent>) => this._init(e.target as SbbDatepickerElement),
(e: CustomEvent<SbbInputUpdateEvent>) => this._init(e.target as SbbDatepickerElement<T>),
{ once: true, signal: this._datePickerController.signal },
);
return;
Expand All @@ -139,15 +140,15 @@ 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<T>);
this._setAriaLabel();
},
{ signal: this._datePickerController.signal },
);
this.datePickerElement.addEventListener(
'datePickerUpdated',
(event: Event) => {
this.setDisabledState(event.target as SbbDatepickerElement);
this.setDisabledState(event.target as SbbDatepickerElement<T>);
this._setAriaLabel();
},
{ signal: this._datePickerController.signal },
Expand All @@ -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]);
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import style from './datepicker-next-day.scss?lit&inline';
@hostAttributes({
slot: 'suffix',
})
export class SbbDatepickerNextDayElement extends SbbDatepickerButton {
export class SbbDatepickerNextDayElement<T = Date> extends SbbDatepickerButton<T> {
public static override styles: CSSResultGroup = style;

protected iconName: string = 'chevron-small-right-small';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import style from './datepicker-previous-day.scss?lit&inline';
@hostAttributes({
slot: 'prefix',
})
export class SbbDatepickerPreviousDayElement extends SbbDatepickerButton {
export class SbbDatepickerPreviousDayElement<T = Date> extends SbbDatepickerButton<T> {
public static override styles: CSSResultGroup = style;

protected iconName: string = 'chevron-small-left-small';
Expand Down
Loading

0 comments on commit 9c54f9a

Please sign in to comment.