/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import { afterNextRender, AfterRenderRef, booleanAttribute, ChangeDetectionStrategy, Component, effect, ElementRef, inject, Injector, input, InputSignal, InputSignalWithTransform, OnDestroy, output, OutputEmitterRef, Signal, signal, TemplateRef, untracked, viewChild, viewChildren, ViewContainerRef, ViewEncapsulation, } from '@angular/core'; import {animate, group, state, style, transition, trigger} from '@angular/animations'; import { DateAdapter, MAT_DATE_FORMATS, MAT_OPTION_PARENT_COMPONENT, MatOption, MatOptionParentComponent, } from '@angular/material/core'; import {Directionality} from '@angular/cdk/bidi'; import {Overlay, OverlayRef} from '@angular/cdk/overlay'; import {TemplatePortal} from '@angular/cdk/portal'; import {_getEventTarget} from '@angular/cdk/platform'; import {ENTER, ESCAPE, hasModifierKey, TAB} from '@angular/cdk/keycodes'; import {_IdGenerator, ActiveDescendantKeyManager} from '@angular/cdk/a11y'; import type {MatTimepickerInput} from './timepicker-input'; import { generateOptions, MAT_TIMEPICKER_CONFIG, MatTimepickerOption, parseInterval, validateAdapter, } from './util'; import {Subscription} from 'rxjs'; /** Event emitted when a value is selected in the timepicker. */ export interface MatTimepickerSelected { value: D; source: MatTimepicker; } /** * Renders out a listbox that can be used to select a time of day. * Intended to be used together with `MatTimepickerInput`. */ @Component({ selector: 'mat-timepicker', exportAs: 'matTimepicker', templateUrl: 'timepicker.html', styleUrl: 'timepicker.css', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [MatOption], providers: [ { provide: MAT_OPTION_PARENT_COMPONENT, useExisting: MatTimepicker, }, ], animations: [ trigger('panel', [ state('void', style({opacity: 0, transform: 'scaleY(0.8)'})), transition(':enter', [ group([ animate('0.03s linear', style({opacity: 1})), animate('0.12s cubic-bezier(0, 0, 0.2, 1)', style({transform: 'scaleY(1)'})), ]), ]), transition(':leave', [animate('0.075s linear', style({opacity: 0}))]), ]), ], }) export class MatTimepicker implements OnDestroy, MatOptionParentComponent { private _overlay = inject(Overlay); private _dir = inject(Directionality, {optional: true}); private _viewContainerRef = inject(ViewContainerRef); private _injector = inject(Injector); private _defaultConfig = inject(MAT_TIMEPICKER_CONFIG, {optional: true}); private _dateAdapter = inject>(DateAdapter, {optional: true})!; private _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; private _isOpen = signal(false); private _activeDescendant = signal(null); private _input: MatTimepickerInput; private _overlayRef: OverlayRef | null = null; private _portal: TemplatePortal | null = null; private _optionsCacheKey: string | null = null; private _localeChanges: Subscription; private _onOpenRender: AfterRenderRef | null = null; protected _panelTemplate = viewChild.required>('panelTemplate'); protected _timeOptions: readonly MatTimepickerOption[] = []; protected _options = viewChildren(MatOption); private _keyManager = new ActiveDescendantKeyManager(this._options, this._injector) .withHomeAndEnd(true) .withPageUpDown(true) .withVerticalOrientation(true); /** * Interval between each option in the timepicker. The value can either be an amount of * seconds (e.g. 90) or a number with a unit (e.g. 45m). Supported units are `s` for seconds, * `m` for minutes or `h` for hours. */ readonly interval: InputSignalWithTransform = input( parseInterval(this._defaultConfig?.interval || null), {transform: parseInterval}, ); /** * Array of pre-defined options that the user can select from, as an alternative to using the * `interval` input. An error will be thrown if both `options` and `interval` are specified. */ readonly options: InputSignal[] | null> = input< readonly MatTimepickerOption[] | null >(null); /** Whether the timepicker is open. */ readonly isOpen: Signal = this._isOpen.asReadonly(); /** Emits when the user selects a time. */ readonly selected: OutputEmitterRef> = output(); /** Emits when the timepicker is opened. */ readonly opened: OutputEmitterRef = output(); /** Emits when the timepicker is closed. */ readonly closed: OutputEmitterRef = output(); /** ID of the active descendant option. */ readonly activeDescendant: Signal = this._activeDescendant.asReadonly(); /** Unique ID of the timepicker's panel */ readonly panelId: string = inject(_IdGenerator).getId('mat-timepicker-panel-'); /** Whether ripples within the timepicker should be disabled. */ readonly disableRipple: InputSignalWithTransform = input( this._defaultConfig?.disableRipple ?? false, { transform: booleanAttribute, }, ); /** ARIA label for the timepicker panel. */ readonly ariaLabel: InputSignal = input(null, { alias: 'aria-label', }); /** ID of the label element for the timepicker panel. */ readonly ariaLabelledby: InputSignal = input(null, { alias: 'aria-labelledby', }); constructor() { if (typeof ngDevMode === 'undefined' || ngDevMode) { validateAdapter(this._dateAdapter, this._dateFormats); effect(() => { const options = this.options(); const interval = this.interval(); if (options !== null && interval !== null) { throw new Error( 'Cannot specify both the `options` and `interval` inputs at the same time', ); } else if (options?.length === 0) { throw new Error('Value of `options` input cannot be an empty array'); } }); } // Since the panel ID is static, we can set it once without having to maintain a host binding. const element = inject>(ElementRef); element.nativeElement.setAttribute('mat-timepicker-panel-id', this.panelId); this._handleLocaleChanges(); this._handleInputStateChanges(); this._keyManager.change.subscribe(() => this._activeDescendant.set(this._keyManager.activeItem?.id || null), ); } /** Opens the timepicker. */ open(): void { if (!this._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(); if (this._isOpen()) { return; } this._isOpen.set(true); this._generateOptions(); const overlayRef = this._getOverlayRef(); overlayRef.updateSize({width: this._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._onOpenRender = null; }, {injector: this._injector}, ); this.opened.emit(); } /** Closes the timepicker. */ close(): void { if (this._isOpen()) { this._isOpen.set(false); this._overlayRef?.detach(); this.closed.emit(); } } /** Registers an input with the timepicker. */ registerInput(input: MatTimepickerInput): void { if (this._input && input !== this._input && (typeof ngDevMode === 'undefined' || ngDevMode)) { throw new Error('MatTimepicker can only be registered with one input at a time'); } this._input = input; } ngOnDestroy(): void { this._keyManager.destroy(); this._localeChanges.unsubscribe(); this._onOpenRender?.destroy(); this._overlayRef?.dispose(); } /** Selects a specific time value. */ protected _selectValue(value: D) { this.close(); this.selected.emit({value, source: this}); this._input.focus(); } /** Gets the value of the `aria-labelledby` attribute. */ protected _getAriaLabelledby(): string | null { if (this.ariaLabel()) { return null; } return this.ariaLabelledby() || this._input?._getLabelId() || null; } /** Creates an overlay reference for the timepicker panel. */ private _getOverlayRef(): OverlayRef { if (this._overlayRef) { return this._overlayRef; } const positionStrategy = this._overlay .position() .flexibleConnectedTo(this._input.getOverlayOrigin()) .withFlexibleDimensions(false) .withPush(false) .withTransformOriginOn('.mat-timepicker-panel') .withPositions([ { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', }, { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', panelClass: 'mat-timepicker-above', }, ]); this._overlayRef = this._overlay.create({ positionStrategy, scrollStrategy: this._overlay.scrollStrategies.reposition(), direction: this._dir || 'ltr', hasBackdrop: false, }); this._overlayRef.keydownEvents().subscribe(event => { this._handleKeydown(event); }); this._overlayRef.outsidePointerEvents().subscribe(event => { const target = _getEventTarget(event) as HTMLElement; const origin = this._input.getOverlayOrigin().nativeElement; if (target && target !== origin && !origin.contains(target)) { this.close(); } }); return this._overlayRef; } /** Generates the list of options from which the user can select.. */ private _generateOptions(): void { // Default the interval to 30 minutes. const interval = this.interval() ?? 30 * 60; const options = this.options(); if (options !== null) { this._timeOptions = options; } else { 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 cacheKey = interval + '/' + adapter.format(min, timeFormat) + '/' + adapter.format(max, timeFormat); // Don't re-generate the options if the inputs haven't changed. if (cacheKey !== this._optionsCacheKey) { this._optionsCacheKey = cacheKey; this._timeOptions = generateOptions(adapter, this._dateFormats, min, max, interval); } } } /** * Synchronizes the internal state of the component based on a specific selected date. * @param value Currently selected date. * @param options Options rendered out in the timepicker. * @param fallback Option to set as active if no option is selected. */ private _syncSelectedState( value: D | null, options: readonly MatOption[], fallback: MatOption | null, ): void { let hasSelected = false; for (const option of options) { if (value && this._dateAdapter.sameTime(option.value, value)) { option.select(false); scrollOptionIntoView(option, 'center'); untracked(() => this._keyManager.setActiveItem(option)); hasSelected = true; } else { option.deselect(false); } } // If no option was selected, we need to reset the key manager since // it might be holding onto an option that no longer exists. if (!hasSelected) { if (fallback) { untracked(() => this._keyManager.setActiveItem(fallback)); scrollOptionIntoView(fallback, 'center'); } else { untracked(() => this._keyManager.setActiveItem(-1)); } } } /** Handles keyboard events while the overlay is open. */ private _handleKeydown(event: KeyboardEvent): void { const keyCode = event.keyCode; if (keyCode === TAB) { this.close(); } else if (keyCode === ESCAPE && !hasModifierKey(event)) { event.preventDefault(); this.close(); } else if (keyCode === ENTER) { event.preventDefault(); if (this._keyManager.activeItem) { this._selectValue(this._keyManager.activeItem.value); } else { this.close(); } } else { const previousActive = this._keyManager.activeItem; this._keyManager.onKeydown(event); const currentActive = this._keyManager.activeItem; if (currentActive && currentActive !== previousActive) { scrollOptionIntoView(currentActive, 'nearest'); } } } /** Sets up the logic that updates the timepicker when the locale changes. */ private _handleLocaleChanges(): void { // Re-generate the options list if the locale changes. this._localeChanges = this._dateAdapter.localeChanges.subscribe(() => { this._optionsCacheKey = null; if (this.isOpen()) { this._generateOptions(); } }); } /** * Sets up the logic that updates the timepicker when the state of the connected input changes. */ private _handleInputStateChanges(): void { effect(() => { const value = this._input?.value(); const options = this._options(); if (this._isOpen()) { this._syncSelectedState(value, options, null); } }); } } /** * Scrolls an option into view. * @param option Option to be scrolled into view. * @param position Position to which to align the option relative to the scrollable container. */ function scrollOptionIntoView(option: MatOption, position: ScrollLogicalPosition) { option._getHostElement().scrollIntoView({block: position, inline: position}); }