/** * Copyright Aquera Inc 2026 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html, nothing } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styles } from './nile-timestamp-builder.css'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup, PropertyValues } from 'lit'; import type { NileFormControl } from '../internal/nile-element'; import { FormControlController } from '../internal/form'; import { defaultValue } from '../internal/default-value'; import { EPOCH_WALLCLOCK, TimestampField, WallClock, clampWallClock, daysInMonth, epochToFields, fieldsToEpoch, formatWallClock, formatZonedValue, getLocalTimeZone, getTimezoneList, isValidTimeZone, normalizeValueInput, parseZonedValue, } from './nile-timestamp-builder-utils'; /** * @event nile-init - Emitted when the component is added to the DOM. * @event nile-destroy - Emitted when the component is removed from the DOM. * @event nile-change - Emitted when the value is committed. * @event nile-input - Emitted as fields change, before the value is committed. * @event nile-expand - Emitted when the editor opens. * @event nile-collapse - Emitted when the editor closes. * @event nile-copy - Emitted after the value is written to the clipboard. * @event nile-copy-error - Emitted when the clipboard write is refused. */ @customElement('nile-timestamp-builder') export class NileTimestampBuilder extends NileElement implements NileFormControl { static get styles(): CSSResultGroup { return [styles]; } private formControlController: FormControlController | null = null; private internalValueUpdate = false; private internalTimezoneUpdate = false; @query('.value-input') valueInput!: HTMLInputElement;private _value: Date | null = null; @property({ attribute: 'value', converter: { fromAttribute: (value: string | null) => value, toAttribute: (value: Date | null) => value === null ? null : value.toISOString(), }, }) get value(): Date | null { return this._value; } set value(input: Date | number | string | null) { if (typeof input === 'string') { const zoned = parseZonedValue(input); if (zoned && isValidTimeZone(zoned.timezone)) { this.timezone = zoned.timezone; } } const oldValue = this._value; const next = normalizeValueInput(input); if (next?.getTime() === oldValue?.getTime()) { this.internalValueUpdate = false; return; } this._value = next; this.requestUpdate('value', oldValue); } @defaultValue() defaultValue: Date | null = null; @property({ type: String, reflect: true }) timezone: string = getLocalTimeZone(); @property({ type: String, reflect: true }) name = ''; @property({ type: Boolean, reflect: true }) disabled = false; @property({ type: Boolean, reflect: true }) required = false; @property({ type: Boolean, reflect: true }) readonly = false; @property({ type: Boolean, reflect: true }) copyable = false; @property({ type: String, reflect: true }) form = ''; @property({ type: Boolean, reflect: true }) open = false; @property({ type: String, reflect: true }) label = ''; @property({ attribute: true, type: Number, reflect: true }) minYear = 1; @property({ attribute: true, type: Number, reflect: true }) maxYear = 9999; @state() wallClock: WallClock = EPOCH_WALLCLOCK; @state() timezoneList: string[] = []; @state() hasValue = false; @state() justCopied = false; get validity(): ValidityState { return this.valueInput?.validity; } get validationMessage(): string { return this.valueInput?.validationMessage ?? ''; } connectedCallback() { super.connectedCallback(); this.timezoneList = getTimezoneList(); if (!isValidTimeZone(this.timezone)) { this.timezone = getLocalTimeZone(); } if (this.value !== null) { this.wallClock = epochToFields(this.value.getTime(), this.timezone); this.hasValue = true; } else { this.wallClock = epochToFields(Date.now(), this.timezone); this.hasValue = false; } this.formControlController = new FormControlController(this, { assumeInteractionOn: ['nile-change'], value: input => { const el = input as NileTimestampBuilder; return el.value === null ? undefined : (el.valueString() ?? undefined); }, defaultValue: input => (input as NileTimestampBuilder).defaultValue, setValue: (input, val) => { (input as NileTimestampBuilder).value = val as string | null; }, }); this.emit('nile-init'); } disconnectedCallback() { super.disconnectedCallback(); window.clearTimeout(this.copiedTimer); this.emit('nile-destroy'); } protected willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has('value')) { if (this.internalValueUpdate) { this.internalValueUpdate = false; } else { if (this.value === null) { this.hasValue = false; } else { this.wallClock = epochToFields(this.value.getTime(), this.timezone); this.hasValue = true; } } } else if (this.isTimezoneReassigned(changedProperties) && !this.open) { this.syncValueToTimezone(); } } protected updated(changedProperties: PropertyValues): void { if (this.isTimezoneReassigned(changedProperties)) { if (this.internalTimezoneUpdate) { this.internalTimezoneUpdate = false; } else { this.emit('nile-input', this.liveDetail()); } } } private isTimezoneReassigned(changedProperties: PropertyValues): boolean { return ( changedProperties.has('timezone') && changedProperties.get('timezone') !== undefined ); } private syncValueToTimezone(): void { if (!this.hasValue || this.value === null) return; const epoch = fieldsToEpoch(this.wallClock, this.timezone); if (Number.isNaN(epoch) || epoch === this.value.getTime()) return; this.internalValueUpdate = true; this.value = new Date(epoch); this.syncHiddenInput(); } private valueString(): string | null { return this.value === null ? null : this.value.toISOString(); } private syncHiddenInput(): void { if (this.valueInput) { this.valueInput.value = this.valueString() ?? ''; } this.formControlController?.updateValidity(); } private changeDetail(): { value: string | null; localTimezoneValue: Date | null; unix: number | null; timezone: string; } { if (this.value === null) { return { value: null, localTimezoneValue: null, unix: null, timezone: this.timezone, }; } const epoch = this.value.getTime(); return { value: formatZonedValue(this.wallClock, this.timezone), localTimezoneValue: new Date(epoch), // Unix time (epoch milliseconds, matching Date.getTime()). unix: epoch, timezone: this.timezone, }; } private liveDetail(): { value: string | null; localTimezoneValue: Date | null; unix: number | null; timezone: string; } { const epoch = fieldsToEpoch(this.wallClock, this.timezone); if (Number.isNaN(epoch)) { return { value: null, localTimezoneValue: null, unix: null, timezone: this.timezone, }; } return { value: formatZonedValue(this.wallClock, this.timezone), localTimezoneValue: new Date(epoch), // Unix time (epoch milliseconds, matching Date.getTime()). unix: epoch, timezone: this.timezone, }; } private commit(): void { const epoch = fieldsToEpoch(this.wallClock, this.timezone); this.internalValueUpdate = true; this.value = new Date(epoch); this.hasValue = true; this.syncHiddenInput(); this.emit('nile-change', this.changeDetail()); } private onFieldChange(field: TimestampField) { return (event: CustomEvent) => { event.stopPropagation(); const raw = Number(event.detail?.value); const next = clampWallClock( { ...this.wallClock, [field]: raw }, this.minYear, this.maxYear ); this.wallClock = next; this.emit('nile-input', this.liveDetail()); }; } private onTimezoneChange(event: CustomEvent) { event.stopPropagation(); const next = event.detail?.value; if (typeof next === 'string' && isValidTimeZone(next)) { this.internalTimezoneUpdate = true; this.timezone = next; this.emit('nile-input', this.liveDetail()); } } private handleSetNow = () => { if (this.disabled || this.readonly) return; const now = Date.now(); this.wallClock = epochToFields(now, this.timezone); this.commit(); }; /** Copies the canonical zoned value */ private handleCopy = async () => { if (this.disabled || !this.hasValue) return; const text = formatZonedValue(this.wallClock, this.timezone); try { await navigator.clipboard.writeText(text); } catch { this.emit('nile-copy-error', { value: text }); return; } this.justCopied = true; window.clearTimeout(this.copiedTimer); this.copiedTimer = window.setTimeout(() => { this.justCopied = false; }, NileTimestampBuilder.COPIED_FEEDBACK_MS); this.emit('nile-copy', { value: text }); }; private handleStageNow = () => { if (this.disabled || this.readonly) return; const now = Date.now(); this.wallClock = epochToFields(now, this.timezone); this.emit('nile-input', this.liveDetail()); }; private handleUseLocalTz = () => { if (this.disabled || this.readonly) return; this.internalTimezoneUpdate = true; this.timezone = getLocalTimeZone(); this.emit('nile-input', this.liveDetail()); }; private handleUseUtc = () => { if (this.disabled || this.readonly) return; this.internalTimezoneUpdate = true; this.timezone = 'UTC'; this.emit('nile-input', this.liveDetail()); }; /** Touch input has no reliable `dblclick`, so double-taps are timed by hand. */ private static readonly DOUBLE_TAP_MS = 300; private static readonly COPIED_FEEDBACK_MS = 1600; private lastTapAt = 0; private copiedTimer = 0; private handleDisplayDoubleClick = () => { this.handleEdit(); }; private handleDisplayPointerUp = (event: PointerEvent) => { if (event.pointerType === 'mouse') return; const now = Date.now(); if (now - this.lastTapAt <= NileTimestampBuilder.DOUBLE_TAP_MS) { this.lastTapAt = 0; this.handleEdit(); return; } this.lastTapAt = now; }; /** Keyboard equivalent of the double-click/double-tap edit affordance. */ private handleDisplayKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); this.handleEdit(); }; private handleEdit = () => { if (this.disabled || this.readonly || this.open) return; if (this.value === null) { this.wallClock = epochToFields(Date.now(), this.timezone); } this.open = true; this.emit('nile-expand', { open: true }); }; private handleDone = () => { this.open = false; this.commit(); this.emit('nile-collapse', { open: false }); }; checkValidity(): boolean { return this.valueInput?.checkValidity() ?? true; } reportValidity(): boolean { return this.valueInput?.reportValidity() ?? true; } setCustomValidity(message: string): void { this.valueInput?.setCustomValidity(message); this.formControlController?.updateValidity(); } getForm(): HTMLFormElement | null { return this.formControlController?.getForm() ?? null; } private renderField( field: TimestampField, fieldLabel: string, val: number, min: number, max: number ) { return html`
`; } private renderCollapsed() { const wc = this.wallClock; const editable = !this.disabled && !this.readonly; return html`
${this.hasValue ? formatWallClock(wc) : 'No timestamp set'} ${this.timezone}
${editable ? this.renderEditActions() : nothing} ${this.copyable && this.readonly && !this.disabled ? this.renderCopyAction() : nothing}
`; } private renderEditActions() { return html`
Now
`; } private renderCopyAction() { return html`
`; } private renderExpanded() { const wc = this.wallClock; const maxDay = daysInMonth(wc.year, wc.month); return html`
${this.renderField( TimestampField.Year, 'Year', wc.year, this.minYear, this.maxYear )} ${this.renderField(TimestampField.Month, 'Month', wc.month, 1, 12)} ${this.renderField(TimestampField.Day, 'Day', wc.day, 1, maxDay)} ${this.renderField(TimestampField.Hour, 'Hour', wc.hour, 0, 23)} ${this.renderField( TimestampField.Minute, 'Minute', wc.minute, 0, 59 )} ${this.renderField( TimestampField.Second, 'Second', wc.second, 0, 59 )} ${this.renderField( TimestampField.Millisecond, 'Milliseconds', wc.millisecond, 0, 999 )}
${this.timezoneList.map( tz => html`${tz}` )}
Set to Now Use Local Timezone Use UTC Done
`; } render() { return html`
${this.label ? html`` : ''} ${this.open && !this.readonly ? this.renderExpanded() : this.renderCollapsed()}
`; } } export default NileTimestampBuilder; declare global { interface HTMLElementTagNameMap { 'nile-timestamp-builder': NileTimestampBuilder; } }