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

fix: handle datepicker association reliably #3339

Merged
merged 4 commits into from
Jan 15, 2025
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
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';
107 changes: 107 additions & 0 deletions src/elements/datepicker/common/datepicker-association-controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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>>();
kyubisation marked this conversation as resolved.
Show resolved Hide resolved
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 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?.hosts.delete(this._host);
this._context?.deleteIfEmpty();
}

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?.controls.delete(this._host);
this._context?.deleteIfEmpty();
}
}
196 changes: 74 additions & 122 deletions src/elements/datepicker/common/datepicker-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,53 @@
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.
* @internal
* @deprecated Use property/attribute `datepicker` instead.
*/
@property({ attribute: 'date-picker' })
kyubisation marked this conversation as resolved.
Show resolved Hide resolved
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 34 in src/elements/datepicker/common/datepicker-button.ts

View check run for this annotation

Codecov / codecov/patch

src/elements/datepicker/common/datepicker-button.ts#L31-L34

Added lines #L31 - L34 were not covered by tests
this.datepicker = value as unknown as SbbDatepickerElement<T> | null;
}
/** @internal */
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,153 +64,73 @@
/** Whether the component is disabled due date-picker's input disabled. */
private _inputDisabled = false;

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

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 || this.datepicker.inputElement?.readOnly) ?? 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 _setDisabledRenderAttributes(
isDisabled: boolean = this._disabled || this._inputDisabled,
): void {
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(): void {
const isDisabled = this._disabled || this._inputDisabled;
this.toggleAttribute('data-disabled', isDisabled);
if (isDisabled) {
this.setAttribute('aria-disabled', 'true');
Expand Down
Loading
Loading