Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sbb-datepicker): add support for DateAdapter #2889

Merged
merged 4 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
16 changes: 8 additions & 8 deletions src/elements/datepicker/datepicker-next-day/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ both standalone or within the `sbb-form-field`, they must have the same parent e

## Properties

| Name | Attribute | Privacy | Type | Default | Description |
| ------------ | ------------- | ------- | --------------------------------------------- | ---------- | ------------------------------------------------ |
| `datePicker` | `date-picker` | public | `string \| SbbDatepickerElement \| undefined` | | Datepicker reference. |
| `form` | `form` | public | `string \| undefined` | | The <form> element to associate the button with. |
| `name` | `name` | public | `string` | | The name of the button element. |
| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. |
| `type` | `type` | public | `SbbButtonType` | `'button'` | The type attribute to use for the button. |
| `value` | `value` | public | `string` | | The value of the button element. |
| Name | Attribute | Privacy | Type | Default | Description |
| ------------ | ------------- | ------- | ------------------------------------------------ | ---------- | ------------------------------------------------ |
| `datePicker` | `date-picker` | public | `string \| SbbDatepickerElement<T> \| undefined` | | Datepicker reference. |
| `form` | `form` | public | `string \| undefined` | | The <form> element to associate the button with. |
| `name` | `name` | public | `string` | | The name of the button element. |
| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. |
| `type` | `type` | public | `SbbButtonType` | `'button'` | The type attribute to use for the button. |
| `value` | `value` | public | `string` | | The value of the button element. |
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
Loading