From 4b6916299826101c4c8a12c9c4aa77ae0e49db71 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 9 Dec 2024 17:42:46 +0100 Subject: [PATCH] fix(material/timepicker): disable toggle if timepicker is disabled (#30137) Fixes that the timepicker toggle wasn't considered as disabled automatically when the timepicker is disabled. Fixes #30134. (cherry picked from commit db8f6c0a9f3102056eead1197a6926a34cb89cbd) --- src/material/timepicker/timepicker-input.ts | 8 ++-- .../timepicker/timepicker-toggle.html | 4 +- src/material/timepicker/timepicker-toggle.ts | 8 +++- src/material/timepicker/timepicker.scss | 4 +- src/material/timepicker/timepicker.spec.ts | 13 ++++++ src/material/timepicker/timepicker.ts | 43 +++++++++++-------- tools/public_api_guard/material/timepicker.md | 3 ++ 7 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/material/timepicker/timepicker-input.ts b/src/material/timepicker/timepicker-input.ts index 54f088f7dd28..ad7ed2c99af1 100644 --- a/src/material/timepicker/timepicker-input.ts +++ b/src/material/timepicker/timepicker-input.ts @@ -244,7 +244,9 @@ export class MatTimepickerInput implements ControlValueAccessor, Validator, O /** Handles clicks on the input or the containing form field. */ private _handleClick = (): void => { - this.timepicker().open(); + if (!this.disabled()) { + this.timepicker().open(); + } }; /** Handles the `input` event. */ @@ -278,7 +280,7 @@ export class MatTimepickerInput implements ControlValueAccessor, Validator, O /** Handles the `keydown` event. */ protected _handleKeydown(event: KeyboardEvent) { // All keyboard events while open are handled through the timepicker. - if (this.timepicker().isOpen()) { + if (this.timepicker().isOpen() || this.disabled()) { return; } @@ -286,7 +288,7 @@ export class MatTimepickerInput implements ControlValueAccessor, Validator, O event.preventDefault(); this.value.set(null); this._formatValue(null); - } else if ((event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) && !this.disabled()) { + } else if (event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) { event.preventDefault(); this.timepicker().open(); } diff --git a/src/material/timepicker/timepicker-toggle.html b/src/material/timepicker/timepicker-toggle.html index dab7a3d38c84..ef15001b8e5a 100644 --- a/src/material/timepicker/timepicker-toggle.html +++ b/src/material/timepicker/timepicker-toggle.html @@ -4,8 +4,8 @@ aria-haspopup="listbox" [attr.aria-label]="ariaLabel()" [attr.aria-expanded]="timepicker().isOpen()" - [attr.tabindex]="disabled() ? -1 : tabIndex()" - [disabled]="disabled()" + [attr.tabindex]="_isDisabled() ? -1 : tabIndex()" + [disabled]="_isDisabled()" [disableRipple]="disableRipple()"> diff --git a/src/material/timepicker/timepicker-toggle.ts b/src/material/timepicker/timepicker-toggle.ts index 7e88a18f405a..9bf902390a16 100644 --- a/src/material/timepicker/timepicker-toggle.ts +++ b/src/material/timepicker/timepicker-toggle.ts @@ -10,6 +10,7 @@ import { booleanAttribute, ChangeDetectionStrategy, Component, + computed, HostAttributeToken, inject, input, @@ -46,6 +47,11 @@ export class MatTimepickerToggle { return isNaN(parsed) ? null : parsed; })(); + protected _isDisabled = computed(() => { + const timepicker = this.timepicker(); + return this.disabled() || timepicker.disabled(); + }); + /** Timepicker instance that the button will toggle. */ readonly timepicker: InputSignal> = input.required>({ alias: 'for', @@ -73,7 +79,7 @@ export class MatTimepickerToggle { /** Opens the connected timepicker. */ protected _open(event: Event): void { - if (this.timepicker() && !this.disabled()) { + if (this.timepicker() && !this._isDisabled()) { this.timepicker().open(); event.stopPropagation(); } diff --git a/src/material/timepicker/timepicker.scss b/src/material/timepicker/timepicker.scss index ae8cfc84fff8..4cea2ec88ebc 100644 --- a/src/material/timepicker/timepicker.scss +++ b/src/material/timepicker/timepicker.scss @@ -38,11 +38,9 @@ mat-timepicker { } } -// stylelint-disable material/no-prefixes -.mat-timepicker-input:read-only { +.mat-timepicker-input[readonly] { cursor: pointer; } -// stylelint-enable material/no-prefixes @include cdk.high-contrast { .mat-timepicker-toggle-default-icon { diff --git a/src/material/timepicker/timepicker.spec.ts b/src/material/timepicker/timepicker.spec.ts index 54c61d9b1b65..24198da61930 100644 --- a/src/material/timepicker/timepicker.spec.ts +++ b/src/material/timepicker/timepicker.spec.ts @@ -1164,6 +1164,19 @@ describe('MatTimepicker', () => { fixture.detectChanges(); expect(getPanel()).toBeFalsy(); }); + + it('should disable the toggle when the timepicker is disabled', () => { + const fixture = TestBed.createComponent(StandaloneTimepicker); + const toggle = getToggle(fixture); + fixture.detectChanges(); + expect(toggle.disabled).toBe(false); + expect(toggle.getAttribute('tabindex')).toBe('0'); + + fixture.componentInstance.disabled.set(true); + fixture.detectChanges(); + expect(toggle.disabled).toBe(true); + expect(toggle.getAttribute('tabindex')).toBe('-1'); + }); }); describe('global defaults', () => { diff --git a/src/material/timepicker/timepicker.ts b/src/material/timepicker/timepicker.ts index e1628a2ef7b3..2a32ca0837b9 100644 --- a/src/material/timepicker/timepicker.ts +++ b/src/material/timepicker/timepicker.ts @@ -12,6 +12,7 @@ import { booleanAttribute, ChangeDetectionStrategy, Component, + computed, effect, ElementRef, inject, @@ -104,7 +105,7 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { private _isOpen = signal(false); private _activeDescendant = signal(null); - private _input: MatTimepickerInput; + private _input = signal | null>(null); private _overlayRef: OverlayRef | null = null; private _portal: TemplatePortal | null = null; private _optionsCacheKey: string | null = null; @@ -174,6 +175,9 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { alias: 'aria-labelledby', }); + /** Whether the timepicker is currently disabled. */ + readonly disabled: Signal = computed(() => !!this._input()?.disabled()); + constructor() { if (typeof ngDevMode === 'undefined' || ngDevMode) { validateAdapter(this._dateAdapter, this._dateFormats); @@ -204,14 +208,16 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { /** Opens the timepicker. */ open(): void { - if (!this._input) { + const input = this._input(); + + if (!input) { return; } // Focus should already be on the input, but this call is in case the timepicker is opened // programmatically. We need to call this even if the timepicker is already open, because // the user might be clicking the toggle. - this._input.focus(); + input.focus(); if (this._isOpen()) { return; @@ -220,14 +226,14 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { this._isOpen.set(true); this._generateOptions(); const overlayRef = this._getOverlayRef(); - overlayRef.updateSize({width: this._input.getOverlayOrigin().nativeElement.offsetWidth}); + overlayRef.updateSize({width: input.getOverlayOrigin().nativeElement.offsetWidth}); this._portal ??= new TemplatePortal(this._panelTemplate(), this._viewContainerRef); overlayRef.attach(this._portal); this._onOpenRender?.destroy(); this._onOpenRender = afterNextRender( () => { const options = this._options(); - this._syncSelectedState(this._input.value(), options, options[0]); + this._syncSelectedState(input.value(), options, options[0]); this._onOpenRender = null; }, {injector: this._injector}, @@ -247,11 +253,13 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { /** Registers an input with the timepicker. */ registerInput(input: MatTimepickerInput): void { - if (this._input && input !== this._input && (typeof ngDevMode === 'undefined' || ngDevMode)) { + const currentInput = this._input(); + + if (currentInput && input !== currentInput && (typeof ngDevMode === 'undefined' || ngDevMode)) { throw new Error('MatTimepicker can only be registered with one input at a time'); } - this._input = input; + this._input.set(input); } ngOnDestroy(): void { @@ -265,7 +273,7 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { protected _selectValue(value: D) { this.close(); this.selected.emit({value, source: this}); - this._input.focus(); + this._input()?.focus(); } /** Gets the value of the `aria-labelledby` attribute. */ @@ -273,7 +281,7 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { if (this.ariaLabel()) { return null; } - return this.ariaLabelledby() || this._input?._getLabelId() || null; + return this.ariaLabelledby() || this._input()?._getLabelId() || null; } /** Creates an overlay reference for the timepicker panel. */ @@ -284,7 +292,7 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { const positionStrategy = this._overlay .position() - .flexibleConnectedTo(this._input.getOverlayOrigin()) + .flexibleConnectedTo(this._input()!.getOverlayOrigin()) .withFlexibleDimensions(false) .withPush(false) .withTransformOriginOn('.mat-timepicker-panel') @@ -317,9 +325,9 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { this._overlayRef.outsidePointerEvents().subscribe(event => { const target = _getEventTarget(event) as HTMLElement; - const origin = this._input.getOverlayOrigin().nativeElement; + const origin = this._input()?.getOverlayOrigin().nativeElement; - if (target && target !== origin && !origin.contains(target)) { + if (target && origin && target !== origin && !origin.contains(target)) { this.close(); } }); @@ -336,10 +344,11 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { if (options !== null) { this._timeOptions = options; } else { + const input = this._input(); const adapter = this._dateAdapter; const timeFormat = this._dateFormats.display.timeInput; - const min = this._input.min() || adapter.setTime(adapter.today(), 0, 0, 0); - const max = this._input.max() || adapter.setTime(adapter.today(), 23, 59, 0); + const min = input?.min() || adapter.setTime(adapter.today(), 0, 0, 0); + const max = input?.max() || adapter.setTime(adapter.today(), 23, 59, 0); const cacheKey = interval + '/' + adapter.format(min, timeFormat) + '/' + adapter.format(max, timeFormat); @@ -432,11 +441,11 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { */ private _handleInputStateChanges(): void { effect(() => { - const value = this._input?.value(); + const input = this._input(); const options = this._options(); - if (this._isOpen()) { - this._syncSelectedState(value, options, null); + if (this._isOpen() && input) { + this._syncSelectedState(input.value(), options, null); } }); } diff --git a/tools/public_api_guard/material/timepicker.md b/tools/public_api_guard/material/timepicker.md index ecfcae907ac8..528d217bd53f 100644 --- a/tools/public_api_guard/material/timepicker.md +++ b/tools/public_api_guard/material/timepicker.md @@ -33,6 +33,7 @@ export class MatTimepicker implements OnDestroy, MatOptionParentComponent { readonly ariaLabelledby: InputSignal; close(): void; readonly closed: OutputEmitterRef; + readonly disabled: Signal; readonly disableRipple: InputSignalWithTransform; protected _getAriaLabelledby(): string | null; readonly interval: InputSignalWithTransform; @@ -125,6 +126,8 @@ export class MatTimepickerToggle { readonly ariaLabel: InputSignal; readonly disabled: InputSignalWithTransform; readonly disableRipple: InputSignalWithTransform; + // (undocumented) + protected _isDisabled: Signal; protected _open(event: Event): void; readonly tabIndex: InputSignal; readonly timepicker: InputSignal>;