diff --git a/src/elements/core/datetime/date-adapter.ts b/src/elements/core/datetime/date-adapter.ts index 8be5ef8bed..bc06e85e3d 100644 --- a/src/elements/core/datetime/date-adapter.ts +++ b/src/elements/core/datetime/date-adapter.ts @@ -68,7 +68,7 @@ export abstract class DateAdapter { * 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. diff --git a/src/elements/core/datetime/native-date-adapter.ts b/src/elements/core/datetime/native-date-adapter.ts index 401a33d4a4..a6f372d8da 100644 --- a/src/elements/core/datetime/native-date-adapter.ts +++ b/src/elements/core/datetime/native-date-adapter.ts @@ -116,7 +116,7 @@ export class NativeDateAdapter extends DateAdapter { } /** 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()); } diff --git a/src/elements/datepicker/common.ts b/src/elements/datepicker/common.ts index d754b6cef7..232c1a76bf 100644 --- a/src/elements/datepicker/common.ts +++ b/src/elements/datepicker/common.ts @@ -1 +1,2 @@ export * from './common/datepicker-button.js'; +export * from './common/datepicker-association-controllers.js'; diff --git a/src/elements/datepicker/common/datepicker-association-controllers.ts b/src/elements/datepicker/common/datepicker-association-controllers.ts new file mode 100644 index 0000000000..d16f274b9a --- /dev/null +++ b/src/elements/datepicker/common/datepicker-association-controllers.ts @@ -0,0 +1,115 @@ +import type { LitElement, ReactiveController } from 'lit'; + +import type { SbbDatepickerElement } from '../datepicker/datepicker.js'; + +export interface SbbDatepickerControl extends LitElement { + datepicker: SbbDatepickerElement | null; +} + +class SbbDatepickerAssociationContext { + private static _registry = new WeakMap>(); + + public readonly hosts = new Set>(); + public readonly controls = new Set>(); + + private constructor(private _host: Node) {} + + public static connect(rootNode: Node): SbbDatepickerAssociationContext { + let context = this._registry.get(rootNode); + if (!context) { + context = new SbbDatepickerAssociationContext(rootNode); + this._registry.set(rootNode, context); + } + + return context; + } + + public updateControls(datepicker: SbbDatepickerElement): void { + Array.from(this.controls) + .filter((c) => c.datepicker === datepicker) + .forEach((c) => c.requestUpdate()); + } + + public deleteHost(datepicker: SbbDatepickerElement): undefined { + this.hosts.delete(datepicker); + this._deleteIfEmpty(); + } + + public deleteControl(control: SbbDatepickerControl): 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 implements ReactiveController { + private _context?: SbbDatepickerAssociationContext; + + public constructor(private _host: SbbDatepickerElement) { + 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 implements ReactiveController { + private _context?: SbbDatepickerAssociationContext; + + public constructor(private _host: SbbDatepickerControl) { + 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 | 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); + } +} diff --git a/src/elements/datepicker/common/datepicker-button.ts b/src/elements/datepicker/common/datepicker-button.ts index e7519120e8..972b84c45d 100644 --- a/src/elements/datepicker/common/datepicker-button.ts +++ b/src/elements/datepicker/common/datepicker-button.ts @@ -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 extends SbbNegativeMixin(SbbButtonBaseElement) { +export abstract class SbbDatepickerButton + extends SbbNegativeMixin(SbbButtonBaseElement) + implements SbbDatepickerControl +{ + /** + * Datepicker reference. + * @deprecated Use property/attribute datepicker instead. + */ + @property({ attribute: 'date-picker' }) + public set datePicker(value: string | SbbDatepickerElement | null) { + if (import.meta.env.DEV) { + console.warn( + `Property datePicker/Attribute date-picker is deprecated. Use datepicker instead.`, + ); + } + this.datepicker = value as unknown as SbbDatepickerElement | null; + } + public get datePicker(): string | SbbDatepickerElement | null { + return this.datepicker; + } + /** Datepicker reference. */ - @property({ attribute: 'date-picker' }) public accessor datePicker: - | string - | SbbDatepickerElement - | null = null; + @property({ attribute: 'datepicker' }) + public set datepicker(value: SbbDatepickerElement | 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 | null { + return this._datepicker ?? null; + } + private _datepicker?: SbbDatepickerElement | null; /** The boundary date (min/max) as set in the date-picker's input. */ @state() protected accessor boundary: string | number | null = null; @@ -32,10 +62,12 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(Sbb /** Whether the component is disabled due date-picker's input disabled. */ private _inputDisabled = false; + /** + * @deprecated Use datepicker instead. + */ protected datePickerElement?: SbbDatepickerElement | null = null; private _dateAdapter: DateAdapter = 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; @@ -43,6 +75,7 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(Sbb public constructor() { super(); + this.addController(new SbbDatepickerAssociationControlController(this)); this.addEventListener?.('click', () => this._handleClick()); } @@ -50,125 +83,31 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(Sbb public override connectedCallback(): void { super.connectedCallback(); - this._syncUpstreamProperties(); - if (!this.datePicker) { - this._init(); - } - } - - public override willUpdate(changedProperties: PropertyValues): 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): 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) => this._init(e.target as SbbDatepickerElement), - { 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) => { - 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): 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', @@ -176,6 +115,17 @@ export abstract class SbbDatepickerButton extends SbbNegativeMixin(Sbb ); } + 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 { diff --git a/src/elements/datepicker/datepicker-next-day/datepicker-next-day.spec.ts b/src/elements/datepicker/datepicker-next-day/datepicker-next-day.spec.ts index c08601f0d0..5bf15d2ae7 100644 --- a/src/elements/datepicker/datepicker-next-day/datepicker-next-day.spec.ts +++ b/src/elements/datepicker/datepicker-next-day/datepicker-next-day.spec.ts @@ -27,7 +27,7 @@ describe(`sbb-datepicker-next-day`, () => {
- +
`); @@ -55,7 +55,7 @@ describe(`sbb-datepicker-next-day`, () => { const element = await fixture(html`
- +
`); @@ -74,8 +74,7 @@ describe(`sbb-datepicker-next-day`, () => { element.appendChild(picker); await waitForLitRender(element); - // the datepicker is connected, which triggers a 1st inputUpdated event which calls _init and a 2nd one which sets max/min/disabled - expect(inputUpdated.count).to.be.equal(2); + expect(inputUpdated.count).to.be.equal(1); expect(nextButton).not.to.have.attribute('data-disabled'); }); @@ -84,7 +83,7 @@ describe(`sbb-datepicker-next-day`, () => {
- +
@@ -105,9 +104,8 @@ describe(`sbb-datepicker-next-day`, () => { element.querySelector('#other')!.appendChild(picker); await waitForLitRender(element); - // the datepicker is connected on a different parent, so no changes are triggered expect(inputUpdated.count).to.be.equal(0); - expect(nextButton).to.have.attribute('data-disabled'); + expect(nextButton).not.to.have.attribute('data-disabled'); }); }); diff --git a/src/elements/datepicker/datepicker-next-day/readme.md b/src/elements/datepicker/datepicker-next-day/readme.md index 71388cda72..0583274da7 100644 --- a/src/elements/datepicker/datepicker-next-day/readme.md +++ b/src/elements/datepicker/datepicker-next-day/readme.md @@ -37,7 +37,8 @@ both standalone or within the `sbb-form-field`, they must have the same parent e | Name | Attribute | Privacy | Type | Default | Description | | ------------ | ------------- | ------- | ------------------------------------------- | ---------- | ----------------------------------------------------------- | -| `datePicker` | `date-picker` | public | `string \| SbbDatepickerElement \| null` | `null` | Datepicker reference. | +| `datepicker` | `datepicker` | public | `SbbDatepickerElement \| null` | | Datepicker reference. | +| `datePicker` | `date-picker` | public | `string \| SbbDatepickerElement \| null` | | Datepicker reference. | | `form` | `form` | public | `HTMLFormElement \| null` | | The `
` element to associate the button with. | | `name` | `name` | public | `string` | | Name of the form element. Will be read from name attribute. | | `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | diff --git a/src/elements/datepicker/datepicker-previous-day/datepicker-previous-day.spec.ts b/src/elements/datepicker/datepicker-previous-day/datepicker-previous-day.spec.ts index 053d9f9f23..5fa6bbc716 100644 --- a/src/elements/datepicker/datepicker-previous-day/datepicker-previous-day.spec.ts +++ b/src/elements/datepicker/datepicker-previous-day/datepicker-previous-day.spec.ts @@ -26,7 +26,7 @@ describe(`sbb-datepicker-previous-day`, () => { const root = await fixture(html`
- +
`); @@ -51,7 +51,7 @@ describe(`sbb-datepicker-previous-day`, () => { const doc = await fixture(html`
- +
`); @@ -70,8 +70,7 @@ describe(`sbb-datepicker-previous-day`, () => { doc.appendChild(picker); await waitForLitRender(doc); - // the datepicker is connected, which triggers a 1st inputUpdated event which calls _init and a 2nd one which sets max/min/disabled - expect(inputUpdated.count).to.be.equal(2); + expect(inputUpdated.count).to.be.equal(1); expect(prevButton).not.to.have.attribute('data-disabled'); }); @@ -80,7 +79,7 @@ describe(`sbb-datepicker-previous-day`, () => {
- +
@@ -104,9 +103,8 @@ describe(`sbb-datepicker-previous-day`, () => { root.querySelector('#other')!.appendChild(picker); await waitForLitRender(root); - // the datepicker is connected on a different parent, so no changes are triggered expect(inputUpdated.count).to.be.equal(0); - expect(prevButton).to.have.attribute('data-disabled'); + expect(prevButton).not.to.have.attribute('data-disabled'); }); }); diff --git a/src/elements/datepicker/datepicker-previous-day/readme.md b/src/elements/datepicker/datepicker-previous-day/readme.md index f1a02f3264..3c16e171d9 100644 --- a/src/elements/datepicker/datepicker-previous-day/readme.md +++ b/src/elements/datepicker/datepicker-previous-day/readme.md @@ -37,7 +37,8 @@ both standalone or within the `sbb-form-field`, they must have the same parent e | Name | Attribute | Privacy | Type | Default | Description | | ------------ | ------------- | ------- | ------------------------------------------- | ---------- | ----------------------------------------------------------- | -| `datePicker` | `date-picker` | public | `string \| SbbDatepickerElement \| null` | `null` | Datepicker reference. | +| `datepicker` | `datepicker` | public | `SbbDatepickerElement \| null` | | Datepicker reference. | +| `datePicker` | `date-picker` | public | `string \| SbbDatepickerElement \| null` | | Datepicker reference. | | `form` | `form` | public | `HTMLFormElement \| null` | | The `` element to associate the button with. | | `name` | `name` | public | `string` | | Name of the form element. Will be read from name attribute. | | `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | diff --git a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.spec.ts b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.spec.ts index 6596beffc7..36efc515f7 100644 --- a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.spec.ts +++ b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.spec.ts @@ -31,7 +31,7 @@ describe(`sbb-datepicker-toggle`, () => { it('renders and opens popover with picker', async () => { const root = await fixture(html`
- +
@@ -61,7 +61,7 @@ describe(`sbb-datepicker-toggle`, () => { it('renders and opens popover programmatically', async () => { const root = await fixture(html`
- +
@@ -91,7 +91,7 @@ describe(`sbb-datepicker-toggle`, () => { it('renders and opens popover programmatically by click', async () => { const root = await fixture(html`
- +
@@ -111,7 +111,7 @@ describe(`sbb-datepicker-toggle`, () => { it('datepicker is created after the component', async () => { const root = await fixture(html`
- +
`); @@ -133,8 +133,7 @@ describe(`sbb-datepicker-toggle`, () => { root.appendChild(picker); await waitForLitRender(root); - // the datepicker is connected, which triggers a 1st inputUpdated event which calls _init and a 2nd one which sets max/min/disabled - expect(inputUpdated.count).to.be.equal(2); + expect(inputUpdated.count).to.be.equal(1); expect(trigger).not.to.have.attribute('disabled'); }); @@ -142,7 +141,7 @@ describe(`sbb-datepicker-toggle`, () => { const root = await fixture(html`
- +
@@ -165,9 +164,8 @@ describe(`sbb-datepicker-toggle`, () => { root.querySelector('#other')!.appendChild(picker); await waitForLitRender(root); - // the datepicker is connected on a different parent, so no changes are triggered expect(inputUpdated.count).to.be.equal(0); - expect(trigger).to.have.attribute('disabled'); + expect(trigger).not.to.have.attribute('disabled'); }); it('renders in form field, open calendar and change date', async () => { @@ -216,6 +214,7 @@ describe(`sbb-datepicker-toggle`, () => { input.value = ''; input.dispatchEvent(new Event('input')); input.dispatchEvent(new Event('change')); + await waitForLitRender(element); expect(input.value).to.be.equal(''); expect(calendar.selected).to.be.null; diff --git a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts index 079d245c45..768cedfa66 100644 --- a/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts +++ b/src/elements/datepicker/datepicker-toggle/datepicker-toggle.ts @@ -11,8 +11,8 @@ import { hostAttributes } from '../../core/decorators.js'; import { i18nShowCalendar } from '../../core/i18n.js'; import { SbbHydrationMixin, SbbNegativeMixin } from '../../core/mixins.js'; import type { SbbPopoverElement } from '../../popover/popover.js'; -import type { SbbDatepickerElement, SbbInputUpdateEvent } from '../datepicker.js'; -import { datepickerControlRegisteredEventFactory, getDatePicker } from '../datepicker.js'; +import { SbbDatepickerAssociationControlController, type SbbDatepickerControl } from '../common.js'; +import type { SbbDatepickerElement } from '../datepicker.js'; import style from './datepicker-toggle.scss?lit&inline'; @@ -28,14 +28,41 @@ export @hostAttributes({ slot: 'prefix', }) -class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydrationMixin(LitElement)) { +class SbbDatepickerToggleElement + extends SbbNegativeMixin(SbbHydrationMixin(LitElement)) + implements SbbDatepickerControl +{ public static override styles: CSSResultGroup = style; + /** + * Datepicker reference. + * @deprecated Use property/attribute datepicker instead. + */ + @property({ attribute: 'date-picker' }) + public set datePicker(value: string | SbbDatepickerElement | null) { + if (import.meta.env.DEV) { + console.warn( + `Property datePicker/Attribute date-picker is deprecated. Use datepicker instead.`, + ); + } + this.datepicker = value as unknown as SbbDatepickerElement | null; + } + public get datePicker(): string | SbbDatepickerElement | null { + return this.datepicker; + } + /** Datepicker reference. */ - @property({ attribute: 'date-picker' }) public accessor datePicker: - | string - | SbbDatepickerElement - | null = null; + @property({ attribute: 'datepicker' }) + public set datepicker(value: SbbDatepickerElement | null) { + this._datepicker = + typeof value === 'string' + ? ((this.getRootNode?.() as ParentNode)?.querySelector?.(`#${value}`) ?? null) + : value; + } + public get datepicker(): SbbDatepickerElement | null { + return this._datepicker ?? null; + } + private _datepicker?: SbbDatepickerElement | null; /** The initial view of calendar which should be displayed on opening. */ @property() public accessor view: CalendarView = 'day'; @@ -48,15 +75,14 @@ class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydration @state() private accessor _renderCalendar = false; - private _datePickerElement: SbbDatepickerElement | null | undefined; private _calendarElement!: SbbCalendarElement; private _triggerElement!: SbbMiniButtonElement; private _popoverElement!: SbbPopoverElement; - private _datePickerController!: AbortController; private _language = new SbbLanguageController(this); public constructor() { super(); + this.addController(new SbbDatepickerAssociationControlController(this)); this.addEventListener?.('click', (event) => { if (event.composedPath()[0] === this) { this.open(); @@ -79,87 +105,38 @@ class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydration public override connectedCallback(): void { super.connectedCallback(); - if (!this.datePicker) { - this._init(); - } - const formField = this.closest?.('sbb-form-field') ?? this.closest?.('[data-form-field]'); if (formField) { this.negative = formField.hasAttribute('negative'); } } - public override willUpdate(changedProperties: PropertyValues): void { + protected override willUpdate(changedProperties: PropertyValues): void { super.willUpdate(changedProperties); - - if (changedProperties.has('datePicker')) { - this._init(this.datePicker!); - } - } - - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this._datePickerController?.abort(); - } - - private _init(datePicker?: string | SbbDatepickerElement): void { - this._datePickerController?.abort(); - this._datePickerController = new AbortController(); - 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. - this.parentElement?.addEventListener( - 'inputUpdated', - (e: Event) => this._init(e.target as SbbDatepickerElement), - { once: true, signal: this._datePickerController.signal }, - ); - return; + if (this.datepicker) { + this._disabled = + (this.datepicker.inputElement?.disabled || this.datepicker.inputElement?.readOnly) ?? true; + this._min = this.datepicker.inputElement?.min; + this._max = this.datepicker.inputElement?.max; + + this._configureCalendar(); + if (this._calendarElement) { + this._calendarElement.selected = this.datepicker.valueAsDate ?? null; + } + } else { + this._disabled = true; + this._min = null; + this._max = null; } - - this._datePickerElement?.addEventListener( - 'inputUpdated', - (event: CustomEvent) => { - this._datePickerElement = event.target as SbbDatepickerElement; - this._disabled = !!(event.detail.disabled || event.detail.readonly); - this._min = event.detail.min; - this._max = event.detail.max; - }, - { signal: this._datePickerController.signal }, - ); - this._datePickerElement?.addEventListener( - 'change', - (event: Event) => this._datePickerChanged(event), - { - signal: this._datePickerController.signal, - }, - ); - this._datePickerElement?.addEventListener( - 'datePickerUpdated', - (event: Event) => - this._configureCalendar(this._calendarElement, event.target as SbbDatepickerElement), - { signal: this._datePickerController.signal }, - ); - this._datePickerElement.dispatchEvent(datepickerControlRegisteredEventFactory()); } - private _configureCalendar( - calendar: SbbCalendarElement, - datepicker: SbbDatepickerElement, - ): void { - if (!calendar || !datepicker) { + private _configureCalendar(): void { + if (!this._calendarElement || !this.datepicker) { return; } - calendar.wide = datepicker.wide; - calendar.now = this._nowOrNull(); - calendar.dateFilter = datepicker.dateFilter; - } - - private _datePickerChanged(event: Event): void { - this._datePickerElement = event.target as SbbDatepickerElement; - if (this._calendarElement) { - this._calendarElement.selected = this._datePickerElement.valueAsDate ?? null; - } + this._calendarElement.wide = this.datepicker.wide; + this._calendarElement.now = this._nowOrNull(); + this._calendarElement.dateFilter = this.datepicker.dateFilter; } private _assignCalendar(calendar: SbbCalendarElement): void { @@ -167,14 +144,11 @@ class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydration return; } this._calendarElement = calendar; - if ( - !('valueAsDate' in (this._datePickerElement ?? {})) || - !this._calendarElement?.resetPosition - ) { + if (!('valueAsDate' in (this.datepicker ?? {})) || !this._calendarElement?.resetPosition) { return; } - this._calendarElement.selected = this._datePickerElement!.valueAsDate ?? null; - this._configureCalendar(this._calendarElement, this._datePickerElement!); + this._calendarElement.selected = this.datepicker!.valueAsDate ?? null; + this._configureCalendar(); this._calendarElement.resetPosition(); } @@ -185,7 +159,7 @@ class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydration } private _nowOrNull(): T | null { - return this._datePickerElement?.hasCustomNow() ? this._datePickerElement.now : null; + return this.datepicker?.hasCustomNow() ? this.datepicker.now : null; } protected override render(): TemplateResult { @@ -194,7 +168,7 @@ class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydration class="sbb-datepicker-toggle__trigger" icon-name="calendar-small" aria-label=${i18nShowCalendar[this._language.current]} - ?disabled=${!isServer && (!this._datePickerElement || this._disabled)} + ?disabled=${!isServer && (!this.datepicker || this._disabled)} ?negative=${this.negative} ${ref((el?: Element) => (this._triggerElement = el as SbbMiniButtonElement))} > @@ -215,12 +189,12 @@ class SbbDatepickerToggleElement extends SbbNegativeMixin(SbbHydration .min=${this._min} .max=${this._max} .now=${this._nowOrNull()} - ?wide=${this._datePickerElement?.wide} - .dateFilter=${this._datePickerElement?.dateFilter} + ?wide=${this.datepicker?.wide} + .dateFilter=${this.datepicker?.dateFilter} @dateSelected=${(d: CustomEvent) => { this._calendarElement.selected = d.detail; - if (this._datePickerElement) { - this._datePickerElement.valueAsDate = d.detail; + if (this.datepicker) { + this.datepicker.valueAsDate = d.detail; } }} ${ref((calendar?: Element) => diff --git a/src/elements/datepicker/datepicker-toggle/readme.md b/src/elements/datepicker/datepicker-toggle/readme.md index 289948c003..5e6f26178c 100644 --- a/src/elements/datepicker/datepicker-toggle/readme.md +++ b/src/elements/datepicker/datepicker-toggle/readme.md @@ -36,11 +36,12 @@ 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 \| null` | `null` | Datepicker reference. | -| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | -| `view` | `view` | public | `CalendarView` | `'day'` | The initial view of calendar which should be displayed on opening. | +| Name | Attribute | Privacy | Type | Default | Description | +| ------------ | ------------- | ------- | ------------------------------------------- | ------- | ------------------------------------------------------------------ | +| `datepicker` | `datepicker` | public | `SbbDatepickerElement \| null` | | Datepicker reference. | +| `datePicker` | `date-picker` | public | `string \| SbbDatepickerElement \| null` | | Datepicker reference. | +| `negative` | `negative` | public | `boolean` | `false` | Negative coloring variant flag. | +| `view` | `view` | public | `CalendarView` | `'day'` | The initial view of calendar which should be displayed on opening. | ## Methods diff --git a/src/elements/datepicker/datepicker/__snapshots__/datepicker.snapshot.spec.snap.js b/src/elements/datepicker/datepicker/__snapshots__/datepicker.snapshot.spec.snap.js index fbc148c1b6..755613049a 100644 --- a/src/elements/datepicker/datepicker/__snapshots__/datepicker.snapshot.spec.snap.js +++ b/src/elements/datepicker/datepicker/__snapshots__/datepicker.snapshot.spec.snap.js @@ -14,7 +14,7 @@ snapshots["sbb-datepicker renders DOM"] = placeholder="DD.MM.YYYY" type="text" > - + { let formField: SbbFormFieldElement; diff --git a/src/elements/datepicker/datepicker/datepicker.ts b/src/elements/datepicker/datepicker/datepicker.ts index e9c38e705e..4f386dabb2 100644 --- a/src/elements/datepicker/datepicker/datepicker.ts +++ b/src/elements/datepicker/datepicker/datepicker.ts @@ -16,7 +16,7 @@ import { findInput, findReferencedElement } from '../../core/dom.js'; import { EventEmitter, forwardEvent } from '../../core/eventing.js'; import { i18nDateChangedTo, i18nDatePickerPlaceholder } from '../../core/i18n.js'; import type { SbbDateLike, SbbValidationChangeEvent } from '../../core/interfaces.js'; -import type { SbbDatepickerButton } from '../common.js'; +import { SbbDatepickerAssociationHostController, type SbbDatepickerButton } from '../common.js'; import type { SbbDatepickerToggleElement } from '../datepicker-toggle.js'; import style from './datepicker.scss?lit&inline'; @@ -33,6 +33,7 @@ export interface SbbInputUpdateEvent { * 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. + * @deprecated No longer in use. */ export function getDatePicker( element: SbbDatepickerButton | SbbDatepickerToggleElement, @@ -47,12 +48,17 @@ export function getDatePicker( return findReferencedElement>(trigger); } +/** + * @deprecated No longer in use. + */ export const datepickerControlRegisteredEventFactory = (): CustomEvent => new CustomEvent('datepickerControlRegistered', { bubbles: false, composed: true, }); +let nextId = 0; + /** * Combined with a native input, it displays the input's value as a formatted date. * @@ -110,6 +116,12 @@ class SbbDatepickerElement extends LitElement { } private _valueAsDate?: T | null; + /** The associated input element. */ + public get inputElement(): HTMLInputElement | null { + return this._inputElement; + } + @state() private accessor _inputElement: HTMLInputElement | null = null; + /** Notifies that the connected input has changes. */ private _change: EventEmitter = new EventEmitter(this, SbbDatepickerElement.events.change, { bubbles: true, @@ -138,8 +150,6 @@ class SbbDatepickerElement extends LitElement { SbbDatepickerElement.events.validationChange, ); - @state() - private accessor _inputElement: HTMLInputElement | null = null; private _inputElementPlaceholderMutable = false; private _datePickerController!: AbortController; @@ -147,6 +157,7 @@ class SbbDatepickerElement extends LitElement { private _inputObserver = !isServer ? new MutationObserver((mutationsList) => { this._emitInputUpdated(); + this._associationController?.updateControls(); // TODO: Decide whether to remove this logic by adding a value property to the datepicker. if (this._inputElement && mutationsList?.some((e) => e.attributeName === 'value')) { const value = this._inputElement.getAttribute('value'); @@ -167,18 +178,16 @@ class SbbDatepickerElement extends LitElement { } } }); + private _associationController = new SbbDatepickerAssociationHostController(this); public constructor() { super(); - this.addEventListener?.('datepickerControlRegistered', () => this._emitInputUpdated()); } public override connectedCallback(): void { + this.id ||= `sbb-datepicker-${++nextId}`; super.connectedCallback(); this._attachInput(); - if (this._inputElement) { - this._emitInputUpdated(); - } } public override willUpdate(changedProperties: PropertyValues): void { @@ -253,6 +262,8 @@ class SbbDatepickerElement extends LitElement { this._parseInput(true); this._tryApplyFormatToInput(); this._validateDate(); + this._emitInputUpdated(); + this._associationController?.updateControls(); } } @@ -268,6 +279,7 @@ class SbbDatepickerElement extends LitElement { this._validateDate(); this._setAriaLiveMessage(); this._change.emit(); + this._associationController?.updateControls(); } private _tryApplyFormatToInput(): boolean { diff --git a/src/elements/datepicker/datepicker/readme.md b/src/elements/datepicker/datepicker/readme.md index c20f66281a..affb6cad32 100644 --- a/src/elements/datepicker/datepicker/readme.md +++ b/src/elements/datepicker/datepicker/readme.md @@ -72,13 +72,14 @@ Whenever the validation state changes (e.g., a valid value becomes invalid or vi ## Properties -| Name | Attribute | Privacy | Type | Default | Description | -| ------------- | --------- | ------- | ------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- | -| `dateFilter` | - | public | `(date: T \| null) => boolean` | | A function used to filter out dates. | -| `input` | `input` | public | `string \| HTMLElement \| null` | `null` | Reference of the native input connected to the datepicker. | -| `now` | `now` | public | `T` | | A configured date which acts as the current date instead of the real current date. Recommended for testing purposes. | -| `valueAsDate` | - | public | `T \| null` | | The currently selected date as a Date or custom date provider instance. | -| `wide` | `wide` | public | `boolean` | `false` | If set to true, two months are displayed. | +| Name | Attribute | Privacy | Type | Default | Description | +| -------------- | --------- | ------- | ------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- | +| `dateFilter` | - | public | `(date: T \| null) => boolean` | | A function used to filter out dates. | +| `input` | `input` | public | `string \| HTMLElement \| null` | `null` | Reference of the native input connected to the datepicker. | +| `inputElement` | - | public | `HTMLInputElement \| null` | `null` | The associated input element. | +| `now` | `now` | public | `T` | | A configured date which acts as the current date instead of the real current date. Recommended for testing purposes. | +| `valueAsDate` | - | public | `T \| null` | | The currently selected date as a Date or custom date provider instance. | +| `wide` | `wide` | public | `boolean` | `false` | If set to true, two months are displayed. | ## Methods