sass-references/angular-material/material/timepicker/timepicker.ts

453 lines
14 KiB
TypeScript

/**
* @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<D> {
value: D;
source: MatTimepicker<D>;
}
/**
* 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<D> 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<D>>(DateAdapter, {optional: true})!;
private _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!;
private _isOpen = signal(false);
private _activeDescendant = signal<string | null>(null);
private _input: MatTimepickerInput<D>;
private _overlayRef: OverlayRef | null = null;
private _portal: TemplatePortal<unknown> | null = null;
private _optionsCacheKey: string | null = null;
private _localeChanges: Subscription;
private _onOpenRender: AfterRenderRef | null = null;
protected _panelTemplate = viewChild.required<TemplateRef<unknown>>('panelTemplate');
protected _timeOptions: readonly MatTimepickerOption<D>[] = [];
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<number | null, number | string | null> = 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<readonly MatTimepickerOption<D>[] | null> = input<
readonly MatTimepickerOption<D>[] | null
>(null);
/** Whether the timepicker is open. */
readonly isOpen: Signal<boolean> = this._isOpen.asReadonly();
/** Emits when the user selects a time. */
readonly selected: OutputEmitterRef<MatTimepickerSelected<D>> = output();
/** Emits when the timepicker is opened. */
readonly opened: OutputEmitterRef<void> = output();
/** Emits when the timepicker is closed. */
readonly closed: OutputEmitterRef<void> = output();
/** ID of the active descendant option. */
readonly activeDescendant: Signal<string | null> = 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<boolean, unknown> = input(
this._defaultConfig?.disableRipple ?? false,
{
transform: booleanAttribute,
},
);
/** ARIA label for the timepicker panel. */
readonly ariaLabel: InputSignal<string | null> = input<string | null>(null, {
alias: 'aria-label',
});
/** ID of the label element for the timepicker panel. */
readonly ariaLabelledby: InputSignal<string | null> = input<string | null>(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<HTMLElement>>(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<D>): 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});
}