@blockly/field-date
Version:
A Blockly date picker field that uses the browser's date picker.
261 lines (235 loc) • 8.65 kB
text/typescript
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Plugin overview.
*/
import * as Blockly from 'blockly/core';
import {getLocaleDateString} from './utils';
/**
* Class for a date input field.
*/
export class FieldDate extends Blockly.FieldTextInput {
/**
* Serializable fields are saved by the XML renderer, non-serializable fields
* are not. Editable fields should also be serializable.
*/
SERIALIZABLE = true;
/**
* Class for a date input field. Derived from the Closure library date
* picker.
*
* @param value The initial value of the field. Should be in
* 'YYYY-MM-DD' format. Defaults to the current date.
* @param validator A function that is called to validate
* changes to the field's value. Takes in a date string & returns a
* validated date string ('YYYY-MM-DD' format), or null to abort the
* change.
* @param config A map of options used to configure the field.
*/
constructor(
value?: string,
validator?: FieldDateValidator,
config?: FieldDateConfig,
) {
super(value, validator, config);
}
/**
* Constructs a FieldDate from a JSON arg object.
*
* @param options A JSON object with options (date).
* @returns The new field instance.
* @package
* @nocollapse
*/
static fromJson(options: FieldDateFromJsonConfig): FieldDate {
const {date, ...fieldDateConfig} = options;
// `this` might be a subclass of FieldDate if that class doesn't
// override the static fromJson method.
return new this(date, undefined, fieldDateConfig);
}
/* eslint-disable @typescript-eslint/naming-convention */
/**
* Ensures that the input value is a valid date. Additionally, if the date
* string provided includes a time, the time will be removed and the date for
* relative to the user's timezone will be used.
*
* @param newValue The input value. Ex: '2023-04-28'
* @returns A valid date string, or null if invalid.
* @override
*/
protected doClassValidation_(newValue?: string): string | null {
if (!newValue) return null;
const newDate = typeof newValue === 'string' ? new Date(newValue) : null;
if (!newDate || isNaN(newDate.getTime())) return null;
// NOTE: 'newValue' should be a valid date format here.
if (isISOFormat(newValue)) return newValue;
// Assume the time needs to be corrected.
return toLocalISOString(newDate);
}
/**
* Get the text to display on the block when the input hasn't spawned in.
*
* @returns The text to display on the block.
* @override
*/
protected getText_(): string | null {
const value = this.getValue();
if (!value) return null;
// NOTE: There may be discrepancies between the text and the input based on
// browser. For example, 'en-US' would display the text '2/14/2020', then
// clicking in Safari on iOS would display 'Feb 14, 2020'.
return getLocaleDateString(value);
}
/**
* Renders the field. If the picker is shown make sure it has the current
* date selected.
*/
protected render_() {
super.render_();
}
/**
* Shows the inline free-text editor on top of the text along with the date
* editor.
*
* @param e Optional mouse event that triggered the field to
* open, or undefined if triggered programmatically.
* @override
*/
protected showEditor_(e?: Event) {
// Pass in `true` for `quietInput` to disable modal inputs for the date
// block without setting `this.sourceBlock_.workspace.options.modalInputs`,
// which would impact the entire workspace.
super.showEditor_(e, true);
// Even though `quietInput` was set true, focus on the element.
this.htmlInput_?.focus({
preventScroll: true,
});
this.htmlInput_?.select();
this.showDropdown();
}
/**
* Updates the size of the field based on the text.
*
* @param margin margin to use when positioning the text element.
* @override
*/
protected updateSize_(margin?: number) {
// Add margin so that the date input's datepicker icon doesn't clip with
// the text when sized for the date.
super.updateSize_((margin ?? 0) + 20);
}
/**
* Shows the datepicker.
*/
private showDropdown(): void {
if (!this.htmlInput_) return;
Blockly.utils.dom.addClass(this.htmlInput_, 'blocklyDateInput');
// Delay showing the picker until the editor has a chance to position
window.requestAnimationFrame(() => {
// NOTE: HTMLInputElement.showPicker() is not available in earlier
// TypeScript versions (like 4.7.4), so casting to `any` to be compatible
// with dev scripts. Additionally, it's not available for date inputs for
// Safari. For browser compatibility of showPicker, see:
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/showPicker
/* eslint-disable @typescript-eslint/no-explicit-any */
(this.htmlInput_ as any).showPicker();
/* eslint-enable @typescript-eslint/no-explicit-any */
});
}
/**
* Create the html input and set it to type date.
*
* @returns The newly created date input editor.
*/
protected widgetCreate_(): HTMLInputElement {
// NOTE: field_input should return HTMLInputElement for this.
const htmlInput = super.widgetCreate_() as HTMLInputElement;
htmlInput.type = 'date';
return htmlInput;
}
/* eslint-enable @typescript-eslint/naming-convention */
}
/**
* NOTE: There are a few minor ways to tweak the datepicker CSS, though they're
* not consistent across browsers.
*
* @see{@link https://developer.mozilla.org/en-US/docs/Learn/Forms/Property_compatibility_table_for_form_controls#date_pickers}
*
* Below are a few ways this can be tweaked on *some* browsers:
* Blockly.Css.register(`
* ::-webkit-datetime-edit { }
* ::-webkit-datetime-edit-fields-wrapper { }
* ::-webkit-datetime-edit-text { }
* ::-webkit-datetime-edit-month-field { }
* ::-webkit-datetime-edit-day-field { }
* ::-webkit-datetime-edit-year-field { }
* ::-webkit-inner-spin-button { }
* ::-webkit-calendar-picker-indicator { }
* `);
*/
if (Blockly.utils.userAgent.MAC) {
// NOTE: By default, 4 px padding total are added within the User Agent
// Shadow Content on Safari on MAC. Remove the padding so the inner input
// matches the outer input's height and, by extension, the height of the text
// node.
Blockly.Css.register(`
input.blocklyDateInput::-webkit-datetime-edit,
input.blocklyDateInput::-webkit-datetime-edit-month-field,
input.blocklyDateInput::-webkit-datetime-edit-day-field,
input.blocklyDateInput::-webkit-datetime-edit-year-field {
padding: 0;
}
`);
}
Blockly.fieldRegistry.register('field_date', FieldDate);
/**
* A config object for defining a field date.
*/
export interface FieldDateConfig extends Blockly.FieldTextInputConfig {
// NOTE: spellcheck is defined for FieldInput though irrelevant for FieldDate.
spellcheck?: never;
}
/**
* Options used to define a field date from JSON.
*/
export interface FieldDateFromJsonConfig extends FieldDateConfig {
date?: string;
}
export type FieldDateValidator = Blockly.FieldTextInputValidator;
/**
* Validate a string value to see if it matches the format.
*
* @param value The value to validate the format for.
* @returns true if the value is in 'yyyy-mm-dd' format.
* @example
* isISOFormat('2000-02-20T00:00:00Z') === false
* isISOFormat('2000-02-20') === true
*/
export function isISOFormat(value: string): boolean {
const valueMatch = value.match(/\d\d\d\d-\d\d-\d\d/);
// If it matches ####-##-## and is the same as its input string,
// then assume this is the right format
return valueMatch !== null && valueMatch[0] === valueMatch.input;
}
/**
* Convert the date to ISO format for the current timezone.
*
* @param date The date to convert to an ISO string.
* @returns The string in 'yyyy-mm-dd' format, though for the current timezone.
* Ex: new Date('2000-02-20')
*/
export function toLocalISOString(date: Date) {
// NOTE: If the date is Feb 20, 2000 at 23:00 for GMT-6, it would be
// '2000-02-21' at GMT+0, which is what `date.toISOString()` would return.
// For a user whose timezone is GMT-6, this should return '2000-02-20'.
// For a user whose timezone is GMT-5, that date should return '2000-02-21'.
return date
.toLocaleDateString('en-US')
.replace(/(\d+)\/(\d+)\/(\d+)/, '$3-$1-$2')
.replace(/-(\d)(?!\d)/g, '-0$1');
}
// NOTE: Set default here instead of in class so it's available at Field.
FieldDate.prototype.DEFAULT_VALUE = toLocalISOString(new Date());