Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
18aef58
poc editable date range input
ddaribo Apr 17, 2025
ee9d1fc
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Jun 10, 2025
8870280
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Jun 17, 2025
773dc52
feat(drp): handle the alt + arrow down/up focus properly for single i…
ddaribo Jun 17, 2025
73f897e
Merge branch 'master' into bpachilova/range-editable-date-input
rkaraivanov Jun 23, 2025
cb12b8a
Merge branch 'master' into bpachilova/range-editable-date-input
rkaraivanov Jun 27, 2025
5ee269f
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Jul 15, 2025
cb41172
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Jul 28, 2025
2b29c4a
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Jul 28, 2025
7ca2d82
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Aug 15, 2025
3a366f7
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Oct 28, 2025
c8c3a68
Merge branch 'master' into bpachilova/range-editable-date-input & get…
ddaribo Feb 20, 2026
adf7e14
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Feb 20, 2026
52b4571
refactor(date-time): single input working, wip
ddaribo Feb 20, 2026
7d1f073
chore(drp): checkpoint
ddaribo Feb 24, 2026
ae41f03
chore(drp): working checkpoint
ddaribo Feb 26, 2026
6c7e112
refactor(date-time-inputs): organize regions, remove redundancies
ddaribo Mar 4, 2026
e416379
refactor(date-time-inputs): polishing and fixes
ddaribo Mar 5, 2026
3bd270e
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Mar 5, 2026
bb4ad22
chore(file-input): fix leftover circular import
ddaribo Mar 5, 2026
e5a9c67
chore(*): fix has time/date-parts getter invocation
ddaribo Mar 5, 2026
801fb1d
test(drp): increase coverage
ddaribo Mar 6, 2026
6cef110
refactor(drp): validator to use new utility methods
ddaribo Mar 6, 2026
5c8cb91
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Mar 6, 2026
ad2ed38
refactor(*): apply suggestions
ddaribo Mar 13, 2026
21ea1b0
Merge branch 'master' into bpachilova/range-editable-date-input
ddaribo Mar 13, 2026
d4184ce
chore(drp): fix typo
ddaribo Mar 13, 2026
2bccfe1
chore(*): address comments
ddaribo Mar 13, 2026
5f90309
Merge branch 'master' into bpachilova/range-editable-date-input
rkaraivanov Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/calendar/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
last,
modulo,
} from '../common/util.js';
import type { DateRangeValue } from '../date-range-picker/date-range-picker.js';
import type { DateRangeValue } from '../types.js';
import {
CalendarDay,
type CalendarRangeParams,
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/mixins/forms/form-transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
convertToDateRange,
getDateFormValue,
} from '../../../calendar/helpers.js';
import type { DateRangeValue } from '../../../date-range-picker/date-range-picker.js';
import type { DateRangeValue } from '../../../types.js';
import { asNumber } from '../../util.js';
import type { FormValueType, IgcFormControl } from './types.js';

Expand Down
381 changes: 381 additions & 0 deletions src/components/date-range-picker/date-range-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,381 @@
import { property } from 'lit/decorators.js';
import { addThemingController } from '../../theming/theming-controller.js';
import { CalendarDay } from '../calendar/model.js';
import { registerComponent } from '../common/definitions/register.js';
import { formatDisplayDate } from '../common/i18n/i18n-controller.js';
import { FormValueDateRangeTransformers } from '../common/mixins/forms/form-transformers.js';
import { createFormValueState } from '../common/mixins/forms/form-value.js';
import { equal } from '../common/util.js';
import {
type DatePart,
type DatePartDeltas,
DatePartType,
} from '../date-time-input/date-part.js';
import { IgcDateTimeInputBaseComponent } from '../date-time-input/date-time-input.base.js';
import { DateParts } from '../date-time-input/datetime-mask-parser.js';
import { styles } from '../input/themes/input.base.css.js';
import { styles as shared } from '../input/themes/shared/input.common.css.js';
import { all } from '../input/themes/themes.js';
import type { DateRangeValue } from '../types.js';
import {
DateRangeMaskParser,
type DateRangePart,
DateRangePosition,
} from './date-range-mask-parser.js';

