Skip to content

Commit

Permalink
fix: handle datepicker association reliably
Browse files Browse the repository at this point in the history
  • Loading branch information
kyubisation committed Jan 13, 2025
1 parent 10564f2 commit c451e40
Show file tree
Hide file tree
Showing 16 changed files with 308 additions and 256 deletions.
2 changes: 1 addition & 1 deletion src/elements/core/datetime/date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export abstract class DateAdapter<T = any> {
* Checks whether a given `date` is valid.
* @param date
*/
public abstract isValid(date: T | null | undefined): boolean;
public abstract isValid(date: T | null | undefined): date is T;

/**
* Creates a new date by cloning the given one.
Expand Down
2 changes: 1 addition & 1 deletion src/elements/core/datetime/native-date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export class NativeDateAdapter extends DateAdapter<Date> {
}

/** Checks whether the given `date` is a valid Date. */
public isValid(date: Date | null | undefined): boolean {
public isValid(date: Date | null | undefined): date is Date {
return !!date && !isNaN(date.valueOf());
}

Expand Down
1 change: 1 addition & 0 deletions src/elements/datepicker/common.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './common/datepicker-button.js';
export * from './common/datepicker-association-controllers.js';
115 changes: 115 additions & 0 deletions src/elements/datepicker/common/datepicker-association-controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { LitElement, ReactiveController } from 'lit';

import type { SbbDatepickerElement } from '../datepicker/datepicker.js';

export interface SbbDatepickerControl<T> extends LitElement {
datepicker: SbbDatepickerElement<T> | null;
}

class SbbDatepickerAssociationContext<T> {
private static _registry = new WeakMap<Node, SbbDatepickerAssociationContext<any>>();

public readonly hosts = new Set<SbbDatepickerElement<T>>();
public readonly controls = new Set<SbbDatepickerControl<T>>();

private constructor(private _host: Node) {}

public static connect<T>(rootNode: Node): SbbDatepickerAssociationContext<T> {
let context = this._registry.get(rootNode);
if (!context) {
context = new SbbDatepickerAssociationContext<T>(rootNode);
this._registry.set(rootNode, context);
}

return context;
}

public updateControls(datepicker: SbbDatepickerElement<T>): void {
Array.from(this.controls)
.filter((c) => c.datepicker === datepicker)
.forEach((c) => c.requestUpdate());
}

public deleteHost(datepicker: SbbDatepickerElement<T>): undefined {
this.hosts.delete(datepicker);
this._deleteIfEmpty();
}

public deleteControl(control: SbbDatepickerControl<T>): undefined {
this.controls.delete(control);
this._deleteIfEmpty();
}

private _deleteIfEmpty(): void {
if (!this.controls.size && !this.hosts.size) {
(this.constructor as typeof SbbDatepickerAssociationContext)._registry.delete(this._host);
}
}
}

export class SbbDatepickerAssociationHostController<T> implements ReactiveController {
private _context?: SbbDatepickerAssociationContext<T>;

public constructor(private _host: SbbDatepickerElement<T>) {
this._host.addController(this);
}

public hostConnected(): void {
this._context = SbbDatepickerAssociationContext.connect(this._host.getRootNode());
this._context.hosts.add(this._host);
const formField = this._host.closest('sbb-form-field');
for (const control of this._context.controls) {
// TODO: Remove date-picker once datePicker in the controls has been removed.
const datepickerAttribute =
control.getAttribute('datepicker') ?? control.getAttribute('date-picker');
if (
datepickerAttribute
? datepickerAttribute === this._host.id
: control.closest('sbb-form-field') === formField
) {
control.datepicker = this._host;
}
}
}

public hostDisconnected(): void {
this._context?.deleteHost(this._host);
}

public updateControls(): void {
this._context?.updateControls(this._host);
}
}

export class SbbDatepickerAssociationControlController<T> implements ReactiveController {
private _context?: SbbDatepickerAssociationContext<T>;

public constructor(private _host: SbbDatepickerControl<T>) {
this._host.addController(this);
}

public hostConnected(): void {
this._context = SbbDatepickerAssociationContext.connect(this._host.getRootNode());
this._context.controls.add(this._host);
const formField = this._host.closest('sbb-form-field');
let datepicker: SbbDatepickerElement<T> | null = null;
// TODO: Remove date-picker once datePicker in the controls has been removed.
const datepickerId =
this._host.getAttribute('datepicker') ?? this._host.getAttribute('date-picker');
if (datepickerId) {
datepicker = Array.from(this._context.hosts).find((d) => d.id === datepickerId) ?? null;
} else {
datepicker =
Array.from(this._context.hosts).find((d) => d.closest('sbb-form-field') === formField) ??
null;
}

if (datepicker) {
this._host.datepicker = datepicker;
}
}

public hostDisconnected(): void {
this._context?.deleteControl(this._host);
}
}
188 changes: 69 additions & 119 deletions src/elements/datepicker/common/datepicker-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,51 @@ import { SbbLanguageController } from '../../core/controllers.js';
import { type DateAdapter, defaultDateAdapter } from '../../core/datetime.js';
import { i18nToday } from '../../core/i18n.js';
import { SbbNegativeMixin } from '../../core/mixins.js';
import type { SbbDatepickerElement } from '../datepicker.js';

import {
datepickerControlRegisteredEventFactory,
getDatePicker,
type SbbDatepickerElement,
type SbbInputUpdateEvent,
} from '../datepicker.js';
SbbDatepickerAssociationControlController,
type SbbDatepickerControl,
} from './datepicker-association-controllers.js';

import '../../icon.js';

export abstract class SbbDatepickerButton<T = Date> extends SbbNegativeMixin(SbbButtonBaseElement) {
export abstract class SbbDatepickerButton<T = Date>
extends SbbNegativeMixin(SbbButtonBaseElement)
implements SbbDatepickerControl<T>
{
/**
* Datepicker reference.
* @deprecated Use property/attribute datepicker instead.
*/
@property({ attribute: 'date-picker' })
public set datePicker(value: string | SbbDatepickerElement<T> | null) {
if (import.meta.env.DEV) {
console.warn(
`Property datePicker/Attribute date-picker is deprecated. Use datepicker instead.`,
);
}

Check warning on line 33 in src/elements/datepicker/common/datepicker-button.ts

View check run for this annotation

Codecov / codecov/patch

src/elements/datepicker/common/datepicker-button.ts#L30-L33

Added lines #L30 - L33 were not covered by tests
this.datepicker = value as unknown as SbbDatepickerElement<T> | null;
}
public get datePicker(): string | SbbDatepickerElement<T> | null {
return this.datepicker;
}

/** Datepicker reference. */
@property({ attribute: 'date-picker' }) public accessor datePicker:
| string
| SbbDatepickerElement<T>
| null = null;
@property({ attribute: 'datepicker' })
public set datepicker(value: SbbDatepickerElement<T> | null) {
this._datepicker =
typeof value === 'string'
? // In case the value is a string, it should be treated as an id reference
// and attempt to be resolved.
((this.getRootNode?.() as ParentNode | undefined)?.querySelector?.(`#${value}`) ?? null)
: value;
this.datePickerElement = this._datepicker;
}
public get datepicker(): SbbDatepickerElement<T> | null {
return this._datepicker ?? null;
}
private _datepicker?: SbbDatepickerElement<T> | null;

/** The boundary date (min/max) as set in the date-picker's input. */
@state() protected accessor boundary: string | number | null = null;
Expand All @@ -32,150 +62,70 @@ export abstract class SbbDatepickerButton<T = Date> extends SbbNegativeMixin(Sbb
/** Whether the component is disabled due date-picker's input disabled. */
private _inputDisabled = false;

/**
* @deprecated Use datepicker instead.
*/
protected datePickerElement?: SbbDatepickerElement<T> | null = null;
private _dateAdapter: DateAdapter<T> = readConfig().datetime?.dateAdapter ?? defaultDateAdapter;
private _datePickerController!: AbortController;
private _language = new SbbLanguageController(this).withHandler(() => this._setAriaLabel());
private _language = new SbbLanguageController(this);

protected abstract iconName: string;
protected abstract i18nOffBoundaryDay: Record<string, string>;
protected abstract i18nSelectOffBoundaryDay: (_currentDate: string) => Record<string, string>;

public constructor() {
super();
this.addController(new SbbDatepickerAssociationControlController(this));
this.addEventListener?.('click', () => this._handleClick());
}

protected abstract findAvailableDate(_date: T): T;

public override connectedCallback(): void {
super.connectedCallback();
this._syncUpstreamProperties();
if (!this.datePicker) {
this._init();
}
}

public override willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);

if (changedProperties.has('datePicker')) {
this._init(this.datePicker!);
}
}

public override disconnectedCallback(): void {
super.disconnectedCallback();
this._datePickerController?.abort();
}

private _setDisabledState(): void {
const pickerValueAsDate = this.datePickerElement?.valueAsDate;

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

const availableDate: T = this.findAvailableDate(pickerValueAsDate);
this._disabled = this._dateAdapter.compareDate(availableDate, pickerValueAsDate) === 0;
this._setDisabledRenderAttributes();
}

private _handleClick(): void {
if (!this.datePickerElement || this.hasAttribute('data-disabled')) {
return;
}
const startingDate: T = this.datePickerElement.valueAsDate ?? this.datePickerElement.now;
const date: T = this.findAvailableDate(startingDate);
if (this._dateAdapter.compareDate(date, startingDate) !== 0) {
this.datePickerElement.valueAsDate = date;
}
}

private _syncUpstreamProperties(): void {
const formField = this.closest?.('sbb-form-field') ?? this.closest?.('[data-form-field]');
if (formField) {
this.negative = formField.hasAttribute('negative');

const inputElement = formField.querySelector('input');

if (inputElement) {
this._inputDisabled =
inputElement.hasAttribute('disabled') || inputElement.hasAttribute('readonly');
this._setDisabledRenderAttributes();
}
}
}

private _init(picker?: string | SbbDatepickerElement<T>): void {
this._datePickerController?.abort();
this._datePickerController = new AbortController();
this.datePickerElement = getDatePicker(this, picker);
this._setDisabledState();
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.
this.parentElement?.addEventListener(
'inputUpdated',
(e: CustomEvent<SbbInputUpdateEvent>) => this._init(e.target as SbbDatepickerElement<T>),
{ once: true, signal: this._datePickerController.signal },
);
return;
}
this._setAriaLabel();

this.datePickerElement.addEventListener(
'change',
() => {
this._setDisabledState();
this._setAriaLabel();
},
{ signal: this._datePickerController.signal },
);
this.datePickerElement.addEventListener(
'datePickerUpdated',
() => {
this._setDisabledState();
this._setAriaLabel();
},
{ signal: this._datePickerController.signal },
);
this.datePickerElement.addEventListener(
'inputUpdated',
(event: CustomEvent<SbbInputUpdateEvent>) => {
this._inputDisabled = !!(event.detail.disabled || event.detail.readonly);
this._setDisabledRenderAttributes();
this._setAriaLabel();
this._setDisabledState();
},
{ signal: this._datePickerController.signal },
);

this.datePickerElement.dispatchEvent(datepickerControlRegisteredEventFactory());
}

private _setAriaLabel(): void {
const currentDate = this.datePickerElement?.valueAsDate;

if (!currentDate || !this._dateAdapter.isValid(currentDate)) {
protected override willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (!this.datepicker || !this._dateAdapter.isValid(this.datepicker.valueAsDate)) {
this._disabled = true;
this.setAttribute('aria-label', this.i18nOffBoundaryDay[this._language.current]);
this._setDisabledRenderAttributes();
return;
}

const availableDate: T = this.findAvailableDate(this.datepicker.valueAsDate);
this._disabled =
this._dateAdapter.compareDate(availableDate, this.datepicker.valueAsDate) === 0;
this._inputDisabled = this.datepicker.inputElement?.disabled ?? true;
this._setDisabledRenderAttributes();

const currentDateString =
this.datePickerElement &&
this._dateAdapter.compareDate(this.datePickerElement.now, currentDate) === 0
this._dateAdapter.compareDate(this.datepicker!.now, this.datepicker.valueAsDate) === 0
? i18nToday[this._language.current].toLowerCase()
: this._dateAdapter.getAccessibilityFormatDate(currentDate);
: this._dateAdapter.getAccessibilityFormatDate(this.datepicker.valueAsDate);

this.setAttribute(
'aria-label',
this.i18nSelectOffBoundaryDay(currentDateString)[this._language.current],
);
}

private _handleClick(): void {
if (!this.datepicker || this.hasAttribute('data-disabled')) {
return;
}
const startingDate: T = this.datepicker.valueAsDate ?? this.datepicker.now;
const date: T = this.findAvailableDate(startingDate);
if (this._dateAdapter.compareDate(date, startingDate) !== 0) {
this.datepicker.valueAsDate = date;
}
}

private _setDisabledRenderAttributes(
isDisabled: boolean = this._disabled || this._inputDisabled,
): void {
Expand Down
Loading

0 comments on commit c451e40

Please sign in to comment.