export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComponent<
DateRangeValue | null,
DateRangePart
> {
public static readonly tagName = 'igc-date-range-input';
public static styles = [styles, shared];

/* blazorSuppress */
public static register() {
registerComponent(IgcDateRangeInputComponent);
}

//#region Private state and properties

protected override readonly _formValue = createFormValueState(this, {
initialValue: { start: null, end: null },
transformers: FormValueDateRangeTransformers,
});

protected override readonly _themes = addThemingController(this, all);
protected override readonly _parser = new DateRangeMaskParser();

protected override get _datePartDeltas(): DatePartDeltas {
return {
date: 1,
month: 1,
year: 1,
};
}

// #endregion

// #region Public attributes and properties

/* @tsTwoWayProperty(true, "igcChange", "detail", false, true) */
/**
* The value of the date range input.
* @attr
*/
@property({ attribute: false })
public set value(value: DateRangeValue | null) {
this._formValue.setValueAndFormState(value);
this._updateMaskDisplay();
}

public get value(): DateRangeValue | null {
return this._formValue.value;
}

// #endregion

// #region Lifecycle Hooks

public override connectedCallback(): void {
super.connectedCallback();
this._initializeDefaultMask();
this._updateMaskDisplay();
}

// #endregion

// #region Event Handlers Overrides

protected override async _handleFocus(): Promise<void> {
this._focused = true;

if (this.readOnly) {
return;
}

this._oldValue = this.value;

if (!this.value || (!this.value.start && !this.value.end)) {
this._maskedValue = this._parser.emptyMask;
await this.updateComplete;
this.select();
} else if (this.displayFormat !== this.inputFormat) {
this._updateMaskDisplay();
}
}

protected override _handleBlur(): void {
const isSameValue = equal(this._oldValue, this.value);

this._focused = false;

if (!(this._isMaskComplete() || this._isEmptyMask)) {
const parsed = this._parser.parseDateRange(this._maskedValue);

if (parsed && (parsed.start || parsed.end)) {
this.value = parsed;
} else {
this.value = null;
this._maskedValue = '';
}
} else {
this._updateMaskDisplay();
}

if (!(this.readOnly || isSameValue)) {
this.emitEvent('igcChange', { detail: this.value });
}

super._handleBlur();
}

// #endregion

// #region Keybindings overrides

protected override _setCurrentDateTime(): void {
const today = CalendarDay.today.native;
this.value = { start: today, end: today };
this._emitInputEvent();
}

protected override _calculatePartNavigationPosition(
inputValue: string,
direction: number
): number {
const cursorPos = this._maskSelection.start;
const rangeParts = this._parser.rangeParts;

const currentPart = rangeParts.find(
(p) =>
p.type !== DateParts.Literal &&
cursorPos >= p.start &&
cursorPos <= p.end
);

const isStartOrEndPart =
currentPart &&
(currentPart.position === DateRangePosition.Start ||
currentPart.position === DateRangePosition.End);

if (direction === 0) {
// Backward: if inside a start/end part, move to its start; else, move to previous part's start
if (isStartOrEndPart && cursorPos !== currentPart.start) {
return currentPart.start;
}
const prevPart = [...rangeParts]
.reverse()
.find((p) => p.type !== DateParts.Literal && p.end < cursorPos);
return prevPart?.start ?? 0;
}

// Forward: if inside a start/end part, move to its end; else, move to next part's end
if (isStartOrEndPart && cursorPos !== currentPart.end) {
return currentPart.end;
}
const nextPart = rangeParts.find(
(p) => p.type !== DateParts.Literal && p.start > cursorPos
);
return nextPart?.end ?? inputValue.length;
}

// #endregion

// #region Internal API Overrides

protected override _emitInputEvent(): void {
this._setTouchedState();
this.emitEvent('igcInput', { detail: this._maskedValue });
}

protected override _updateMaskDisplay(): void {
if (this._focused) {
// Only reset mask from value when value is non-null (i.e. after spinning or programmatic set).
// When value is null the user is mid-typing — leave _maskedValue unchanged.
if (this.value?.start || this.value?.end) {
this._maskedValue = this._buildMaskedValue();
} else if (!this._maskedValue) {
this._maskedValue = this._parser.emptyMask;
}
return;
}

if (!this.value?.start && !this.value?.end) {
this._maskedValue = '';
return;
}

const { start, end } = this.value;
const startStr = start
? formatDisplayDate(start, this.locale, this.displayFormat)
: '';
const endStr = end
? formatDisplayDate(end, this.locale, this.displayFormat)
: '';
this._maskedValue =
startStr && endStr
? `${startStr}${this._parser.separator}${endStr}`
: startStr || endStr;
}

protected override _performStep(
datePart: DateRangePart | undefined,
delta: number | undefined,
isDecrement: boolean
): void {
// If no value exists, set to today's date first
if (!this.value?.start && !this.value?.end) {
const today = CalendarDay.today.native;
this.value = { start: today, end: today };
const { start, end } = this._inputSelection;
this.updateComplete.then(() =>
this._input?.setSelectionRange(start, end)
);
return;
}

super._performStep(datePart, delta, isDecrement);
}

protected override _calculateSpunValue(
datePart: DateRangePart,
delta: number | undefined,
isDecrement: boolean
): DateRangeValue {
const part = this._parser.getPartByTypeAndPosition(
datePart.part as DatePartType,
datePart.position
);

const today = CalendarDay.today.native;
const defaultValue = { start: today, end: today };

if (!part) {
return this.value || defaultValue;
}

const effectiveDelta =
delta ?? this._datePartDeltas[datePart.part as keyof DatePartDeltas] ?? 1;
const spinAmount = effectiveDelta * (isDecrement ? -1 : 1);

// For AM/PM spinning, extract the current AM/PM value from the mask
const amPmValue =
part.type === DatePartType.AmPm
? this._maskedValue.substring(part.start, part.end)
: undefined;

return this._parser.spinDateRangePart(
part,
spinAmount,
this.value,
this.spinLoop,
amPmValue
);
}

/**
* Gets the date range part at the current cursor position.
* If the cursor is at a literal, finds the nearest non-literal part.
* Returns undefined if no valid part is found.
*/
protected override _getDatePartAtCursor(): DateRangePart | undefined {
const cursorPos = this._inputSelection.start;
let part = this._parser.getDateRangePartForCursor(cursorPos);

// If cursor is at a literal, find the nearest non-literal part
if (part?.type === DatePartType.Literal) {
const nextPart = this._parser.rangeParts.find(
(p) => p.start >= cursorPos && p.type !== DatePartType.Literal
);
if (nextPart) {
part = nextPart;
} else {
part = this._parser.rangeParts.findLast(
(p) => p.end <= cursorPos && p.type !== DatePartType.Literal
);
}
}

if (part && part.type !== DatePartType.Literal) {
return {
part: part.type as DatePart,
position: part.position,
};
}

return undefined;
}

/**
* Gets the default date range part to target when the input is not focused.
* Returns the first date part at the start position.
*/
protected override _getDefaultDatePart(): DateRangePart | undefined {
const firstPart = this._parser.getFirstDatePartForPosition(
DateRangePosition.Start
);
if (firstPart) {
return {
part: firstPart.type as DatePart,
position: DateRangePosition.Start,
};
}

return undefined;
}

protected override _buildMaskedValue(): string {
return this._parser.formatDateRange(this.value);
}

protected override _applyMask(string: string): void {
const previous = this._parser.mask;

this._parser.mask = string;
this._defaultMask = string;
this._parser.prompt = this.prompt;

// Update placeholder if not set or if it matches the previous format
if (!this.placeholder || previous === this.placeholder) {
this.placeholder = this._parser.mask;
}
}

protected override _updateValueFromMask(): void {
if (!this._isMaskComplete()) {
this.value = null;
return;
}

const parsed = this._parser.parseDateRange(this._maskedValue);
this.value = parsed?.start || parsed?.end ? parsed : null;
}

// #region Public API Overrides

public override hasDateParts(): boolean {
return this._parser.rangeParts.some(
(p) =>
p.type === DatePartType.Date ||
p.type === DatePartType.Month ||
p.type === DatePartType.Year
);
}

public override hasTimeParts(): boolean {
return this._parser.rangeParts.some(
(p) =>
p.type === DatePartType.Hours ||
p.type === DatePartType.Minutes ||
p.type === DatePartType.Seconds
);
}

// #endregion
}

declare global {
interface HTMLElementTagNameMap {
'igc-date-range-input': IgcDateRangeInputComponent;
}
}
Loading
Loading