diff --git a/.gitignore b/.gitignore index bf41696c5..e4a7943de 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,7 @@ tsconfig.*.tsbuildinfo **/*\wsl.* **/coverage packages/**/.vitest-attachments +.playwright-mcp/ ############################################################################### # OS / editor / IDE files diff --git a/packages/common/core/package.json b/packages/common/core/package.json index 6ab9a7e31..604607180 100644 --- a/packages/common/core/package.json +++ b/packages/common/core/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@faker-js/faker": "~9.6.0", + "@internationalized/date": "^3.6.4", "@internationalized/number": "^3.6.4", "@qualcomm-ui/cli": "catalog:", "@qualcomm-ui/css-utils": "catalog:", @@ -57,6 +58,7 @@ "vitest": "catalog:" }, "peerDependencies": { + "@internationalized/date": ">=3.6.4", "@internationalized/number": ">=3.6.4", "@qualcomm-ui/dom": "workspace:^1.2.0", "@qualcomm-ui/utils": "workspace:^1.4.0" diff --git a/packages/common/core/src/combobox/combobox.types.ts b/packages/common/core/src/combobox/combobox.types.ts index d80f0ad40..02217c5e7 100644 --- a/packages/common/core/src/combobox/combobox.types.ts +++ b/packages/common/core/src/combobox/combobox.types.ts @@ -347,12 +347,6 @@ export interface ComboboxApiProps } export interface ComboboxPositioningOptions extends PositioningOptions { - /** - * The main axis offset or gap between the reference and floating element - * @default 2 - */ - gutter?: number | undefined - /** * The initial placement of the floating element * @default 'bottom-start' diff --git a/packages/common/core/src/date-picker/__tests__/date-picker.parse.spec.ts b/packages/common/core/src/date-picker/__tests__/date-picker.parse.spec.ts new file mode 100644 index 000000000..4f757a3c9 --- /dev/null +++ b/packages/common/core/src/date-picker/__tests__/date-picker.parse.spec.ts @@ -0,0 +1,53 @@ +import {CalendarDate, parseDate} from "@internationalized/date" +import {describe, expect, test} from "vitest" + +import {parse} from "../date-picker.parse.js" + +describe("parse", () => { + test("converts a native Date, shifting 0-indexed months to 1-indexed", () => { + expect(parse(new Date(2024, 0, 15))).toEqual(new CalendarDate(2024, 1, 15)) + expect(parse(new Date(2024, 11, 31))).toEqual( + new CalendarDate(2024, 12, 31), + ) + }) + + test("reads a native Date's local wall-clock fields, dropping the time", () => { + expect(parse(new Date(2024, 1, 20, 0, 0, 0))).toEqual( + new CalendarDate(2024, 2, 20), + ) + expect(parse(new Date(2024, 1, 20, 23, 59, 59))).toEqual( + new CalendarDate(2024, 2, 20), + ) + }) + + test("maps over an array, converting each entry", () => { + expect(parse([new Date(2024, 0, 15), new Date(2024, 1, 20)])).toEqual([ + new CalendarDate(2024, 1, 15), + new CalendarDate(2024, 2, 20), + ]) + }) + + test("maps over an array of mixed strings and Dates", () => { + expect(parse(["2024-01-15", new Date(2024, 1, 20)])).toEqual([ + new CalendarDate(2024, 1, 15), + new CalendarDate(2024, 2, 20), + ]) + }) + + test("returns an empty array for an empty array", () => { + expect(parse([])).toEqual([]) + }) + + test("delegates a plain ISO string to parseDate", () => { + expect(parse("2024-01-15")).toEqual(parseDate("2024-01-15")) + }) + + test("keeps a leap day intact", () => { + expect(parse("2024-02-29")).toEqual(new CalendarDate(2024, 2, 29)) + expect(parse(new Date(2024, 1, 29))).toEqual(new CalendarDate(2024, 2, 29)) + }) + + test("throws on a malformed ISO string", () => { + expect(() => parse("not-a-date")).toThrow() + }) +}) diff --git a/packages/common/core/src/date-picker/__tests__/date-picker.utils.spec.ts b/packages/common/core/src/date-picker/__tests__/date-picker.utils.spec.ts new file mode 100644 index 000000000..071de85d6 --- /dev/null +++ b/packages/common/core/src/date-picker/__tests__/date-picker.utils.spec.ts @@ -0,0 +1,298 @@ +import {CalendarDate, parseDate} from "@internationalized/date" +import {describe, expect, test} from "vitest" + +import { + adjustStartAndEndDate, + clampView, + eachView, + getInputPlaceholder, + getNextView, + getPreviousView, + getRoleDescription, + getVisibleRangeText, + isAboveMinView, + isBelowMinView, + isDateWithinRange, + isValidDate, + normalizeValueForMode, + sortDates, +} from "../date-picker.utils.js" + +describe("sortDates", () => { + test("sorts dates in ascending order within a month", () => { + const date1 = parseDate("2024-01-15") + const date2 = parseDate("2024-01-20") + const date3 = parseDate("2024-01-10") + + const sorted = sortDates([date1, date2, date3]) + expect(sorted).toEqual([date3, date1, date2]) + }) + + test("orders by month, not by day of month", () => { + const january = parseDate("2024-01-30") + const february = parseDate("2024-02-10") + const december = parseDate("2024-12-05") + + const sorted = sortDates([december, february, january]) + expect(sorted).toEqual([january, february, december]) + }) + + test("orders by year, not by month or day", () => { + const y2023 = parseDate("2023-12-31") + const y2024 = parseDate("2024-06-15") + const y2025 = parseDate("2025-01-01") + + const sorted = sortDates([y2025, y2023, y2024]) + expect(sorted).toEqual([y2023, y2024, y2025]) + }) + + test("returns the array unsorted when it contains a null", () => { + const date1 = parseDate("2024-01-20") + const date2 = parseDate("2024-01-10") + + expect(sortDates([date1, null, date2])).toEqual([date1, null, date2]) + }) + + test("does not mutate the original array", () => { + const date1 = parseDate("2024-01-15") + const date2 = parseDate("2024-01-20") + const values = [date2, date1] + const original = [...values] + + sortDates(values) + expect(values).toEqual(original) + }) +}) + +describe("adjustStartAndEndDate", () => { + test("returns value as-is when dates are already in order", () => { + const start = parseDate("2024-01-10") + const end = parseDate("2024-01-20") + + expect(adjustStartAndEndDate([start, end])).toEqual([start, end]) + }) + + test("swaps dates when the end date is before the start date", () => { + const start = parseDate("2024-01-20") + const end = parseDate("2024-01-10") + + expect(adjustStartAndEndDate([start, end])).toEqual([end, start]) + }) + + test("swaps across a year boundary even when the day of month is larger", () => { + const start = parseDate("2025-01-01") + const end = parseDate("2024-12-31") + + expect(adjustStartAndEndDate([start, end])).toEqual([end, start]) + }) + + test("returns value as-is when either endpoint is null", () => { + const date = parseDate("2024-01-20") + + expect(adjustStartAndEndDate([null, date])).toEqual([null, date]) + expect(adjustStartAndEndDate([date, null])).toEqual([date, null]) + expect(adjustStartAndEndDate([null, null])).toEqual([null, null]) + }) +}) + +describe("normalizeValueForMode", () => { + const a = parseDate("2024-01-10") + const b = parseDate("2024-01-20") + const c = parseDate("2024-01-30") + + test("single keeps only the first non-null date", () => { + expect(normalizeValueForMode([null, a, b], "single")).toEqual([a]) + }) + + test("multiple drops nulls but keeps all remaining dates", () => { + expect(normalizeValueForMode([a, null, b, null, c], "multiple")).toEqual([ + a, + b, + c, + ]) + }) + + test("range preserves a positional hole rather than collapsing it", () => { + expect(normalizeValueForMode([null, b], "range")).toEqual([null, b]) + }) + + test("range truncates to two endpoints and orders them", () => { + expect(normalizeValueForMode([c, a, b], "range")).toEqual([a, c]) + }) + + test("range orders a reversed pair", () => { + expect(normalizeValueForMode([c, a], "range")).toEqual([a, c]) + }) + + test("range preserves a hole rather than ordering around it", () => { + expect(normalizeValueForMode([null, a], "range")).toEqual([null, a]) + expect(normalizeValueForMode([a, null], "range")).toEqual([a, null]) + expect(normalizeValueForMode([], "range")).toEqual([]) + expect(normalizeValueForMode([a], "range")).toEqual([a]) + }) + + test("single and multiple keep dates in the given order", () => { + expect(normalizeValueForMode([c, a], "single")).toEqual([c]) + expect(normalizeValueForMode([c, a, b], "multiple")).toEqual([c, a, b]) + }) +}) + +describe("isDateWithinRange", () => { + const start = parseDate("2024-01-10") + const end = parseDate("2024-01-20") + + test("returns true when the date is inside the range", () => { + expect(isDateWithinRange(parseDate("2024-01-15"), [start, end])).toBe(true) + }) + + test("returns false when the date is outside the range", () => { + expect(isDateWithinRange(parseDate("2024-01-05"), [start, end])).toBe(false) + expect(isDateWithinRange(parseDate("2024-01-25"), [start, end])).toBe(false) + }) + + test("treats both endpoints as inclusive", () => { + expect(isDateWithinRange(start, [start, end])).toBe(true) + expect(isDateWithinRange(end, [start, end])).toBe(true) + expect(isDateWithinRange(parseDate("2024-01-09"), [start, end])).toBe(false) + expect(isDateWithinRange(parseDate("2024-01-21"), [start, end])).toBe(false) + }) + + test("returns false when either endpoint is null", () => { + const date = parseDate("2024-01-15") + expect(isDateWithinRange(date, [null, end])).toBe(false) + expect(isDateWithinRange(date, [start, null])).toBe(false) + expect(isDateWithinRange(date, [null, null])).toBe(false) + }) + + test("spans month and year boundaries", () => { + const range = [parseDate("2024-11-20"), parseDate("2025-02-10")] + + expect(isDateWithinRange(parseDate("2024-12-31"), range)).toBe(true) + expect(isDateWithinRange(parseDate("2025-01-05"), range)).toBe(true) + expect(isDateWithinRange(parseDate("2024-11-19"), range)).toBe(false) + expect(isDateWithinRange(parseDate("2025-02-11"), range)).toBe(false) + }) +}) + +describe("isValidDate", () => { + test("returns true for a real date", () => { + expect(isValidDate(parseDate("2024-01-15"))).toBe(true) + }) + + test("returns false when any single component is NaN", () => { + expect(isValidDate(new CalendarDate(NaN, 1, 15))).toBe(false) + expect(isValidDate(new CalendarDate(2024, NaN, 15))).toBe(false) + expect(isValidDate(new CalendarDate(2024, 1, NaN))).toBe(false) + expect(isValidDate(new CalendarDate(NaN, NaN, NaN))).toBe(false) + }) +}) + +describe("view helpers", () => { + test("clampView constrains the view between min and max", () => { + expect(clampView("day", "month", "year")).toBe("month") + expect(clampView("year", "day", "month")).toBe("month") + expect(clampView("month", "day", "year")).toBe("month") + }) + + test("clampView falls back to the day floor and year ceiling when bounds are omitted", () => { + expect(clampView(undefined, undefined, undefined)).toBe("day") + expect(clampView("year", undefined, undefined)).toBe("year") + expect(clampView("day", "month", undefined)).toBe("month") + expect(clampView("year", undefined, "month")).toBe("month") + }) + + test("isAboveMinView / isBelowMinView compare view rank", () => { + expect(isAboveMinView("year", "month")).toBe(true) + expect(isAboveMinView("day", "day")).toBe(false) + expect(isBelowMinView("day", "month")).toBe(true) + expect(isBelowMinView("year", "day")).toBe(false) + }) + + test("getNextView advances one rank, clamped to the year ceiling", () => { + expect(getNextView("day", "day", "year")).toBe("month") + expect(getNextView("month", "day", "year")).toBe("year") + expect(getNextView("year", "day", "year")).toBe("year") + }) + + test("getNextView respects a maxView below year", () => { + expect(getNextView("day", "day", "month")).toBe("month") + expect(getNextView("month", "day", "month")).toBe("month") + expect(getNextView("day", "day", "day")).toBe("day") + }) + + test("getPreviousView steps back one rank, clamped to the day floor", () => { + expect(getPreviousView("year", "day", "year")).toBe("month") + expect(getPreviousView("month", "day", "year")).toBe("day") + expect(getPreviousView("day", "day", "year")).toBe("day") + expect(getPreviousView("month", "month", "year")).toBe("month") + }) + + test("eachView visits every view once, from day to year", () => { + const visited: string[] = [] + eachView((view) => visited.push(view)) + expect(visited).toEqual(["day", "month", "year"]) + }) +}) + +describe("getRoleDescription", () => { + test("names the span each view covers", () => { + expect(getRoleDescription("day")).toBe("calendar month") + expect(getRoleDescription("month")).toBe("calendar year") + expect(getRoleDescription("year")).toBe("calendar decade") + }) +}) + +describe("getInputPlaceholder", () => { + test("mirrors the locale's field order and separators", () => { + expect(getInputPlaceholder("en-US")).toBe("mm/dd/yyyy") + expect(getInputPlaceholder("en-GB")).toBe("dd/mm/yyyy") + expect(getInputPlaceholder("de-DE")).toBe("dd.mm.yyyy") + expect(getInputPlaceholder("ja-JP")).toBe("yyyy/mm/dd") + }) +}) + +describe("getVisibleRangeText", () => { + const startValue = parseDate("2026-06-01") + const endValue = parseDate("2026-07-31") + + const visibleRangeText = ( + overrides: Partial[0]> = {}, + ) => + getVisibleRangeText({ + endValue, + locale: "en", + startValue, + timeZone: "UTC", + view: "day", + ...overrides, + }) + + test("day view formats both endpoints with a spelled-out month", () => { + const range = visibleRangeText() + + expect(range.start).toBe("June 2026") + expect(range.end).toBe("July 2026") + expect(range.formatted).toBe("June 2026 - July 2026") + }) + + test("collapses the label when a single month is visible", () => { + const range = visibleRangeText({endValue: parseDate("2026-06-30")}) + + expect(range.start).toBe("June 2026") + expect(range.end).toBe("June 2026") + expect(range.formatted).toBe("June 2026") + }) + + test("month view formats the year only", () => { + const result = visibleRangeText({view: "month"}) + expect(result.formatted).toBe("2026") + }) + + test("year view spans the whole decade containing the start year", () => { + const result = visibleRangeText({view: "year"}) + + expect(result.start).toBe("2020") + expect(result.end).toBe("2029") + expect(result.formatted).toBe("2020 - 2029") + }) +}) diff --git a/packages/common/core/src/date-picker/date-picker.anatomy.ts b/packages/common/core/src/date-picker/date-picker.anatomy.ts new file mode 100644 index 000000000..432659cc0 --- /dev/null +++ b/packages/common/core/src/date-picker/date-picker.anatomy.ts @@ -0,0 +1,41 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {type Anatomy, createAnatomy} from "@qualcomm-ui/utils/anatomy" + +export const datePickerParts = [ + "root", + "clearTrigger", + "content", + "control", + "errorIndicator", + "errorText", + "hiddenInput", + "hint", + "input", + "label", + "nextTrigger", + "positioner", + "presets", + "presetsTrigger", + "presetTrigger", + "prevTrigger", + "rangeText", + "table", + "tableBody", + "tableCell", + "tableCellTrigger", + "tableHead", + "tableHeader", + "tableRow", + "trigger", + "view", + "viewCloseTrigger", + "viewControl", + "viewTrigger", +] as const + +export const datePickerAnatomy: Anatomy< + "datePicker", + (typeof datePickerParts)[number] +> = createAnatomy("datePicker").parts(...datePickerParts) diff --git a/packages/common/core/src/date-picker/date-picker.api.ts b/packages/common/core/src/date-picker/date-picker.api.ts new file mode 100644 index 000000000..142c70271 --- /dev/null +++ b/packages/common/core/src/date-picker/date-picker.api.ts @@ -0,0 +1,1443 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + DateFormatter, + type DateValue, + isEqualDay, + isEqualMonth, + isEqualYear, + isSameDay, + isToday, + isWeekend, + toCalendarDateTime, +} from "@internationalized/date" + +import { + getPlacementSide, + getPlacementStyles, +} from "@qualcomm-ui/dom/floating-ui" +import { + getEventKey, + getNativeEvent, + isComposingEvent, + visuallyHiddenStyle, +} from "@qualcomm-ui/dom/query" +import {chunk} from "@qualcomm-ui/utils/array" +import { + booleanAriaAttr, + booleanDataAttr, + mergeAriaIds, +} from "@qualcomm-ui/utils/attributes" +import { + constrainValue, + getDateRangePreset, + getDayFormatter, + getDaysInWeek, + getDecadeRange, + getDefaultYearRange, + getMonthDays, + getMonthFormatter, + getMonthNames, + getTodayDate, + getUnitDuration, + getWeekDays, + getYearsRange, + isDateEqual, + isDateOutsideRange, + isMonthOutsideRange, + isDateUnavailable, + isValidCharacter, + ensureValidCharacters, + getLocaleSeparator, +} from "@qualcomm-ui/utils/date-utils" +import type {Direction} from "@qualcomm-ui/utils/direction" +import type { + EventKeyMap, + Machine, + PropNormalizer, +} from "@qualcomm-ui/utils/machine" +import {isValueWithinRange} from "@qualcomm-ui/utils/number" +import {compact} from "@qualcomm-ui/utils/object" + +import {datePickerAnatomy} from "./date-picker.anatomy.js" +import type { + DatePickerApi, + DatePickerSchema, + DatePickerApiDayTableCellProps, + DatePickerDayTableCellState, + DatePickerApiTableCellProps, + DatePickerTableCellState, + DatePickerTime, +} from "./date-picker.types.js" +import { + adjustStartAndEndDate, + clampView, + defaultTranslations, + getInputPlaceholder, + getRoleDescription, + isDateWithinRange, +} from "./date-picker.utils.js" +import {domEls, domIds, isInteractiveDescendantEvent} from "./internal/index.js" + +export function createDatePickerApi( + machine: Machine, + normalize: PropNormalizer, +): DatePickerApi { + const {computed, context, prop, scope, send, state} = machine + + const startValue = context.get("startValue") + const endValue = computed("endValue") + const selectedValue = context.get("value") + const focusedValue = context.get("focusedValue") + + const hoveredValue = context.get("hoveredValue") + const anchorIndex = context.get("activeIndex") === 0 ? 1 : 0 + const hoveredRangeValue = hoveredValue + ? adjustStartAndEndDate([selectedValue[anchorIndex], hoveredValue]) + : [] + + const disabled = computed("disabled") + const readOnly = Boolean(prop("readOnly")) + const invalid = Boolean(prop("invalid")) + const interactive = computed("isInteractive") + + const empty = selectedValue.every((date) => date == null) + + const min = prop("min") + const max = prop("max") + const locale = prop("locale") + const timeZone = prop("timeZone") + const startOfWeek = prop("startOfWeek") + + const focused = state.matches("focused") + const open = state.matches("open") + const presetsOpen = context.get("presetsOpen") + + const isRangePicker = prop("selectionMode") === "range" + const isMultiPicker = prop("selectionMode") === "multiple" + const isDateUnavailableFn = prop("isDateUnavailable") + const maxSelectedDates = prop("maxSelectedDates") + const isMaxSelected = + isMultiPicker && + maxSelectedDates != null && + selectedValue.length >= maxSelectedDates + + const currentPlacement = context.get("currentPlacement") + const currentPlacementSide = currentPlacement + ? getPlacementSide(currentPlacement) + : undefined + const popperStyles = getPlacementStyles({ + ...prop("positioning"), + placement: currentPlacement, + }) + + const separator = getLocaleSeparator(locale!) + const translations = { + ...defaultTranslations, + ...compact(prop("translations")), + } + const inputFormat = getInputPlaceholder(locale!) + + const dayFormatter = getDayFormatter(locale!, timeZone!, focusedValue) + const monthFormatter = getMonthFormatter(locale!, timeZone!, focusedValue) + const unitDuration = getUnitDuration(computed("visibleDuration")) + + const triggerLabel = translations.trigger({ + open, + selectionMode: prop("selectionMode")!, + valueText: selectedValue.map((date) => + date ? dayFormatter.format(date.toDate(timeZone!)) : undefined, + ), + }) + + function getMonthWeeks(from = startValue) { + const numOfWeeks = prop("fixedWeeks") ? 6 : undefined + return getMonthDays(from, locale!, numOfWeeks, startOfWeek) + } + + function getMonths(props: {format?: "short" | "long" | undefined} = {}) { + const {format} = props + return getMonthNames(locale!, format, focusedValue).map((label, index) => { + const value = index + 1 + const dateValue = focusedValue.set({month: value}) + const disabled = isMonthOutsideRange(dateValue, min, max) + return {disabled, label, value} + }) + } + + function getYears() { + const defaultRange = getDefaultYearRange(focusedValue, min, max) + const range = getYearsRange(defaultRange) + return range.map((year) => ({ + disabled: !isValueWithinRange( + year, + min?.year as number, + max?.year as number, + ), + label: year.toString(), + value: year, + })) + } + + function isUnavailable(date: DateValue) { + return isDateUnavailable(date, isDateUnavailableFn, locale!, min, max) + } + + function focusMonth(month: number) { + const date = startValue ?? getTodayDate(timeZone, focusedValue.calendar) + send({type: "FOCUS.SET", value: date.set({month})}) + } + + function focusYear(year: number) { + const date = startValue ?? getTodayDate(timeZone, focusedValue.calendar) + send({type: "FOCUS.SET", value: date.set({year})}) + } + + function getYearTableCellState( + props: DatePickerApiTableCellProps, + ): DatePickerTableCellState { + const {disabled: disabledProp, value} = props + const dateValue = focusedValue.set({year: value}) + + const decadeYears = getDecadeRange(startValue.year, {strict: true}) + const isOutsideVisibleRange = !decadeYears.includes(value) + const isWithinMinMax = isValueWithinRange(value, min?.year, max?.year) + + const isInSelectedRange = + isRangePicker && isDateWithinRange(dateValue, selectedValue) + const isFirstInSelectedRange = + isRangePicker && + !!selectedValue[0] && + isEqualYear(dateValue, selectedValue[0]) + const isLastInSelectedRange = + isRangePicker && + !!selectedValue[1] && + isEqualYear(dateValue, selectedValue[1]) + + const hasHoveredRange = isRangePicker && hoveredRangeValue.length > 0 + const isInHoveredRange = + hasHoveredRange && isDateWithinRange(dateValue, hoveredRangeValue) + const isFirstInHoveredRange = + hasHoveredRange && + !!hoveredRangeValue[0] && + isEqualYear(dateValue, hoveredRangeValue[0]) + const isLastInHoveredRange = + hasHoveredRange && + !!hoveredRangeValue[1] && + isEqualYear(dateValue, hoveredRangeValue[1]) + + const cellState = { + get disabled() { + return disabled || disabledProp || !cellState.selectable + }, + firstInHoveredRange: isFirstInHoveredRange, + firstInRange: isFirstInSelectedRange, + focused: focusedValue.year === props.value, + inHoveredRange: isInHoveredRange, + inRange: isInSelectedRange || isInHoveredRange, + lastInHoveredRange: isLastInHoveredRange, + lastInRange: isLastInSelectedRange, + outsideRange: isOutsideVisibleRange, + selectable: !isOutsideVisibleRange && isWithinMinMax, + selected: !!selectedValue.find((date) => date && date.year === value), + value: dateValue, + valueText: value.toString(), + } + return cellState + } + + function getMonthTableCellState( + props: DatePickerApiTableCellProps, + ): DatePickerTableCellState { + const {disabled: disabledProp, value} = props + const dateValue = focusedValue.set({month: value}) + + const isInSelectedRange = + isRangePicker && isDateWithinRange(dateValue, selectedValue) + const isFirstInSelectedRange = + isRangePicker && + !!selectedValue[0] && + isEqualMonth(dateValue, selectedValue[0]) + const isLastInSelectedRange = + isRangePicker && + !!selectedValue[1] && + isEqualMonth(dateValue, selectedValue[1]) + + const hasHoveredRange = isRangePicker && hoveredRangeValue.length > 0 + const isInHoveredRange = + hasHoveredRange && isDateWithinRange(dateValue, hoveredRangeValue) + const isFirstInHoveredRange = + hasHoveredRange && + !!hoveredRangeValue[0] && + isEqualMonth(dateValue, hoveredRangeValue[0]) + const isLastInHoveredRange = + hasHoveredRange && + !!hoveredRangeValue[1] && + isEqualMonth(dateValue, hoveredRangeValue[1]) + + const cellState = { + get disabled() { + return disabled || disabledProp || !cellState.selectable + }, + firstInHoveredRange: isFirstInHoveredRange, + firstInRange: isFirstInSelectedRange, + focused: focusedValue.month === props.value, + inHoveredRange: isInHoveredRange, + inRange: isInSelectedRange || isInHoveredRange, + lastInHoveredRange: isLastInHoveredRange, + lastInRange: isLastInSelectedRange, + outsideRange: false, + selectable: !isMonthOutsideRange(dateValue, min, max), + selected: !!selectedValue.find( + (date) => + date && date.month === value && date.year === focusedValue.year, + ), + value: dateValue, + valueText: monthFormatter.format(dateValue.toDate(timeZone!)), + } + return cellState + } + + function getDayTableCellState( + props: DatePickerApiDayTableCellProps, + ): DatePickerDayTableCellState { + const { + disabled: disabledProp, + value, + visibleRange = computed("visibleRange"), + } = props + + const outsideDaySelectable = prop("outsideDaySelectable") + + const end = visibleRange.start.add(unitDuration).subtract({days: 1}) + const isOutsideRange = isDateOutsideRange(value, visibleRange.start, end) + + // Check if max number of dates has been reached (for multiple selection mode) + const isSelected = selectedValue.some( + (date) => date != null && isSameDay(value, date), + ) + + // Calculate range states + const isInSelectedRange = + isRangePicker && isDateWithinRange(value, selectedValue) + const isFirstInSelectedRange = + isRangePicker && isDateEqual(value, selectedValue[0]) + const isLastInSelectedRange = + isRangePicker && isDateEqual(value, selectedValue[1]) + + // Calculate hover range states + const hasHoveredRange = isRangePicker && hoveredRangeValue.length > 0 + const isInHoveredRange = + hasHoveredRange && isDateWithinRange(value, hoveredRangeValue) + const isFirstInHoveredRange = + hasHoveredRange && isDateEqual(value, hoveredRangeValue[0]) + const isLastInHoveredRange = + hasHoveredRange && isDateEqual(value, hoveredRangeValue[1]) + + const cellState = { + disabled: + disabled || + disabledProp || + (!outsideDaySelectable && isOutsideRange) || + isDateOutsideRange(value, min, max) || + // Disable unselected dates when max is reached in multiple selection mode + (isMaxSelected && !isSelected), + firstInHoveredRange: isFirstInHoveredRange, + firstInRange: isFirstInSelectedRange, + get focused() { + return !!( + isDateEqual(value, focusedValue) && + (!cellState.outsideRange || outsideDaySelectable) + ) + }, + // Preview range states + inHoveredRange: isInHoveredRange, + // Range states + inRange: isInSelectedRange || isInHoveredRange, + invalid: isDateOutsideRange(value, min, max), + lastInHoveredRange: isLastInHoveredRange, + lastInRange: isLastInSelectedRange, + + outsideRange: isOutsideRange, + get selectable() { + return !cellState.disabled && !cellState.unavailable + }, + selected: isSelected, + + today: isToday(value, timeZone!), + unavailable: + isDateUnavailable(value, isDateUnavailableFn, locale!, min, max) && + !disabledProp, + value, + valueText: dayFormatter.format(value.toDate(timeZone!)), + weekend: isWeekend(value, locale!), + } + + return cellState + } + + const commonProps: {dir: Direction | undefined} = { + dir: prop("dir"), + } + + const parts = datePickerAnatomy.parts + + return { + cancel() { + send({type: "CANCEL"}) + }, + clearValue(options = {}) { + const {focus = true} = options + send({focus, type: "VALUE.CLEAR"}) + }, + disabled, + focused, + focusedValue, + focusedValueAsDate: focusedValue?.toDate(timeZone!), + focusedValueAsString: prop("format")!(focusedValue, { + locale: locale!, + timeZone: timeZone!, + }), + focusMonth, + focusYear, + format(value, opts = {month: "long", year: "numeric"}) { + return new DateFormatter(locale!, { + ...opts, + calendar: value.calendar.identifier, + timeZone: opts.timeZone ?? timeZone!, + }).format(value.toDate(timeZone!)) + }, + getDaysInWeek(week, from = startValue) { + return getDaysInWeek(week, from, locale!, startOfWeek) + }, + getDayTableCellState, + getDecade() { + const years = getDecadeRange(startValue.year, {strict: true}) + return {end: years.at(-1), start: years.at(0)} + }, + getMonths, + getMonthsGrid(props = {}) { + const {columns = 1, format} = props + return chunk(getMonths({format}), columns) + }, + getMonthTableCellState, + getMonthWeeks, + getOffset(duration) { + const from = startValue.add(duration) + const end = endValue.add(duration) + return { + visibleRange: {end, start: from}, + visibleRangeText: { + end: monthFormatter.format(end.toDate(timeZone!)), + start: monthFormatter.format(from.toDate(timeZone!)), + }, + weeks: getMonthWeeks(from), + } + }, + getRangePresetValue(preset) { + return getDateRangePreset(preset, locale!, timeZone!) + }, + getYears, + getYearsGrid(props = {}) { + const {columns = 1} = props + const years = getDecadeRange(startValue.year, {strict: true}).map( + (year) => ({ + disabled: !isValueWithinRange( + year, + min?.year as number, + max?.year as number, + ), + label: year.toString(), + value: year, + }), + ) + return chunk(years, columns) + }, + getYearTableCellState, + goToNext() { + send({type: "GOTO.NEXT", view: context.get("view")}) + }, + goToPrev() { + send({type: "GOTO.PREV", view: context.get("view")}) + }, + inline: !!prop("inline"), + invalid, + isMaxSelected, + isUnavailable, + maxSelectedDates, + numOfMonths: prop("numOfMonths")!, + open, + presetsOpen, + readOnly, + selectionMode: prop("selectionMode")!, + selectToday() { + const value = constrainValue( + getTodayDate(timeZone, focusedValue.calendar), + min, + max, + ) + send({type: "VALUE.SET", value: [value]}) + }, + setFocusedValue(value) { + send({type: "FOCUS.SET", value}) + }, + setOpen(nextOpen) { + if (prop("inline")) { + return + } + const open = state.matches("open") + if (open === nextOpen) { + return + } + send({type: nextOpen ? "OPEN" : "CLOSE"}) + }, + setTime(time: DatePickerTime, index = 0) { + const values = Array.from(selectedValue) + let dateValue = values[index] + if (!dateValue) { + return + } + + // Convert CalendarDate to CalendarDateTime/ZonedDateTime if needed + if (!("hour" in dateValue)) { + dateValue = toCalendarDateTime(dateValue) + } + + // Set time components + dateValue = dateValue.set({ + hour: time.hour ?? ("hour" in dateValue ? dateValue.hour : 0), + millisecond: + time.millisecond ?? + ("millisecond" in dateValue ? dateValue.millisecond : 0), + minute: time.minute ?? ("minute" in dateValue ? dateValue.minute : 0), + second: time.second ?? ("second" in dateValue ? dateValue.second : 0), + }) + + values[index] = constrainValue(dateValue, min, max) + send({type: "VALUE.SET", value: values}) + }, + setValue(values) { + const computedValue = values.map((date) => + date == null ? date : constrainValue(date, min, max), + ) + send({type: "VALUE.SET", value: computedValue}) + }, + setView(view) { + send({type: "VIEW.SET", view}) + }, + toggleValue(value) { + if (!interactive) { + return + } + send({type: "VALUE.TOGGLE", value: constrainValue(value, min, max)}) + }, + value: selectedValue, + valueAsDate: selectedValue.map((date) => + date == null ? null : date.toDate(timeZone!), + ), + valueAsString: computed("valueAsString"), + view: context.get("view"), + visibleRange: computed("visibleRange"), + visibleRangeText: computed("visibleRangeText"), + get weekDays() { + return getWeekDays(startValue, startOfWeek, timeZone!, locale!) + }, + get weeks() { + return getMonthWeeks() + }, + + // group: bindings getters + + getClearTriggerBindings(props) { + scope.ids.register("clearTrigger", props) + return normalize.button({ + ...commonProps, + "aria-label": translations.clearTrigger, + ...parts.clearTrigger, + disabled, + hidden: !selectedValue.length, + id: props.id, + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + send({type: "VALUE.CLEAR"}) + }, + type: "button", + }) + }, + getContentBindings(props) { + scope.ids.register("content", props) + return normalize.element({ + ...commonProps, + "aria-label": translations.content, + "aria-roledescription": "datepicker", + "data-inline": booleanDataAttr(prop("inline")), + ...parts.content, + "data-placement": currentPlacement!, + "data-presets-open": booleanDataAttr(presetsOpen), + "data-side": currentPlacementSide, + "data-state": open ? "open" : "closed", + hidden: !open, + id: props.id, + role: prop("inline") ? "group" : "dialog", + tabIndex: -1, + }) + }, + getControlBindings(props) { + scope.ids.register("control", props) + return normalize.element({ + ...commonProps, + "data-disabled": booleanDataAttr(disabled), + ...parts.control, + "data-placeholder-shown": booleanDataAttr(empty), + id: props.id, + }) + }, + getDayTableCellBindings(props) { + const cellState = getDayTableCellState(props) + return normalize.element({ + ...commonProps, + "aria-current": cellState.today ? "date" : undefined, + "aria-disabled": booleanAriaAttr(!cellState.selectable), + "aria-invalid": booleanAriaAttr(cellState.invalid), + "aria-selected": cellState.selected || cellState.inRange, + ...parts.tableCell, + "data-value": props.value.toString(), + role: "gridcell", + }) + }, + getDayTableCellTriggerBindings(props) { + const cellState = getDayTableCellState(props) + const {value} = props + return normalize.element({ + ...commonProps, + "aria-disabled": booleanAriaAttr(!cellState.selectable), + "aria-invalid": booleanAriaAttr(cellState.invalid), + "aria-label": translations.dayCell(cellState), + "data-disabled": booleanDataAttr(!cellState.selectable), + "data-focus": booleanDataAttr(cellState.focused), + "data-hover-range-end": booleanDataAttr(cellState.lastInHoveredRange), + "data-hover-range-start": booleanDataAttr( + cellState.firstInHoveredRange, + ), + "data-in-hover-range": booleanDataAttr(cellState.inHoveredRange), + "data-in-range": booleanDataAttr(cellState.inRange), + "data-outside-range": booleanDataAttr(cellState.outsideRange), + ...parts.tableCellTrigger, + "data-range-end": booleanDataAttr(cellState.lastInRange), + "data-range-start": booleanDataAttr(cellState.firstInRange), + "data-selectable": booleanDataAttr(cellState.selectable), + "data-selected": booleanDataAttr(cellState.selected), + "data-today": booleanDataAttr(cellState.today), + "data-unavailable": booleanDataAttr(cellState.unavailable), + "data-value": value.toString(), + "data-view": "day", + "data-weekend": booleanDataAttr(cellState.weekend), + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + if (!cellState.selectable) { + return + } + send({cell: "day", type: "CELL.CLICK", value}) + }, + onPointerMove: isRangePicker + ? (event) => { + if (event.pointerType === "touch") { + return + } + if (!cellState.selectable) { + return + } + const focus = + (event.currentTarget as Node)?.ownerDocument?.activeElement !== + event.currentTarget + if (hoveredValue && isEqualDay(value, hoveredValue)) { + return + } + send({ + cell: "day", + focus, + outsideRange: cellState.outsideRange, + type: "CELL.POINTER_MOVE", + value, + }) + } + : undefined, + role: "button", + tabIndex: disabled ? -1 : cellState.focused ? 0 : -1, + }) + }, + getErrorIndicatorBindings() { + return normalize.element({ + ...parts.errorIndicator, + "aria-label": translations.errorIndicator, + hidden: !invalid, + }) + }, + getErrorTextBindings(props) { + scope.ids.register("errorText", props) + return normalize.element({ + ...parts.errorText, + "aria-live": "polite", + hidden: !invalid, + id: domIds.errorText(scope), + }) + }, + getHiddenInputBindings(props) { + const {index = 0, value} = props + return normalize.input({ + ...commonProps, + ...parts.hiddenInput, + "aria-hidden": true, + "data-index": index, + disabled, + name: prop("name"), + onChange() {}, + onFocus() { + domEls.trigger(scope)?.focus({preventScroll: true}) + }, + readOnly, + required: index === 0 ? prop("required") : undefined, + style: visuallyHiddenStyle, + tabIndex: -1, + type: "text", + value: value ?? "", + }) + }, + getHintBindings(props) { + scope.ids.register("hint", props) + return normalize.element({ + ...parts.hint, + "data-disabled": booleanDataAttr(disabled), + hidden: invalid, + id: domIds.hint(scope), + }) + }, + getInputBindings(props) { + const {fixOnBlur = true, index = 0} = props + scope.ids + .collection("input") + .register(index.toString(), props.id, props.onDestroy) + + return normalize.input({ + ...commonProps, + "aria-describedby": mergeAriaIds( + invalid ? undefined : domIds.hint(scope), + invalid ? domIds.errorText(scope) : undefined, + ), + "aria-description": translations.inputDescription(inputFormat), + "aria-invalid": booleanAriaAttr(invalid), + "aria-label": isRangePicker + ? index === 0 + ? translations.rangeInputStart + : translations.rangeInputEnd + : undefined, + autoComplete: "off", + autoCorrect: "off", + "data-index": index, + ...parts.input, + "data-invalid": booleanDataAttr(invalid), + "data-placeholder-shown": booleanDataAttr(empty), + "data-state": open ? "open" : "closed", + defaultValue: computed("valueAsString")[index] ?? "", + disabled, + id: props.id, + name: prop("name"), + onBeforeInput(event) { + const {data} = getNativeEvent(event) + if (!isValidCharacter(data, separator)) { + event.preventDefault() + } + }, + onBlur(event) { + const value = (event.currentTarget as HTMLInputElement)?.value.trim() + send({fixOnBlur, index, type: "INPUT.BLUR", value}) + }, + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!prop("openOnClick")) { + return + } + if (!interactive) { + return + } + send({src: "input.click", type: "OPEN"}) + }, + onFocus() { + send({index, type: "INPUT.FOCUS"}) + }, + onInput(event) { + const value = (event.currentTarget as HTMLInputElement)?.value + send({ + index, + type: "INPUT.CHANGE", + value: ensureValidCharacters(value, separator), + }) + }, + onKeyDown(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + const keyMap: EventKeyMap = { + Enter(event) { + // TODO: consider form submission (with enter key) + if (isComposingEvent(event)) { + return + } + if (event.currentTarget?.value.trim() === "") { + return + } + send({ + index, + type: "INPUT.ENTER", + value: event.currentTarget?.value, + }) + }, + } + + const exec = keyMap[event.key] + if (exec) { + exec(event) + event.preventDefault() + } + }, + placeholder: prop("placeholder") || inputFormat, + readOnly, + required: prop("required"), + spellCheck: "false", + }) + }, + getInputGroupBindings() { + if (!isMultiPicker) { + return normalize.element({...commonProps}) + } + return normalize.element({ + ...commonProps, + "aria-describedby": mergeAriaIds( + domIds.hint(scope), + invalid ? domIds.errorText(scope) : undefined, + ), + "aria-invalid": booleanAriaAttr(invalid), + "data-invalid": booleanDataAttr(invalid), + }) + }, + getInputGroupTriggerBindings(props) { + scope.ids.register("trigger", props) + return normalize.element({ + ...commonProps, + "aria-controls": domIds.content(scope), + "aria-disabled": booleanAriaAttr(disabled), + "aria-expanded": open, + "aria-haspopup": "dialog", + "aria-invalid": booleanAriaAttr(invalid), + "aria-label": triggerLabel, + "aria-readonly": booleanAriaAttr(readOnly), + "aria-required": booleanAriaAttr(prop("required")), + "data-disabled": booleanDataAttr(disabled), + "data-placeholder-shown": booleanDataAttr(empty), + "data-placement": currentPlacement, + "data-readonly": booleanDataAttr(readOnly), + "data-side": currentPlacementSide, + "data-state": open ? "open" : "closed", + id: props.id, + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + // tags and the clear button own their own clicks + if (isInteractiveDescendantEvent(event)) { + return + } + send({type: "TRIGGER.CLICK"}) + }, + onKeyDown(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + if (event.target !== event.currentTarget) { + return + } + + const keyMap: EventKeyMap = { + ArrowDown() { + send({src: "input-group-trigger", type: "OPEN"}) + }, + Enter() { + send({type: "TRIGGER.CLICK"}) + }, + Space() { + send({type: "TRIGGER.CLICK"}) + }, + } + + const exec = keyMap[getEventKey(event, {dir: prop("dir")})] + if (exec) { + exec(event) + event.preventDefault() + } + }, + role: "combobox", + tabIndex: disabled ? -1 : 0, + }) + }, + getLabelBindings(props = {}) { + const {index = 0} = props + const idx = index.toString() + return normalize.label({ + ...commonProps, + "data-disabled": booleanDataAttr(disabled), + "data-index": index, + ...parts.label, + "data-readonly": booleanDataAttr(readOnly), + "data-state": open ? "open" : "closed", + htmlFor: isMultiPicker ? undefined : domIds.input(scope, idx), + // no input to associate with, so stand in for native label behavior + onClick: isMultiPicker + ? (event) => { + if (event.defaultPrevented || disabled) { + return + } + domEls.trigger(scope)?.focus({preventScroll: true}) + } + : undefined, + }) + }, + getMonthTableCellBindings(props) { + const cellState = getMonthTableCellState(props) + return normalize.element({ + ...commonProps, + "aria-disabled": booleanAriaAttr(!cellState.selectable), + "aria-selected": booleanAriaAttr( + cellState.selected || cellState.inRange, + ), + colSpan: props.columns, + ...parts.tableCell, + "data-selected": booleanDataAttr(cellState.selected), + "data-value": props.value, + role: "gridcell", + }) + }, + getMonthTableCellTriggerBindings(props) { + const cellState = getMonthTableCellState(props) + const {value} = props + return normalize.element({ + ...commonProps, + "aria-disabled": booleanAriaAttr(!cellState.selectable), + "aria-label": cellState.valueText, + "data-disabled": booleanDataAttr(!cellState.selectable), + "data-focus": booleanDataAttr(cellState.focused), + "data-hover-range-end": booleanDataAttr(cellState.lastInHoveredRange), + "data-hover-range-start": booleanDataAttr( + cellState.firstInHoveredRange, + ), + "data-in-hover-range": booleanDataAttr(cellState.inHoveredRange), + "data-in-range": booleanDataAttr(cellState.inRange), + ...parts.tableCellTrigger, + "data-outside-range": booleanDataAttr(cellState.outsideRange), + "data-range-end": booleanDataAttr(cellState.lastInRange), + "data-range-start": booleanDataAttr(cellState.firstInRange), + "data-selectable": booleanDataAttr(cellState.selectable), + "data-selected": booleanDataAttr(cellState.selected), + "data-value": value, + "data-view": "month", + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + if (!cellState.selectable) { + return + } + send({cell: "month", type: "CELL.CLICK", value}) + }, + onPointerMove: isRangePicker + ? (event) => { + if (event.pointerType === "touch") { + return + } + if (!cellState.selectable) { + return + } + const focus = + (event.currentTarget as Node)?.ownerDocument?.activeElement !== + event.currentTarget + if ( + hoveredValue && + cellState.value && + isEqualMonth(cellState.value, hoveredValue) + ) { + return + } + send({ + cell: "month", + focus, + type: "CELL.POINTER_MOVE", + value: cellState.value, + }) + } + : undefined, + role: "button", + tabIndex: disabled ? -1 : cellState.focused ? 0 : -1, + }) + }, + getNextTriggerBindings(props = {}) { + const {view = "day"} = props + const isDisabled = + disabled || presetsOpen || !computed("isNextVisibleRangeValid") + return normalize.button({ + ...commonProps, + "aria-label": translations.nextTrigger(view), + "data-disabled": booleanDataAttr(isDisabled), + ...parts.nextTrigger, + disabled: isDisabled, + onClick(event) { + if (event.defaultPrevented) { + return + } + send({type: "GOTO.NEXT", view}) + }, + type: "button", + }) + }, + getPositionerBindings(props) { + scope.ids.register("positioner", props) + return normalize.element({ + ...commonProps, + ...parts.positioner, + id: props.id, + style: popperStyles.floating, + }) + }, + getPresetsBindings() { + return normalize.element({ + ...commonProps, + ...parts.presets, + hidden: !presetsOpen, + }) + }, + getPresetsTriggerBindings() { + return normalize.button({ + ...commonProps, + "aria-label": translations.presetsTrigger(presetsOpen), + ...parts.presetsTrigger, + disabled: disabled || undefined, + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + send({type: "PRESET.TOGGLE"}) + }, + type: "button", + }) + }, + getPresetTriggerBindings(props) { + const value = Array.isArray(props.value) + ? props.value + : getDateRangePreset(props.value, locale!, timeZone!) + const valueAsString = value + .filter((item) => item != null) + .map((item) => dayFormatter.format(item.toDate(timeZone!))) + return normalize.button({ + ...commonProps, + "aria-label": translations.presetTrigger(valueAsString), + ...parts.presetTrigger, + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + send({type: "PRESET.CLICK", value}) + }, + type: "button", + }) + }, + getPrevTriggerBindings(props = {}) { + const {view = "day"} = props + const isDisabled = + disabled || presetsOpen || !computed("isPrevVisibleRangeValid") + return normalize.button({ + ...commonProps, + "aria-label": translations.prevTrigger(view), + "data-disabled": booleanDataAttr(isDisabled), + ...parts.prevTrigger, + disabled: isDisabled, + onClick(event) { + if (event.defaultPrevented) { + return + } + send({type: "GOTO.PREV", view}) + }, + type: "button", + }) + }, + getRangeTextBindings() { + return normalize.element({ + ...commonProps, + ...parts.rangeText, + }) + }, + getRootBindings() { + return normalize.element({ + ...commonProps, + "data-disabled": booleanDataAttr(disabled), + ...parts.root, + "data-empty": booleanDataAttr(empty), + "data-readonly": booleanDataAttr(readOnly), + "data-state": open ? "open" : "closed", + }) + }, + getTableBindings(props = {}) { + const {view = "day", columns = view === "day" ? 7 : 4} = props + return normalize.element({ + ...commonProps, + "aria-disabled": booleanAriaAttr(disabled), + "aria-label": computed("visibleRangeText").formatted, + "aria-multiselectable": booleanAriaAttr( + prop("selectionMode") !== "single", + ), + "aria-readonly": booleanAriaAttr(readOnly), + "aria-roledescription": getRoleDescription(view), + "data-columns": columns, + ...parts.table, + "data-view": view, + onKeyDown(event) { + if (event.defaultPrevented) { + return + } + // readOnly still allows roving-focus navigation + if (disabled) { + return + } + + const selectFocusedCell = () => { + if (!interactive) { + return + } + if (view === "day" && isUnavailable(focusedValue)) { + return + } + if (view === "month") { + const cellState = getMonthTableCellState({ + value: focusedValue.month, + }) + if (!cellState.selectable) { + return + } + } + if (view === "year") { + const cellState = getYearTableCellState({ + value: focusedValue.year, + }) + if (!cellState.selectable) { + return + } + } + send({columns, focus: true, type: "TABLE.ENTER", view}) + } + + const keyMap: EventKeyMap = { + ArrowDown() { + send({columns, focus: true, type: "TABLE.ARROW_DOWN", view}) + }, + ArrowLeft() { + send({columns, focus: true, type: "TABLE.ARROW_LEFT", view}) + }, + ArrowRight() { + send({columns, focus: true, type: "TABLE.ARROW_RIGHT", view}) + }, + ArrowUp() { + send({columns, focus: true, type: "TABLE.ARROW_UP", view}) + }, + End() { + send({columns, focus: true, type: "TABLE.END", view}) + }, + Enter: selectFocusedCell, + Home() { + send({columns, focus: true, type: "TABLE.HOME", view}) + }, + PageDown(event) { + send({ + columns, + focus: true, + larger: event.shiftKey, + type: "TABLE.PAGE_DOWN", + view, + }) + }, + PageUp(event) { + send({ + columns, + focus: true, + larger: event.shiftKey, + type: "TABLE.PAGE_UP", + view, + }) + }, + Space: selectFocusedCell, + } + + const exec = + keyMap[ + getEventKey(event, { + dir: prop("dir"), + }) + ] + + if (exec) { + exec(event) + event.preventDefault() + event.stopPropagation() + } + }, + onPointerDown() { + send({type: "TABLE.POINTER_DOWN", view}) + }, + onPointerLeave() { + send({type: "TABLE.POINTER_LEAVE"}) + }, + onPointerUp() { + send({type: "TABLE.POINTER_UP", view}) + }, + role: "grid", + tabIndex: -1, + }) + }, + getTableBodyBindings(props = {}) { + const {view = "day"} = props + return normalize.element({ + ...commonProps, + "data-disabled": booleanDataAttr(disabled), + ...parts.tableBody, + "data-view": view, + }) + }, + getTableHeadBindings(props = {}) { + const {view = "day"} = props + return normalize.element({ + ...commonProps, + "data-disabled": booleanDataAttr(disabled), + ...parts.tableHead, + "data-view": view, + }) + }, + getTableHeaderBindings(props = {}) { + const {view = "day"} = props + return normalize.element({ + ...commonProps, + "data-disabled": booleanDataAttr(disabled), + ...parts.tableHeader, + "data-view": view, + }) + }, + getTableRowBindings(props = {}) { + const {view = "day"} = props + return normalize.element({ + ...commonProps, + "aria-disabled": booleanAriaAttr(disabled), + "data-disabled": booleanDataAttr(disabled), + ...parts.tableRow, + "data-view": view, + }) + }, + getTriggerBindings(props) { + scope.ids.register("trigger", props) + return normalize.button({ + ...commonProps, + "aria-controls": domIds.content(scope), + "aria-expanded": open, + "aria-haspopup": "dialog", + "aria-label": triggerLabel, + ...parts.trigger, + "data-placeholder-shown": booleanDataAttr(empty), + "data-placement": currentPlacement, + "data-side": currentPlacementSide, + "data-state": open ? "open" : "closed", + disabled, + id: props.id, + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + send({type: "TRIGGER.CLICK"}) + }, + type: "button", + }) + }, + getViewBindings(props = {}) { + const {view = "day"} = props + return normalize.element({ + ...commonProps, + ...parts.view, + "data-view": view, + hidden: context.get("view") !== view, + role: "application", + }) + }, + getViewCloseTriggerBindings() { + const baseView = prop("minView")! + return normalize.button({ + ...commonProps, + "aria-label": translations.viewCloseTrigger, + ...parts.viewCloseTrigger, + disabled: disabled || undefined, + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + send({type: "VIEW.SET", view: baseView}) + }, + type: "button", + }) + }, + getViewControlBindings(props = {}) { + const {view = "day"} = props + return normalize.element({ + ...commonProps, + ...parts.viewControl, + "data-view": view, + }) + }, + getViewTriggerBindings(props = {}) { + const {goToView, view = "day"} = props + const unreachableView = + goToView != null && + clampView(goToView, prop("minView"), prop("maxView")) !== goToView + return normalize.button({ + ...commonProps, + "aria-label": translations.viewTrigger(view, goToView), + ...parts.viewTrigger, + "data-view": goToView ?? view, + disabled: disabled || presetsOpen || unreachableView, + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + if (goToView) { + send({type: "VIEW.SET", view: goToView}) + } else { + send({src: "viewTrigger", type: "VIEW.TOGGLE"}) + } + }, + type: "button", + }) + }, + getYearTableCellBindings(props) { + const cellState = getYearTableCellState(props) + return normalize.element({ + ...commonProps, + "aria-disabled": booleanAriaAttr(!cellState.selectable), + "aria-selected": booleanAriaAttr(cellState.selected), + colSpan: props.columns, + ...parts.tableCell, + "data-selected": booleanDataAttr(cellState.selected), + "data-value": props.value, + role: "gridcell", + }) + }, + getYearTableCellTriggerBindings(props) { + const cellState = getYearTableCellState(props) + const {value} = props + return normalize.element({ + ...commonProps, + "aria-disabled": booleanAriaAttr(!cellState.selectable), + "aria-label": cellState.valueText, + "data-disabled": booleanDataAttr(!cellState.selectable), + "data-focus": booleanDataAttr(cellState.focused), + "data-hover-range-end": booleanDataAttr(cellState.lastInHoveredRange), + "data-hover-range-start": booleanDataAttr( + cellState.firstInHoveredRange, + ), + "data-in-hover-range": booleanDataAttr(cellState.inHoveredRange), + "data-in-range": booleanDataAttr(cellState.inRange), + ...parts.tableCellTrigger, + "data-outside-range": booleanDataAttr(cellState.outsideRange), + "data-range-end": booleanDataAttr(cellState.lastInRange), + "data-range-start": booleanDataAttr(cellState.firstInRange), + "data-selectable": booleanDataAttr(cellState.selectable), + "data-selected": booleanDataAttr(cellState.selected), + "data-value": value, + "data-view": "year", + onClick(event) { + if (event.defaultPrevented) { + return + } + if (!interactive) { + return + } + if (!cellState.selectable) { + return + } + send({cell: "year", type: "CELL.CLICK", value}) + }, + onPointerMove: isRangePicker + ? (event) => { + if (event.pointerType === "touch") { + return + } + if (!cellState.selectable) { + return + } + const focus = + (event.currentTarget as Node)?.ownerDocument?.activeElement !== + event.currentTarget + if ( + hoveredValue && + cellState.value && + isEqualYear(cellState.value, hoveredValue) + ) { + return + } + send({ + cell: "year", + focus, + type: "CELL.POINTER_MOVE", + value: cellState.value, + }) + } + : undefined, + role: "button", + tabIndex: disabled ? -1 : cellState.focused ? 0 : -1, + }) + }, + } +} diff --git a/packages/common/core/src/date-picker/date-picker.machine.ts b/packages/common/core/src/date-picker/date-picker.machine.ts new file mode 100644 index 000000000..66ddddcde --- /dev/null +++ b/packages/common/core/src/date-picker/date-picker.machine.ts @@ -0,0 +1,2027 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + type Calendar, + type CalendarIdentifier, + DateFormatter, + type DateValue, + toCalendar, + toCalendarDateTime, + toZoned, +} from "@internationalized/date" + +import {trackDismissableElement} from "@qualcomm-ui/dom/dismissable" +import {getPlacement, type Placement} from "@qualcomm-ui/dom/floating-ui" +import {trapFocus} from "@qualcomm-ui/dom/focus-trap" +import {createLiveRegion} from "@qualcomm-ui/dom/live-region" +import { + disableTextSelection, + raf, + restoreTextSelection, + setElementValue, + trackFormControl, +} from "@qualcomm-ui/dom/query" +import { + type AdjustDateReturn, + alignDate, + constrainValue, + formatSelectedDate, + getAdjustedDateFn, + getDecadeRange, + getEndDate, + getNextPage, + getNextSection, + getPreviousPage, + getPreviousSection, + getTodayDate, + isDateEqual, + isDateOutsideRange, + isNextRangeInvalid, + isPreviousRangeInvalid, + parseDateString, +} from "@qualcomm-ui/utils/date-utils" +import { + createGuards, + createMachine, + type MachineConfig, + type Params, + type PropFn, +} from "@qualcomm-ui/utils/machine" + +import type { + DatePickerActionEvent, + DatePickerSchema, + DatePickerDateView, + DatePickerSelectionMode, +} from "./date-picker.types.js" +import { + adjustStartAndEndDate, + clampView, + eachView, + getNextView, + getPreviousView, + getVisibleRangeText, + isAboveMinView, + isBelowMinView, + isValidDate, + normalizeValueForMode, + sortDates, +} from "./date-picker.utils.js" +import {domEls, getFocusedCell, getInputEls} from "./internal/index.js" + +const {and, not} = createGuards() + +function isDateArrayEqual( + a: (DateValue | null)[], + b: (DateValue | null)[] | undefined, +) { + if (a?.length !== b?.length) { + return false + } + const len = Math.max(a.length, b.length) + for (let i = 0; i < len; i++) { + if (!isDateEqual(a[i], b[i])) { + return false + } + } + return true +} + +function canPreviewRangeSelection( + value: (DateValue | null)[], + activeIndex: number, +): boolean { + const anchorIndex = activeIndex === 0 ? 1 : 0 + return value[anchorIndex] != null && value[activeIndex] == null +} + +function getNextActiveIndex( + value: (DateValue | null)[], + mode: DatePickerSelectionMode, +): number { + return mode === "range" && value[0] != null && value[1] == null ? 1 : 0 +} + +function getValueAsString( + value: (DateValue | null)[], + prop: PropFn, +) { + return value.map((date) => { + if (date == null) { + return "" + } + return prop("format")!(date, { + locale: prop("locale")!, + timeZone: prop("timeZone")!, + }) + }) +} + +export const datePickerMachine: MachineConfig = + createMachine({ + actions: { + adjustValueToSelectionMode({context, flush, prop}) { + const mode = prop("selectionMode") || "single" + const values = context.get("value") + const next = normalizeValueForMode(values, mode) + if (!isDateArrayEqual(next, values)) { + context.set("value", next) + } + const nextActiveIndex = getNextActiveIndex(context.get("value"), mode) + if (context.get("activeIndex") !== nextActiveIndex) { + flush(() => context.set("activeIndex", nextActiveIndex)) + } + }, + announceValueText({context, prop, refs}) { + const value = context.get("value") + const locale = prop("locale")! + const timeZone = prop("timeZone")! + + let announceText: string + if (prop("selectionMode") === "range") { + const [startDate, endDate] = value + if (startDate && endDate) { + announceText = formatSelectedDate( + startDate, + endDate, + locale, + timeZone, + ) + } else if (startDate) { + announceText = formatSelectedDate(startDate, null, locale, timeZone) + } else if (endDate) { + announceText = formatSelectedDate(endDate, null, locale, timeZone) + } else { + announceText = "" + } + } else { + announceText = value + .map((date) => formatSelectedDate(date, null, locale, timeZone)) + .filter(Boolean) + .join(",") + } + + refs.get("announcer")?.announce(announceText, 3000) + }, + announceVisibleRange({computed, refs}) { + const {formatted} = computed("visibleRangeText") + refs.get("announcer")?.announce(formatted) + }, + cancelSyncInputElement({refs}) { + refs.get("syncInputElementCleanup")?.() + refs.set("syncInputElementCleanup", undefined) + }, + clearDateValue({context, event, prop, refs}) { + const index = (event as DatePickerActionEvent).index + let next: (DateValue | null)[] = [] + if (index != null && prop("selectionMode") === "range") { + const [start, end] = context.get("value") + if (index === 1) { + next = start != null ? [start] : [] + } else { + next = end != null ? [null, end] : [] + } + } + context.set("value", next) + if (event.type === "VALUE.CLEAR") { + refs.set("valueSnapshot", Array.from(next)) + } + }, + clearFocusedDate(params) { + const {context, event, prop} = params + const index = (event as DatePickerActionEvent).index + if (index != null && prop("selectionMode") === "range") { + const value = context.get("value") + const retained = index === 1 ? value[0] : value[1] + if (retained != null) { + setFocusedValue(params, retained) + return + } + } + const calendar = context.get("focusedValue").calendar + setFocusedValue(params, getTodayDate(prop("timeZone"), calendar)) + }, + clearHoveredDate({context}) { + context.set("hoveredValue", null) + }, + clearRestoreFocus({context}) { + context.set("restoreFocus", false) + }, + closePresets({context}) { + context.set("presetsOpen", false) + }, + disableTextSelection({scope}) { + disableTextSelection({ + doc: scope.getDoc(), + target: domEls.content(scope), + }) + }, + enableTextSelection({scope}) { + restoreTextSelection({ + doc: scope.getDoc(), + target: domEls.content(scope), + }) + }, + focusActiveCell({context, event, scope}) { + const evt = event as DatePickerActionEvent + if ( + ((evt.previousEvent ?? evt) as DatePickerActionEvent).src === + "input.click" + ) { + return + } + raf(() => { + const view = context.get("view") + getFocusedCell(scope, view)?.focus({preventScroll: true}) + }) + }, + focusActiveCellIfNeeded({context, event, scope}) { + if (!(event as DatePickerActionEvent).focus) { + return + } + raf(() => { + const view = context.get("view") + getFocusedCell(scope, view)?.focus({preventScroll: true}) + }) + }, + focusFirstInputElement({event, scope}) { + if ((event as DatePickerActionEvent).focus === false) { + return + } + raf(() => { + const [inputEl] = getInputEls(scope) + // if no input elements exist (trigger-only mode), + // focus the trigger instead + const elementToFocus = inputEl ?? domEls.trigger(scope) + elementToFocus?.focus({preventScroll: true}) + }) + }, + focusFirstMonth(params) { + const {context} = params + const focused = context.get("focusedValue") + const minMonth = focused.calendar.getMinimumMonthInYear?.(focused) ?? 1 + setFocusedValue(params, focused.set({month: minMonth})) + }, + focusFirstSelectedDate(params) { + const {context} = params + const firstSelected = context.get("value").find((date) => date != null) + setFocusedValue( + params, + firstSelected ?? context.initial("focusedValue"), + ) + }, + focusFirstYear(params) { + const {context} = params + const range = getDecadeRange(context.get("focusedValue").year) + const nextValue = context.get("focusedValue").set({year: range[0]}) + setFocusedValue(params, nextValue) + }, + focusInputElement({scope}) { + raf(() => { + const inputEls = getInputEls(scope) + + // If no input elements exist (trigger-only mode), + // focus the trigger instead + if (inputEls.length === 0) { + domEls.trigger(scope)?.focus({preventScroll: true}) + return + } + + const lastIndexWithValue = inputEls.findLastIndex( + (inputEl) => inputEl.value !== "", + ) + const indexToFocus = Math.max(lastIndexWithValue, 0) + + const inputEl = inputEls[indexToFocus] + inputEl?.focus({preventScroll: true}) + // move cursor to the end + inputEl?.setSelectionRange(inputEl.value.length, inputEl.value.length) + }) + }, + focusLastMonth(params) { + const {context} = params + const focused = context.get("focusedValue") + const maxMonth = focused.calendar.getMonthsInYear(focused) + setFocusedValue(params, focused.set({month: maxMonth})) + }, + focusLastYear(params) { + const {context} = params + const range = getDecadeRange(context.get("focusedValue").year) + const nextValue = context + .get("focusedValue") + .set({year: range[range.length - 1]}) + setFocusedValue(params, nextValue) + }, + focusNextDay(params) { + const {context} = params + const nextValue = context.get("focusedValue").add({days: 1}) + setFocusedValue(params, nextValue) + }, + focusNextDecade(params) { + const {context} = params + const nextValue = context.get("focusedValue").add({years: 10}) + setFocusedValue(params, nextValue) + }, + focusNextMonth(params) { + const {context} = params + setFocusedValue(params, context.get("focusedValue").add({months: 1})) + }, + focusNextMonthColumn(params) { + const {context, event} = params + const nextValue = context + .get("focusedValue") + .add({months: (event as DatePickerActionEvent).columns}) + setFocusedValue(params, nextValue) + }, + focusNextPage(params) { + const {computed, context, prop} = params + const nextPage = getNextPage( + context.get("focusedValue"), + context.get("startValue"), + computed("visibleDuration"), + prop("locale")!, + prop("min"), + prop("max"), + ) + + setAdjustedValue(params, nextPage) + }, + focusNextSection(params) { + const {computed, context, event, prop} = params + const nextSection = getNextSection( + context.get("focusedValue"), + context.get("startValue"), + (event as DatePickerActionEvent).larger!, + computed("visibleDuration"), + prop("locale")!, + prop("min"), + prop("max"), + ) + + if (!nextSection) { + return + } + setAdjustedValue(params, nextSection) + }, + focusNextWeek(params) { + const {context} = params + const nextValue = context.get("focusedValue").add({weeks: 1}) + setFocusedValue(params, nextValue) + }, + focusNextYear(params) { + const {context} = params + const nextValue = context.get("focusedValue").add({years: 1}) + setFocusedValue(params, nextValue) + }, + focusNextYearColumn(params) { + const {context, event} = params + const nextValue = context + .get("focusedValue") + .add({years: (event as DatePickerActionEvent).columns}) + setFocusedValue(params, nextValue) + }, + focusParsedDate(params) { + const { + event, + prop, + }: {event: DatePickerActionEvent; prop: PropFn} = + params + + if (event.index == null) { + return + } + const parse = prop("parse")! + + const date = parse(event.value as string, { + locale: prop("locale")!, + timeZone: prop("timeZone")!, + }) + if (!date || !isValidDate(date)) { + return + } + + setFocusedValue(params, date) + }, + focusPreviousDay(params) { + const {context} = params + const nextValue = context.get("focusedValue").subtract({days: 1}) + setFocusedValue(params, nextValue) + }, + focusPreviousDecade(params) { + const {context} = params + const nextValue = context.get("focusedValue").subtract({years: 10}) + setFocusedValue(params, nextValue) + }, + focusPreviousMonth(params) { + const {context} = params + setFocusedValue( + params, + context.get("focusedValue").subtract({months: 1}), + ) + }, + focusPreviousMonthColumn(params) { + const {context, event} = params + const nextValue = context + .get("focusedValue") + .subtract({months: (event as DatePickerActionEvent).columns}) + setFocusedValue(params, nextValue) + }, + focusPreviousPage(params) { + const {computed, context, prop} = params + const previousPage = getPreviousPage( + context.get("focusedValue"), + context.get("startValue"), + computed("visibleDuration"), + prop("locale")!, + prop("min"), + prop("max"), + ) + setAdjustedValue(params, previousPage) + }, + focusPreviousSection(params) { + const {computed, context, event, prop} = params + const previousSection = getPreviousSection( + context.get("focusedValue"), + context.get("startValue"), + (event as DatePickerActionEvent).larger!, + computed("visibleDuration"), + prop("locale")!, + prop("min"), + prop("max"), + ) + + if (!previousSection) { + return + } + setAdjustedValue(params, previousSection) + }, + focusPreviousWeek(params) { + const {context} = params + const nextValue = context.get("focusedValue").subtract({weeks: 1}) + setFocusedValue(params, nextValue) + }, + focusPreviousYear(params) { + const {context} = params + const nextValue = context.get("focusedValue").subtract({years: 1}) + setFocusedValue(params, nextValue) + }, + focusPreviousYearColumn(params) { + const {context, event} = params + const nextValue = context + .get("focusedValue") + .subtract({years: (event as DatePickerActionEvent).columns}) + setFocusedValue(params, nextValue) + }, + focusSectionEnd(params) { + const {computed} = params + setFocusedValue(params, computed("endValue").copy()) + }, + focusSectionStart(params) { + const {context} = params + setFocusedValue(params, context.get("startValue").copy()) + }, + focusTriggerElement({scope}) { + raf(() => { + domEls.trigger(scope)?.focus({preventScroll: true}) + }) + }, + invokeOnClose({context, prop}) { + if (prop("inline")) { + return + } + prop("onOpenChange")?.({open: false, value: context.get("value")}) + }, + invokeOnOpen({context, prop}) { + if (prop("inline")) { + return + } + prop("onOpenChange")?.({open: true, value: context.get("value")}) + }, + invokeOnVisibleRangeChange({computed, context, prop}) { + prop("onVisibleRangeChange")?.({ + view: context.get("view"), + visibleRange: computed("visibleRange"), + }) + }, + resetSelection(params) { + const {context, event} = params + const existingValue = context.get("value")[0] + const newValue = normalizeValue( + params, + ((event as DatePickerActionEvent).value as DateValue) ?? + context.get("focusedValue"), + ) + context.set("value", [preserveTime(existingValue, newValue)]) + }, + resetView({context}) { + context.set("view", context.initial("view")!) + }, + restoreSnapshot({context, refs}) { + const snapshot = refs.get("valueSnapshot") + context.set("value", snapshot ? Array.from(snapshot) : []) + }, + resumeRangeSelection({context, prop}) { + if (prop("selectionMode") !== "range") { + return + } + const value = context.get("value") + const startSet = value[0] != null + const endSet = value[1] != null + if (startSet && !endSet) { + context.set("activeIndex", 1) + } else if (!startSet && endSet) { + context.set("activeIndex", 0) + } + }, + selectFocusedDate({computed, context, prop}) { + const stored = context.get("value") + const values = + prop("selectionMode") === "range" + ? [stored[0] ?? null, stored[1] ?? null] + : Array.from(stored) + const activeIndex = context.get("activeIndex") + const existingValue = values[activeIndex] + const newValue = context.get("focusedValue").copy() + values[activeIndex] = preserveTime(existingValue, newValue) + context.set("value", adjustStartAndEndDate(values)) + + // always sync the input value, even if the selecteddate is not changed + // e.g. selected value is 02/28/2024, and the input value changed to 02/28 + const valueAsString = computed("valueAsString") + context.set("inputValue", { + index: activeIndex, + value: valueAsString[activeIndex], + }) + }, + selectParsedDate({context, event, prop}) { + const evt = event as DatePickerActionEvent + if (evt.index == null) { + return + } + + const parse = prop("parse")! + let date = parse(evt.value as string, { + locale: prop("locale")!, + timeZone: prop("timeZone")!, + }) + + // restore the last committed value rather than coercing an unparsable date + if (!date || !isValidDate(date)) { + if (evt.value) { + const committed = getValueAsString(context.get("value"), prop) + context.set("inputValue", { + index: evt.index, + value: committed[evt.index] ?? "", + }) + } + return + } + + // constrain date to min/max range + date = constrainValue(date, prop("min"), prop("max")) + + // reject unavailable dates by reverting the input to the last committed + // value. + if (prop("isDateUnavailable")?.(date, prop("locale")!)) { + const committed = getValueAsString(context.get("value"), prop) + context.set("inputValue", { + index: evt.index, + value: committed[evt.index] ?? "", + }) + return + } + + const stored = context.get("value") + const values = + prop("selectionMode") === "range" + ? [stored[0] ?? null, stored[1] ?? null] + : Array.from(stored) + values[evt.index] = preserveTime(values[evt.index], date) + const adjustedValues = adjustStartAndEndDate(values) + + context.set("value", adjustedValues) + // always sync the input value, even if the selecteddate is not changed + // e.g. selected value is 02/28/2024, and the input value changed to 02/28 + const valueAsString = getValueAsString(adjustedValues, prop) + context.set("inputValue", { + index: evt.index, + value: valueAsString[evt.index], + }) + + if (prop("selectionMode") === "range") { + const startSet = adjustedValues[0] != null + const endSet = adjustedValues[1] != null + if (startSet && !endSet) { + context.set("activeIndex", 1) + } else if (!startSet && endSet) { + context.set("activeIndex", 0) + } + } + }, + setActiveIndex({context, event}) { + context.set("activeIndex", (event as DatePickerActionEvent).index!) + }, + setActiveIndexToEnd({context}) { + context.set("activeIndex", 1) + }, + setActiveIndexToStart({context}) { + context.set("activeIndex", 0) + }, + setDateValue({context, event, prop}) { + if (!Array.isArray((event as DatePickerActionEvent).value)) { + return + } + const value = ( + (event as DatePickerActionEvent).value as (DateValue | null)[] + ).map((date) => + date == null ? date : constrainValue(date, prop("min"), prop("max")), + ) + context.set( + "value", + normalizeValueForMode(value, prop("selectionMode") || "single"), + ) + }, + setFocusedDate(params) { + const {event}: {event: DatePickerActionEvent} = params + const value = Array.isArray(event.value) + ? event.value[0] + : (event.value as DateValue) + setFocusedValue(params, value) + }, + setFocusedValueForView(params) { + const {context, event} = params + setFocusedValue( + params, + context.get("focusedValue").set({ + [context.get("view")]: (event as DatePickerActionEvent).value, + }), + ) + }, + setHoveredDate({context, event}) { + context.set( + "hoveredValue", + (event as DatePickerActionEvent).value as DateValue, + ) + }, + setHoveredValueIfKeyboard({context, event, prop}) { + const isKeyboardNavigation = + event.type.startsWith("TABLE.ARROW") || + [ + "TABLE.END", + "TABLE.ENTER", + "TABLE.HOME", + "TABLE.PAGE_DOWN", + "TABLE.PAGE_UP", + ].includes(event.type) + if ( + !isKeyboardNavigation || + prop("selectionMode") !== "range" || + !canPreviewRangeSelection( + context.get("value"), + context.get("activeIndex"), + ) + ) { + return + } + context.set("hoveredValue", context.get("focusedValue").copy()) + }, + setInputValue({context, event}) { + const evt = event as DatePickerActionEvent + if (evt.index == null) { + return + } + context.set("inputValue", { + index: evt.index, + value: evt.value as string, + }) + }, + setMinView({context, prop}) { + context.set("view", prop("minView")!) + }, + setNextView({context, prop}) { + const nextView = getNextView( + context.get("view"), + prop("minView")!, + prop("maxView")!, + ) + context.set("view", nextView) + }, + setPreviousView({context, prop}) { + const prevView = getPreviousView( + context.get("view"), + prop("minView")!, + prop("maxView")!, + ) + context.set("view", prevView) + }, + + setRestoreFocus({context}) { + context.set("restoreFocus", true) + }, + + setSelectedDate(params) { + const {context, event} = params + const values = Array.from(context.get("value")) + const activeIndex = context.get("activeIndex") + const existingValue = values[activeIndex] + const newValue = normalizeValue( + params, + ((event as DatePickerActionEvent).value as DateValue) ?? + context.get("focusedValue"), + ) + values[activeIndex] = preserveTime(existingValue, newValue) + context.set("value", adjustStartAndEndDate(values)) + }, + + setStartValue({computed, context, prop}) { + const focusedValue = context.get("focusedValue") + + const outside = isDateOutsideRange( + focusedValue, + context.get("startValue"), + computed("endValue"), + ) + if (!outside) { + return + } + + const startValue = alignDate( + focusedValue, + "start", + {months: prop("numOfMonths")}, + prop("locale")!, + ) + context.set("startValue", startValue) + }, + + setView({context, event, prop}) { + context.set( + "view", + clampView( + (event as DatePickerActionEvent).view, + prop("minView"), + prop("maxView"), + ), + ) + }, + + snapshotValue({context, refs}) { + refs.set("valueSnapshot", Array.from(context.get("value"))) + }, + + syncInputElement({computed, refs, scope}) { + refs.get("syncInputElementCleanup")?.() + const cleanup = raf(() => { + const inputEls = getInputEls(scope) + for (const [index, inputEl] of inputEls.entries()) { + setElementValue(inputEl, computed("valueAsString")[index] || "") + } + }) + refs.set("syncInputElementCleanup", cleanup) + }, + + syncInputValue({context, scope}) { + queueMicrotask(() => { + const {index, value} = context.get("inputValue") + setElementValue(getInputEls(scope)[index], value) + }) + }, + + togglePresets({context}) { + context.set("presetsOpen", !context.get("presetsOpen")) + }, + + toggleSelectedDate(params) { + const {context, event} = params + const currentValue = normalizeValue( + params, + ((event as DatePickerActionEvent).value as DateValue) ?? + context.get("focusedValue"), + ) + const index = context + .get("value") + .findIndex((date) => isDateEqual(date, currentValue)) + + if (index === -1) { + const values = [...context.get("value"), currentValue] + context.set("value", sortDates(values)) + } else { + const values = Array.from(context.get("value")) + values.splice(index, 1) + context.set("value", sortDates(values)) + } + }, + + toggleVisibility({event, prop, send}) { + send({ + previousEvent: event, + type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE", + }) + }, + }, + + computed: { + disabled: ({context, prop}) => + Boolean(prop("disabled")) || context.get("fieldsetDisabled"), + endValue: ({computed, context}) => + getEndDate(context.get("startValue"), computed("visibleDuration")), + isInteractive: ({computed, prop}) => + !computed("disabled") && !prop("readOnly"), + isNextVisibleRangeValid: ({computed, prop}) => + !isNextRangeInvalid(computed("endValue"), prop("min"), prop("max")), + isPrevVisibleRangeValid: ({context, prop}) => + !isPreviousRangeInvalid( + context.get("startValue"), + prop("min"), + prop("max"), + ), + valueAsString({context, prop}) { + return getValueAsString(context.get("value"), prop) + }, + visibleDuration: ({prop}) => ({months: prop("numOfMonths")!}), + visibleRange: ({computed, context}) => ({ + end: computed("endValue"), + start: context.get("startValue"), + }), + visibleRangeText: ({computed, context, prop}) => + getVisibleRangeText({ + endValue: computed("endValue"), + locale: prop("locale")!, + startValue: context.get("startValue"), + timeZone: prop("timeZone")!, + view: context.get("view"), + }), + }, + + context({bindable, getContext, prop}) { + return { + activeIndex: bindable(() => ({ + defaultValue: 0, + sync: true, + })), + currentPlacement: bindable(() => ({ + defaultValue: undefined, + })), + fieldsetDisabled: bindable(() => ({defaultValue: false})), + focusedValue: bindable(() => ({ + defaultValue: prop("defaultFocusedValue"), + hash: (v) => v.toString(), + isEqual: isDateEqual, + onChange(focusedValue) { + const context = getContext() + const view = context.get("view") + const value = context.get("value") + const valueAsString = getValueAsString(value, prop) + prop("onFocusChange")?.({focusedValue, value, valueAsString, view}) + }, + sync: true, + value: prop("focusedValue"), + })), + hoveredValue: bindable(() => ({ + defaultValue: null, + isEqual: isDateEqual, + })), + inputValue: bindable(() => ({ + defaultValue: {index: 0, value: ""}, + hash: (v) => `${v.index}:${v.value}`, + })), + presetsOpen: bindable(() => ({ + defaultValue: false, + })), + restoreFocus: bindable(() => ({ + defaultValue: false, + // written and read within one dismiss handler, so it cannot lag a render + syncRead: true, + })), + startValue: bindable(() => { + const focusedValue = + prop("focusedValue") || prop("defaultFocusedValue") + return { + defaultValue: alignDate( + focusedValue!, + "start", + {months: prop("numOfMonths")}, + prop("locale")!, + ), + hash: (v) => v.toString(), + isEqual: isDateEqual, + } + }), + value: bindable(() => ({ + defaultValue: prop("defaultValue"), + hash: (v) => v.map((date) => date?.toString() ?? "").join(","), + isEqual: isDateArrayEqual, + onChange(value) { + const context = getContext() + const valueAsString = getValueAsString(value, prop) + prop("onValueChange")?.({ + value, + valueAsString, + view: context.get("view"), + }) + }, + value: prop("value"), + })), + view: bindable(() => ({ + defaultValue: prop("defaultView"), + onChange(value) { + prop("onViewChange")?.({view: value}) + }, + value: prop("view"), + })), + } + }, + + effects: { + setupLiveRegion({refs, scope}) { + const doc = scope.getDoc() + refs.set( + "announcer", + createLiveRegion({document: doc, level: "polite"}), + ) + return () => refs.get("announcer")?.destroy?.() + }, + + trackDismissableElement({context, prop, scope, send}) { + if (prop("inline")) { + return + } + + const getContentEl = () => domEls.content(scope) + return trackDismissableElement(getContentEl, { + defer: true, + exclude: [ + domEls.control(scope), + ...getInputEls(scope), + domEls.trigger(scope), + domEls.clearTrigger(scope), + ], + onDismiss() { + send({type: "INTERACT_OUTSIDE"}) + }, + onEscapeKeyDown(event) { + event.preventDefault() + send({src: "dismissable", type: "TABLE.ESCAPE"}) + }, + onInteractOutside(event) { + context.set("restoreFocus", !event.detail.focusable) + }, + }) + }, + + trackFormControlState({context, prop, refs, scope}) { + const anchorEl = + getInputEls(scope)[0] ?? + domEls.control(scope) ?? + domEls.content(scope) + return trackFormControl(anchorEl, { + onFieldsetDisabledChange: (disabled) => { + context.set("fieldsetDisabled", disabled) + }, + onFormReset: () => { + const mode = prop("selectionMode") || "single" + const next = normalizeValueForMode( + context.initial("value") ?? [], + mode, + ) + context.set("value", next) + refs.set("valueSnapshot", Array.from(next)) + context.set("activeIndex", getNextActiveIndex(next, mode)) + }, + }) + }, + + trackPositioning({context, prop, scope}) { + if (prop("inline")) { + return + } + + if (!context.get("currentPlacement")) { + context.set("currentPlacement", prop("positioning")!.placement) + } + const anchorEl = domEls.control(scope) + const getPositionerEl = () => domEls.positioner(scope) + return getPlacement(anchorEl, getPositionerEl, { + ...prop("positioning"), + defer: true, + onComplete(data) { + context.set("currentPlacement", data.placement) + }, + }) + }, + + trapFocus({prop, scope}) { + if (prop("inline")) { + return + } + + return trapFocus([domEls.control(scope), domEls.content(scope)], { + initialFocus: false, + returnFocusOnDeactivate: false, + }) + }, + }, + + guards: { + canPreviewRange: ({context, prop}) => + prop("selectionMode") === "range" && + canPreviewRangeSelection( + context.get("value"), + context.get("activeIndex"), + ), + canSelectDate: (params) => { + const {context, event, prop} = params + const maxSelectedDates = prop("maxSelectedDates") + if (maxSelectedDates == null) { + return true + } + const existingValues = context.get("value") + // Normalize month/year cells numeric value to a DateValue + const currentValue = normalizeValue( + params, + ((event as DatePickerActionEvent).value as DateValue | number) ?? + context.get("focusedValue"), + ) + // Allow if deselecting (date already selected) + const isDeselecting = existingValues.some((date) => + isDateEqual(date, currentValue), + ) + if (isDeselecting) { + return true + } + // Block if we've reached the maximum + return existingValues.length < maxSelectedDates + }, + closeOnSelect: ({prop}) => !!prop("closeOnSelect"), + hasSelectedRange: ({context}) => { + const value = context.get("value") + return value.length === 2 && value[0] != null && value[1] != null + }, + isAboveMinView: ({context, prop}) => + isAboveMinView(context.get("view"), prop("minView")!), + isDayPointerMoveOutsideVisibleMonth: ({event}) => + (event as DatePickerActionEvent).cell === "day" && + (event as DatePickerActionEvent).outsideRange === true, + isDayView: ({context, event}) => + ((event as DatePickerActionEvent).view || context.get("view")) === + "day", + isInputValueEmpty: ({event}: {event: DatePickerActionEvent}) => + typeof event.value === "string" && event.value.trim() === "", + isInteractive: ({computed}) => computed("isInteractive"), + isInteractOutsideEvent: ({event}: {event: DatePickerActionEvent}) => + event.previousEvent?.type === "INTERACT_OUTSIDE", + isMonthView: ({context, event}) => + ((event as DatePickerActionEvent).view || context.get("view")) === + "month", + isMultiPicker: ({prop}) => prop("selectionMode") === "multiple", + isOpenControlled: ({prop}) => + prop("open") !== undefined || !!prop("inline"), + isRangePicker: ({prop}) => prop("selectionMode") === "range", + isYearView: ({context, event}) => + ((event as DatePickerActionEvent).view || context.get("view")) === + "year", + selectsToMinView: ({prop}) => prop("viewOnSelect") === "min", + shouldFixOnBlur: ({event}) => + !!(event as DatePickerActionEvent).fixOnBlur, + shouldRestoreFocus: ({context}) => !!context.get("restoreFocus"), + }, + + ids: ({bindableId, bindableIdCollection}) => { + return { + clearTrigger: bindableId(), + content: bindableId(), + control: bindableId(), + errorText: bindableId(), + hint: bindableId(), + input: bindableIdCollection(), + positioner: bindableId(), + trigger: bindableId(), + } + }, + + initialState({prop}) { + const open = prop("inline") || (prop("open") ?? prop("defaultOpen")) + return open ? "open" : "idle" + }, + onDestroy: { + actions: ["cancelSyncInputElement"], + }, + onInit: { + effects: ["setupLiveRegion", "trackFormControlState"], + }, + + props({props}) { + const locale = props.locale || "en-US" + const timeZone = props.timeZone || "UTC" + const selectionMode = props.selectionMode || "single" + const numOfMonths = props.numOfMonths || 1 + + // Resolve calendar from locale when createCalendar is provided + let calendar: Calendar | undefined + if (props.createCalendar) { + const resolved = new Intl.DateTimeFormat(locale).resolvedOptions() + const calendarId = resolved.calendar as CalendarIdentifier + if (calendarId !== "gregory" && calendarId !== "iso8601") { + calendar = props.createCalendar(calendarId) + } + } + + // Helper to convert dates to resolved calendar + const toTargetCalendar = (date: DateValue): DateValue => { + if (!calendar) { + return date + } + if (date.calendar.identifier === calendar.identifier) { + return date + } + return toCalendar(date, calendar) + } + + // sort, constrain, and normalize dates to the selection mode invariants + const defaultValue = props.defaultValue + ? normalizeValueForMode( + sortDates(props.defaultValue).map((date) => + date == null + ? date + : constrainValue(toTargetCalendar(date), props.min, props.max), + ), + selectionMode, + ) + : undefined + const value = props.value + ? normalizeValueForMode( + sortDates(props.value).map((date) => + date == null + ? date + : constrainValue(toTargetCalendar(date), props.min, props.max), + ), + selectionMode, + ) + : undefined + + // get initial focused value + let focusedValue = + props.focusedValue || + props.defaultFocusedValue || + value?.find((date) => date != null) || + defaultValue?.find((date) => date != null) || + getTodayDate(timeZone, calendar) + focusedValue = constrainValue( + toTargetCalendar(focusedValue), + props.min, + props.max, + ) + + // get the initial view + const minView: DatePickerDateView = props.minView || "day" + const maxView: DatePickerDateView = props.maxView || "year" + const defaultView = clampView( + props.defaultView || props.view || minView, + minView, + maxView, + ) + + return { + closeOnSelect: true, + dir: props.dir || "ltr", + format(date, {locale, timeZone}) { + const formatter = new DateFormatter(locale, { + calendar: calendar?.identifier, + day: "2-digit", + month: "2-digit", + timeZone, + year: "numeric", + }) + return formatter.format(date.toDate(timeZone)) + }, + locale, + maxView, + minView, + numOfMonths, + outsideDaySelectable: false, + parse(value, {locale, timeZone}) { + return parseDateString(value, locale, timeZone) + }, + selectionMode, + timeZone, + viewOnSelect: "min", + ...props, + defaultFocusedValue: focusedValue, + defaultValue: defaultValue ?? [], + defaultView, + focusedValue: + typeof props.focusedValue === "undefined" ? undefined : focusedValue, + positioning: { + placement: "bottom-start", + ...props.positioning, + }, + value, + } + }, + + refs() { + return { + announcer: undefined, + syncInputElementCleanup: undefined, + valueSnapshot: undefined, + } + }, + + // group: states + on: { + "FOCUS.SET": { + actions: ["setFocusedDate"], + }, + "GOTO.NEXT": [ + { + actions: ["focusNextDecade", "announceVisibleRange"], + guard: "isYearView", + }, + { + actions: ["focusNextYear", "announceVisibleRange"], + guard: "isMonthView", + }, + { + actions: ["focusNextPage", "announceVisibleRange"], + }, + ], + "GOTO.PREV": [ + { + actions: ["focusPreviousDecade", "announceVisibleRange"], + guard: "isYearView", + }, + { + actions: ["focusPreviousYear", "announceVisibleRange"], + guard: "isMonthView", + }, + { + actions: ["focusPreviousPage", "announceVisibleRange"], + }, + ], + "INPUT.BLUR": [ + { + actions: [ + "setActiveIndexToStart", + "resumeRangeSelection", + "selectParsedDate", + ], + guard: "shouldFixOnBlur", + }, + { + actions: ["setActiveIndexToStart", "resumeRangeSelection"], + }, + ], + "INPUT.CHANGE": [ + { + actions: [ + "cancelSyncInputElement", + "setInputValue", + "clearDateValue", + "clearFocusedDate", + ], + guard: "isInputValueEmpty", + }, + { + actions: [ + "cancelSyncInputElement", + "setInputValue", + "focusParsedDate", + ], + }, + ], + "INPUT.ENTER": { + actions: ["focusParsedDate", "selectParsedDate"], + }, + "INPUT.FOCUS": { + actions: ["setActiveIndex"], + }, + "PRESET.CLICK": [ + { + actions: [ + "setDateValue", + "setFocusedDate", + "closePresets", + "invokeOnClose", + "setRestoreFocus", + ], + guard: and("closeOnSelect", "isOpenControlled"), + }, + { + actions: [ + "setDateValue", + "setFocusedDate", + "closePresets", + "focusInputElement", + ], + guard: "closeOnSelect", + target: "focused", + }, + { + actions: [ + "setDateValue", + "setFocusedDate", + "closePresets", + "focusActiveCell", + ], + }, + ], + "PRESET.TOGGLE": { + actions: ["togglePresets"], + }, + "VALUE.CLEAR": { + actions: [ + "clearDateValue", + "clearFocusedDate", + "setActiveIndexToStart", + "clearHoveredDate", + "focusFirstInputElement", + ], + }, + "VALUE.SET": { + actions: ["setDateValue", "setFocusedDate"], + }, + "VALUE.TOGGLE": { + actions: ["toggleSelectedDate"], + guard: and("isMultiPicker", "isInteractive"), + }, + "VIEW.SET": { + actions: ["setView"], + }, + }, + + states: { + focused: { + on: { + "CONTROLLED.OPEN": { + actions: ["resetView", "focusFirstSelectedDate", "focusActiveCell"], + target: "open", + }, + OPEN: [ + { + actions: ["invokeOnOpen"], + guard: "isOpenControlled", + }, + { + actions: [ + "resetView", + "focusFirstSelectedDate", + "focusActiveCell", + "invokeOnOpen", + ], + target: "open", + }, + ], + "TRIGGER.CLICK": [ + { + actions: ["invokeOnOpen"], + guard: "isOpenControlled", + }, + { + actions: [ + "resetView", + "focusFirstSelectedDate", + "focusActiveCell", + "invokeOnOpen", + ], + target: "open", + }, + ], + }, + tags: ["closed"], + }, + + idle: { + on: { + "CONTROLLED.OPEN": { + actions: ["resetView", "focusFirstSelectedDate", "focusActiveCell"], + target: "open", + }, + OPEN: [ + { + actions: ["invokeOnOpen"], + guard: "isOpenControlled", + }, + { + actions: [ + "resetView", + "focusFirstSelectedDate", + "focusActiveCell", + "invokeOnOpen", + ], + target: "open", + }, + ], + "TRIGGER.CLICK": [ + { + actions: ["invokeOnOpen"], + guard: "isOpenControlled", + }, + { + actions: [ + "resetView", + "focusFirstSelectedDate", + "focusActiveCell", + "invokeOnOpen", + ], + target: "open", + }, + ], + }, + tags: ["closed"], + }, + + open: { + effects: ["trackDismissableElement", "trackPositioning", "trapFocus"], + entry: [ + "snapshotValue", + "resumeRangeSelection", + "closePresets", + "clearRestoreFocus", + ], + exit: ["clearHoveredDate"], + on: { + CANCEL: [ + { + actions: [ + "restoreSnapshot", + "setActiveIndexToStart", + "invokeOnClose", + "setRestoreFocus", + ], + guard: "isOpenControlled", + }, + { + actions: [ + "restoreSnapshot", + "setActiveIndexToStart", + "invokeOnClose", + "focusInputElement", + ], + target: "focused", + }, + ], + "CELL.CLICK": [ + { + actions: ["setFocusedValueForView", "setMinView"], + guard: and("isAboveMinView", "selectsToMinView"), + }, + { + actions: ["setFocusedValueForView", "setPreviousView"], + guard: "isAboveMinView", + }, + { + actions: [ + "setActiveIndexToStart", + "resetSelection", + "setActiveIndexToEnd", + ], + guard: and("isRangePicker", "hasSelectedRange"), + }, + // === Grouped transitions (based on `closeOnSelect` and + // `isOpenControlled`) === + { + actions: [ + "setFocusedDate", + "setSelectedDate", + "setActiveIndexToStart", + "clearHoveredDate", + "invokeOnClose", + "setRestoreFocus", + ], + guard: and( + "isRangePicker", + "canPreviewRange", + "closeOnSelect", + "isOpenControlled", + ), + }, + { + actions: [ + "setFocusedDate", + "setSelectedDate", + "setActiveIndexToStart", + "clearHoveredDate", + "invokeOnClose", + "focusInputElement", + ], + guard: and("isRangePicker", "canPreviewRange", "closeOnSelect"), + target: "focused", + }, + { + actions: [ + "setFocusedDate", + "setSelectedDate", + "setActiveIndexToStart", + "clearHoveredDate", + ], + guard: and("isRangePicker", "canPreviewRange"), + }, + // === + { + actions: [ + "setFocusedDate", + "setSelectedDate", + "setActiveIndexToEnd", + ], + guard: "isRangePicker", + }, + { + actions: ["setFocusedDate", "toggleSelectedDate"], + guard: and("isMultiPicker", "canSelectDate"), + }, + { + actions: ["setFocusedDate"], + guard: "isMultiPicker", + }, + // === Grouped transitions (based on `closeOnSelect` and + // `isOpenControlled`) === + { + actions: [ + "setFocusedDate", + "setSelectedDate", + "invokeOnClose", + "setRestoreFocus", + ], + guard: and("closeOnSelect", "isOpenControlled"), + }, + { + actions: [ + "setFocusedDate", + "setSelectedDate", + "invokeOnClose", + "focusInputElement", + ], + guard: "closeOnSelect", + target: "focused", + }, + { + actions: ["setFocusedDate", "setSelectedDate"], + }, + // === + ], + "CELL.POINTER_MOVE": [ + { + actions: ["setHoveredDate"], + guard: and( + "canPreviewRange", + "isDayPointerMoveOutsideVisibleMonth", + ), + }, + { + actions: ["setHoveredDate", "setFocusedDate"], + guard: "canPreviewRange", + }, + ], + CLOSE: [ + { + actions: [ + "setActiveIndexToStart", + "invokeOnClose", + "setRestoreFocus", + ], + guard: "isOpenControlled", + }, + { + actions: [ + "setActiveIndexToStart", + "invokeOnClose", + "focusInputElement", + ], + target: "focused", + }, + ], + "CONTROLLED.CLOSE": [ + { + actions: ["focusTriggerElement"], + guard: and("shouldRestoreFocus", "isInteractOutsideEvent"), + target: "focused", + }, + { + actions: ["focusInputElement"], + guard: "shouldRestoreFocus", + target: "focused", + }, + { + target: "idle", + }, + ], + INTERACT_OUTSIDE: [ + // acts like Cancel if closeOnSelect === false + { + actions: [ + "restoreSnapshot", + "setActiveIndexToStart", + "invokeOnClose", + ], + guard: and(not("closeOnSelect"), "isOpenControlled"), + }, + { + actions: [ + "restoreSnapshot", + "setActiveIndexToStart", + "invokeOnClose", + "focusTriggerElement", + ], + guard: and(not("closeOnSelect"), "shouldRestoreFocus"), + target: "focused", + }, + { + actions: [ + "restoreSnapshot", + "setActiveIndexToStart", + "invokeOnClose", + ], + guard: not("closeOnSelect"), + target: "idle", + }, + { + actions: ["setActiveIndexToStart", "invokeOnClose"], + guard: "isOpenControlled", + }, + { + actions: [ + "setActiveIndexToStart", + "invokeOnClose", + "focusTriggerElement", + ], + guard: "shouldRestoreFocus", + target: "focused", + }, + { + actions: ["setActiveIndexToStart", "invokeOnClose"], + target: "idle", + }, + ], + "TABLE.ARROW_DOWN": [ + { + actions: ["focusNextMonthColumn"], + guard: "isMonthView", + }, + { + actions: ["focusNextYearColumn"], + guard: "isYearView", + }, + { + actions: ["focusNextWeek"], + }, + ], + "TABLE.ARROW_LEFT": [ + { + actions: ["focusPreviousMonth"], + guard: "isMonthView", + }, + { + actions: ["focusPreviousYear"], + guard: "isYearView", + }, + { + actions: ["focusPreviousDay"], + }, + ], + "TABLE.ARROW_RIGHT": [ + { + actions: ["focusNextMonth"], + guard: "isMonthView", + }, + { + actions: ["focusNextYear"], + guard: "isYearView", + }, + { + actions: ["focusNextDay"], + }, + ], + "TABLE.ARROW_UP": [ + { + actions: ["focusPreviousMonthColumn"], + guard: "isMonthView", + }, + { + actions: ["focusPreviousYearColumn"], + guard: "isYearView", + }, + { + actions: ["focusPreviousWeek"], + }, + ], + "TABLE.END": [ + { + actions: ["focusLastMonth"], + guard: "isMonthView", + }, + { + actions: ["focusLastYear"], + guard: "isYearView", + }, + { + actions: ["focusSectionEnd"], + }, + ], + "TABLE.ENTER": [ + { + actions: ["setMinView"], + guard: and("isAboveMinView", "selectsToMinView"), + }, + { + actions: ["setPreviousView"], + guard: "isAboveMinView", + }, + { + actions: [ + "setActiveIndexToStart", + "resetSelection", + "setActiveIndexToEnd", + "focusNextDay", + ], + guard: and("isRangePicker", "hasSelectedRange"), + }, + // === Grouped transitions (based on `closeOnSelect` and + // `isOpenControlled`) === + { + actions: [ + "setSelectedDate", + "setActiveIndexToStart", + "clearHoveredDate", + "invokeOnClose", + "setRestoreFocus", + ], + guard: and( + "isRangePicker", + "canPreviewRange", + "closeOnSelect", + "isOpenControlled", + ), + }, + { + actions: [ + "setSelectedDate", + "setActiveIndexToStart", + "clearHoveredDate", + "invokeOnClose", + "focusInputElement", + ], + guard: and("isRangePicker", "canPreviewRange", "closeOnSelect"), + target: "focused", + }, + { + actions: [ + "setSelectedDate", + "setActiveIndexToStart", + "clearHoveredDate", + ], + guard: and("isRangePicker", "canPreviewRange"), + }, + // === + { + actions: [ + "setSelectedDate", + "setActiveIndexToEnd", + "focusNextDay", + ], + guard: "isRangePicker", + }, + { + actions: ["toggleSelectedDate"], + guard: and("isMultiPicker", "canSelectDate"), + }, + { + guard: "isMultiPicker", + }, + // === Grouped transitions (based on `closeOnSelect` and + // `isOpenControlled`) === + { + actions: [ + "selectFocusedDate", + "invokeOnClose", + "setRestoreFocus", + ], + guard: and("closeOnSelect", "isOpenControlled"), + }, + { + actions: [ + "selectFocusedDate", + "invokeOnClose", + "focusInputElement", + ], + guard: "closeOnSelect", + target: "focused", + }, + { + actions: ["selectFocusedDate"], + }, + // === + ], + "TABLE.ESCAPE": [ + { + actions: [ + "restoreSnapshot", + "setActiveIndexToStart", + "focusFirstSelectedDate", + "invokeOnClose", + "setRestoreFocus", + ], + guard: "isOpenControlled", + }, + { + actions: [ + "restoreSnapshot", + "setActiveIndexToStart", + "focusFirstSelectedDate", + "invokeOnClose", + "focusTriggerElement", + ], + target: "focused", + }, + ], + "TABLE.HOME": [ + { + actions: ["focusFirstMonth"], + guard: "isMonthView", + }, + { + actions: ["focusFirstYear"], + guard: "isYearView", + }, + { + actions: ["focusSectionStart"], + }, + ], + "TABLE.PAGE_DOWN": { + actions: ["focusNextSection"], + }, + "TABLE.PAGE_UP": { + actions: ["focusPreviousSection"], + }, + "TABLE.POINTER_DOWN": { + actions: ["disableTextSelection"], + }, + "TABLE.POINTER_LEAVE": { + actions: ["clearHoveredDate"], + guard: "isRangePicker", + }, + "TABLE.POINTER_UP": { + actions: ["enableTextSelection"], + }, + "TRIGGER.CLICK": [ + { + actions: ["invokeOnClose"], + guard: "isOpenControlled", + }, + { + actions: ["invokeOnClose"], + target: "focused", + }, + ], + "VIEW.TOGGLE": { + actions: ["setNextView"], + }, + }, + tags: ["open"], + }, + }, + + watch({action, computed, context, prop, track}) { + track([() => prop("locale")], () => { + action(["setStartValue", "syncInputElement"]) + }) + + track([() => context.hash("focusedValue")], () => { + action([ + "setStartValue", + "focusActiveCellIfNeeded", + "setHoveredValueIfKeyboard", + ]) + }) + + track([() => context.hash("startValue")], () => { + action(["invokeOnVisibleRangeChange"]) + }) + + track([() => context.hash("inputValue")], () => { + action(["syncInputValue"]) + }) + + track([() => context.hash("value")], () => { + action(["syncInputElement"]) + }) + + track([() => computed("valueAsString").toString()], () => { + action(["announceValueText"]) + }) + + track([() => context.get("view")], () => { + action(["focusActiveCell"]) + }) + + track([() => prop("open")], () => { + action(["toggleVisibility"]) + }) + + track([() => prop("selectionMode")], () => { + action(["adjustValueToSelectionMode"]) + }) + }, + }) + +const normalizeValue = ( + ctx: Params, + value: number | DateValue, +) => { + const {context, prop} = ctx + const view = context.get("view") + let dateValue = + typeof value === "number" + ? context.get("focusedValue").set({[view]: value}) + : value + eachView((view) => { + // normalize month and day + if (isBelowMinView(view, prop("minView")!)) { + dateValue = dateValue.set({[view]: view === "day" ? 1 : 0}) + } + }) + return dateValue +} + +/** + * Preserves time components from an existing date when setting a new date. + * - If existing date is a ZonedDateTime, preserves both time and timezone + * - If existing date is a CalendarDateTime, preserves time only + * - If existing date has no time, returns the new date as-is + */ +const preserveTime = ( + existingDate: DateValue | null | undefined, + newDate: DateValue, +): DateValue => { + if (!existingDate || !("hour" in existingDate)) { + return newDate + } + + // Check if existing date is a ZonedDateTime (has timezone) + const isZoned = "timeZone" in existingDate + + // Convert CalendarDate to appropriate type if needed + let dateWithTime: DateValue = newDate + if (!("hour" in newDate)) { + if (isZoned) { + // Convert to ZonedDateTime with same timezone as existing + dateWithTime = toZoned(toCalendarDateTime(newDate), existingDate.timeZone) + } else { + // Convert to CalendarDateTime + dateWithTime = toCalendarDateTime(newDate) + } + } + + // Copy time components from existing date to new date + return dateWithTime.set({ + hour: existingDate.hour, + millisecond: existingDate.millisecond, + minute: existingDate.minute, + second: existingDate.second, + }) +} + +function setFocusedValue( + ctx: Params, + mixedValue: DateValue | number | null | undefined, +) { + const {computed, context, prop} = ctx + if (!mixedValue) { + return + } + + const value = normalizeValue(ctx, mixedValue) + if (isDateEqual(context.get("focusedValue"), value)) { + return + } + + const adjustFn = getAdjustedDateFn( + computed("visibleDuration"), + prop("locale")!, + prop("min"), + prop("max"), + ) + const adjustedValue = adjustFn({ + focusedDate: value, + startDate: context.get("startValue"), + }) + + context.set("startValue", adjustedValue.startDate) + context.set("focusedValue", adjustedValue.focusedDate) +} + +function setAdjustedValue( + ctx: Params, + value: AdjustDateReturn, +) { + const {context} = ctx + context.set("startValue", value.startDate) + const focusedValue = context.get("focusedValue") + if (isDateEqual(focusedValue, value.focusedDate)) { + return + } + context.set("focusedValue", value.focusedDate) +} diff --git a/packages/common/core/src/date-picker/date-picker.parse.ts b/packages/common/core/src/date-picker/date-picker.parse.ts new file mode 100644 index 000000000..3506220a1 --- /dev/null +++ b/packages/common/core/src/date-picker/date-picker.parse.ts @@ -0,0 +1,27 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {CalendarDate, type DateValue, parseDate} from "@internationalized/date" + +type DateInput = string | Date + +export function parse(value: DateInput): DateValue +export function parse(value: Array): DateValue[] +export function parse( + value: DateInput | Array, +): DateValue | DateValue[] { + if (Array.isArray(value)) { + return value.map((v) => parse(v)) + } + if (value instanceof Date) { + return new CalendarDate( + value.getFullYear(), + value.getMonth() + 1, + value.getDate(), + ) + } + return parseDate(value) +} diff --git a/packages/common/core/src/date-picker/date-picker.props.ts b/packages/common/core/src/date-picker/date-picker.props.ts new file mode 100644 index 000000000..511b57511 --- /dev/null +++ b/packages/common/core/src/date-picker/date-picker.props.ts @@ -0,0 +1,125 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {createProps, createSplitProps} from "@qualcomm-ui/utils/object" + +import type { + DatePickerApiProps, + DatePickerApiDayTableCellProps, + DatePickerApiInputProps, + DatePickerApiLabelProps, + DatePickerApiPresetTriggerProps, + DatePickerApiTableCellProps, + DatePickerApiTableProps, + DatePickerApiViewProps, +} from "./date-picker.types.js" + +const datePickerProps: (keyof DatePickerApiProps)[] = + createProps()( + "closeOnSelect", + "createCalendar", + "dir", + "disabled", + "fixedWeeks", + "focusedValue", + "format", + "parse", + "placeholder", + "inline", + "invalid", + "isDateUnavailable", + "locale", + "max", + "maxSelectedDates", + "min", + "name", + "numOfMonths", + "onFocusChange", + "onOpenChange", + "onValueChange", + "onViewChange", + "onVisibleRangeChange", + "open", + "openOnClick", + "defaultOpen", + "positioning", + "readOnly", + "required", + "selectionMode", + "startOfWeek", + "timeZone", + "translations", + "value", + "defaultView", + "defaultValue", + "view", + "defaultFocusedValue", + "outsideDaySelectable", + "viewOnSelect", + "minView", + "maxView", + ) +export const splitDatePickerProps: ( + props: Props, +) => [DatePickerApiProps, Omit] = + createSplitProps(datePickerProps) + +const datePickerViewProps: (keyof DatePickerApiViewProps)[] = + createProps()("view") +export const splitDatePickerViewProps: ( + props: Props, +) => [DatePickerApiViewProps, Omit] = + createSplitProps(datePickerViewProps) + +const datePickerTableProps: (keyof DatePickerApiTableProps)[] = + createProps()("columns", "view") +export const splitDatePickerTableProps: ( + props: Props, +) => [DatePickerApiTableProps, Omit] = + createSplitProps(datePickerTableProps) + +type DatePickerTableCellProps = DatePickerApiDayTableCellProps & + DatePickerApiTableCellProps +const datePickerTableCellProps: (keyof DatePickerTableCellProps)[] = + createProps()( + "visibleRange", + "columns", + "disabled", + "value", + ) +export const splitDatePickerTableCellProps: < + Props extends DatePickerTableCellProps, +>( + props: Props, +) => [DatePickerTableCellProps, Omit] = + createSplitProps(datePickerTableCellProps) + +const datePickerInputProps: (keyof DatePickerApiInputProps)[] = + createProps()("fixOnBlur", "index") +export const splitDatePickerInputProps: ( + props: Props, +) => [DatePickerApiInputProps, Omit] = + createSplitProps(datePickerInputProps) + +const datePickerLabelProps: (keyof DatePickerApiLabelProps)[] = + createProps()("index") +export const splitDatePickerLabelProps: ( + props: Props, +) => [DatePickerApiLabelProps, Omit] = + createSplitProps(datePickerLabelProps) + +const datePickerPresetTriggerProps: (keyof DatePickerApiPresetTriggerProps)[] = + createProps()("value") +export const splitDatePickerPresetTriggerProps: < + Props extends DatePickerApiPresetTriggerProps, +>( + props: Props, +) => [ + DatePickerApiPresetTriggerProps, + Omit, +] = createSplitProps( + datePickerPresetTriggerProps, +) diff --git a/packages/common/core/src/date-picker/date-picker.types.ts b/packages/common/core/src/date-picker/date-picker.types.ts new file mode 100644 index 000000000..a661c9c9e --- /dev/null +++ b/packages/common/core/src/date-picker/date-picker.types.ts @@ -0,0 +1,1567 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type { + Calendar, + CalendarIdentifier, + DateDuration, + DateValue, +} from "@internationalized/date" + +import type { + Placement, + PlacementSide, + PositioningOptions, +} from "@qualcomm-ui/dom/floating-ui" +import type {LiveRegion} from "@qualcomm-ui/dom/live-region" +import type {AnatomyPart, AnatomyPartName} from "@qualcomm-ui/utils/anatomy" +import type { + BooleanAriaAttr, + BooleanDataAttr, +} from "@qualcomm-ui/utils/attributes" +import type {DateRangePreset} from "@qualcomm-ui/utils/date-utils" +import type {DirectionProperty} from "@qualcomm-ui/utils/direction" +import type {RequiredBy} from "@qualcomm-ui/utils/guard" +import type { + ActionSchema, + EffectSchema, + GuardSchema, + IdRegistrationProps, + JSX, + MachineSchema, + ScopeWithIds, +} from "@qualcomm-ui/utils/machine" + +import type {datePickerAnatomy} from "./date-picker.anatomy.js" + +/** callback details */ + +export type DatePickerDateView = "day" | "month" | "year" + +export type DatePickerViewOnSelect = "previous" | "min" + +export interface DatePickerValueChangeDetails { + value: (DateValue | null)[] + valueAsString: string[] + view: DatePickerDateView +} + +export interface DatePickerFocusChangeDetails extends DatePickerValueChangeDetails { + focusedValue: DateValue + view: DatePickerDateView +} + +export interface DatePickerLocaleDetails { + locale: string + timeZone: string +} + +export interface DatePickerViewChangeDetails { + view: DatePickerDateView +} + +export interface DatePickerVisibleRangeChangeDetails { + view: DatePickerDateView + visibleRange: {end: DateValue; start: DateValue} +} + +export interface DatePickerOpenChangeDetails { + open: boolean + value: (DateValue | null)[] +} + +export interface DatePickerTime { + hour?: number + millisecond?: number + minute?: number + second?: number +} + +/** machine context */ + +export type DatePickerSelectionMode = "single" | "multiple" | "range" + +export interface DatePickerTriggerState { + open: boolean + selectionMode: DatePickerSelectionMode + /** Formatted for speech, by position; holes are `undefined`. */ + valueText: (string | undefined)[] +} + +export interface DatePickerIntlTranslations { + clearTrigger?: string | undefined + content?: string | undefined + dayCell?: ((state: DatePickerDayTableCellState) => string) | undefined + errorIndicator?: string | undefined + inputDescription?: ((format: string) => string) | undefined + nextTrigger?: ((view: DatePickerDateView) => string) | undefined + placeholder?: + | ((locale: string) => {day: string; month: string; year: string}) + | undefined + presetsTrigger?: ((open: boolean) => string) | undefined + presetTrigger?: ((value: string[]) => string) | undefined + prevTrigger?: ((view: DatePickerDateView) => string) | undefined + rangeInputEnd?: string | undefined + rangeInputStart?: string | undefined + trigger?: ((state: DatePickerTriggerState) => string) | undefined + viewCloseTrigger?: string | undefined + viewTrigger?: + | ((view: DatePickerDateView, targetView?: DatePickerDateView) => string) + | undefined +} + +export interface DatePickerApiProps extends DirectionProperty { + /** + * Whether the calendar should close after the date selection is complete. + * This is ignored when the selection mode is `multiple`. + * @default true + */ + closeOnSelect?: boolean | undefined + /** + * Pass this to support non-Gregorian calendars (Persian, Buddhist, Islamic, + * etc.), which keeps every calendar out of the bundle unless you opt in. The + * picker calls it with the calendar identifier resolved from `locale`. + * + * @example + * ```ts + * import {createCalendar} from "@internationalized/date" + * + * const props = {createCalendar, locale: "fa-IR"} + * ``` + */ + createCalendar?: ((identifier: CalendarIdentifier) => Calendar) | undefined + /** + * The initial focused date when rendered. + * Use when you don't need to control the focused date of the date picker. + */ + defaultFocusedValue?: DateValue | undefined + /** + * The initial open state of the date picker when rendered. + * Use when you don't need to control the open state of the date picker. + */ + defaultOpen?: boolean | undefined + /** + * The initial selected date(s) when rendered. + * Use when you don't need to control the selected date(s) of the date picker. + */ + defaultValue?: (DateValue | null)[] | undefined + /** + * The default view of the calendar + * @default "day" + */ + defaultView?: DatePickerDateView | undefined + /** + * Whether the calendar is disabled. + */ + disabled?: boolean | undefined + /** + * Whether the calendar should have a fixed number of weeks. + * This renders the calendar with 6 weeks instead of 5 or 6. + */ + fixedWeeks?: boolean | undefined + /** + * The controlled focused date. + */ + focusedValue?: DateValue | undefined + /** + * The format of the date to display in the input. + */ + format?: + | ((date: DateValue, details: DatePickerLocaleDetails) => string) + | undefined + /** + * Whether to render the date picker inline + */ + inline?: boolean | undefined + /** + * Whether the date picker is invalid + */ + invalid?: boolean | undefined + /** + * Returns whether a date of the calendar is available. + */ + isDateUnavailable?: ((date: DateValue, locale: string) => boolean) | undefined + /** + * The locale (BCP 47 language tag) to use when formatting the date. + * @default "en-US" + */ + locale?: string | undefined + /** + * The maximum date that can be selected. + */ + max?: DateValue | undefined + /** + * The maximum number of dates that can be selected. + * This is only applicable when `selectionMode` is `multiple`. + */ + maxSelectedDates?: number | undefined + /** + * The maximum view of the calendar + * @default "year" + */ + maxView?: DatePickerDateView | undefined + /** + * The minimum date that can be selected. + */ + min?: DateValue | undefined + /** + * The minimum view of the calendar + * @default "day" + */ + minView?: DatePickerDateView | undefined + /** + * The `name` attribute of the input element. + */ + name?: string | undefined + /** + * The number of months to display. + */ + numOfMonths?: number | undefined + /** + * Function called when the focused date changes. + */ + onFocusChange?: ((details: DatePickerFocusChangeDetails) => void) | undefined + /** + * Function called when the calendar opens or closes. + */ + onOpenChange?: ((details: DatePickerOpenChangeDetails) => void) | undefined + /** + * Function called when the value changes. + */ + onValueChange?: ((details: DatePickerValueChangeDetails) => void) | undefined + /** + * Function called when the view changes. + */ + onViewChange?: ((details: DatePickerViewChangeDetails) => void) | undefined + /** + * Function called when the visible range changes. + */ + onVisibleRangeChange?: + | ((details: DatePickerVisibleRangeChangeDetails) => void) + | undefined + /** + * The controlled open state of the date picker + */ + open?: boolean | undefined + /** + * Whether to open the calendar when the input is clicked. + * @default false + */ + openOnClick?: boolean | undefined + /** + * Whether day outside the visible range can be selected. + * @default false + */ + outsideDaySelectable?: boolean | undefined + /** + * Function to parse the date from the input back to a DateValue. + */ + parse?: + | (( + value: string, + details: DatePickerLocaleDetails, + ) => DateValue | undefined) + | undefined + /** + * The placeholder text to display in the input. + */ + placeholder?: string | undefined + /** + * The user provided options used to position the date picker content + * + * @inheritDoc + */ + positioning?: DatePickerPositioningOptions | undefined + /** + * Whether the calendar is read-only. + */ + readOnly?: boolean | undefined + /** + * Whether the date picker is required + */ + required?: boolean | undefined + /** + * The selection mode of the calendar. + * - `single` - only one date can be selected + * - `multiple` - multiple dates can be selected + * - `range` - a range of dates can be selected + * + * @default "single" + */ + selectionMode?: DatePickerSelectionMode | undefined + /** + * The first day of the week. + * `0` - Sunday + * `1` - Monday + * `2` - Tuesday + * `3` - Wednesday + * `4` - Thursday + * `5` - Friday + * `6` - Saturday + */ + startOfWeek?: number | undefined + /** + * The time zone to use + * @default "UTC" + */ + timeZone?: string | undefined + /** + * The localized messages to use. + */ + translations?: DatePickerIntlTranslations | undefined + /** + * The controlled selected date(s). + */ + value?: (DateValue | null)[] | undefined + /** + * The view of the calendar + */ + view?: DatePickerDateView | undefined + /** + * The view to show after a selection. + * 'previous'` = year → month → day + * `'min'` = → + * @default 'min' + */ + viewOnSelect?: DatePickerViewOnSelect | undefined +} + +export interface DatePickerPositioningOptions extends PositioningOptions { + /** + * The initial placement of the floating element + * @default 'bottom-start' + */ + placement?: Placement | undefined +} + +interface InputValue { + index: number + value: string +} + +interface PrivateContext { + /** + * The index of the currently active date. + * Used in range selection mode. + */ + activeIndex: number + /** + * The computed placement (maybe different from initial placement) + */ + currentPlacement?: Placement | undefined + /** + * Whether an ancestor `fieldset` is disabled. + */ + fieldsetDisabled: boolean + /** + * The focused date. + */ + focusedValue: DateValue + /** + * Whether the calendar has focus + */ + hasFocus?: boolean | undefined + /** + * The current hovered date. Useful for range selection mode. + */ + hoveredValue: DateValue | null + /** + * The pending text of the input the user last typed in, paired with that + * input's index. + */ + inputValue: InputValue + /** + * Whether the presets panel is shown in place of the calendar. + */ + presetsOpen: boolean + /** + * Whether the calendar should restore focus to the input when it closes. + */ + restoreFocus?: boolean | undefined + /** + * The start date of the current visible duration. + */ + startValue: DateValue + /** + * The selected date(s). + */ + value: (DateValue | null)[] + /** + * The view of the calendar. + */ + view: DatePickerDateView +} + +type ComputedContext = Readonly<{ + /** + * Whether the calendar is disabled. + */ + disabled: boolean + /** + * The end date of the current visible duration. + */ + endValue: DateValue + /** + * Whether the calendar is interactive. + */ + isInteractive: boolean + /** + * Whether the next visible range is valid. + */ + isNextVisibleRangeValid: boolean + /** + * Whether the previous visible range is valid. + */ + isPrevVisibleRangeValid: boolean + /** + * The value text to display in the input. + */ + valueAsString: string[] + /** + * The duration of the visible range. + */ + visibleDuration: DateDuration + /** + * The start/end date of the current visible duration. + */ + visibleRange: {end: DateValue; start: DateValue} + /** + * The text to announce when the visible range changes. + */ + visibleRangeText: {end: string; formatted: string; start: string} +}> + +/** component API */ + +export interface DatePickerDayTableCellState extends DatePickerTableCellState { + invalid: boolean + today: boolean + unavailable: boolean + weekend: boolean +} + +export interface DatePickerElementIds { + clearTrigger: string + content: string + control: string + errorText: string + hint: string + input: string[] + positioner: string + trigger: string +} + +export interface DatePickerScope extends ScopeWithIds {} + +type Actions = ActionSchema< + | "cancelSyncInputElement" + | "closePresets" + | "togglePresets" + | "adjustValueToSelectionMode" + | "setNextView" + | "setPreviousView" + | "setMinView" + | "setView" + | "setRestoreFocus" + | "announceValueText" + | "announceVisibleRange" + | "disableTextSelection" + | "enableTextSelection" + | "focusFirstSelectedDate" + | "syncInputElement" + | "setFocusedDate" + | "setFocusedValueForView" + | "focusNextMonth" + | "focusPreviousMonth" + | "setDateValue" + | "clearDateValue" + | "setSelectedDate" + | "resetSelection" + | "toggleSelectedDate" + | "setHoveredDate" + | "clearHoveredDate" + | "clearRestoreFocus" + | "selectFocusedDate" + | "focusPreviousDay" + | "focusNextDay" + | "focusPreviousWeek" + | "focusNextWeek" + | "focusNextPage" + | "focusPreviousPage" + | "focusSectionStart" + | "focusSectionEnd" + | "focusNextSection" + | "focusPreviousSection" + | "focusNextYear" + | "focusPreviousYear" + | "focusNextDecade" + | "focusPreviousDecade" + | "clearFocusedDate" + | "focusPreviousMonthColumn" + | "focusNextMonthColumn" + | "focusPreviousYearColumn" + | "focusNextYearColumn" + | "focusFirstMonth" + | "focusLastMonth" + | "focusFirstYear" + | "focusLastYear" + | "setActiveIndex" + | "setActiveIndexToEnd" + | "setActiveIndexToStart" + | "focusActiveCell" + | "focusActiveCellIfNeeded" + | "setHoveredValueIfKeyboard" + | "focusTriggerElement" + | "focusFirstInputElement" + | "focusInputElement" + | "setInputValue" + | "syncInputValue" + | "focusParsedDate" + | "selectParsedDate" + | "resetView" + | "setStartValue" + | "invokeOnOpen" + | "invokeOnClose" + | "invokeOnVisibleRangeChange" + | "toggleVisibility" + | "snapshotValue" + | "restoreSnapshot" + | "resumeRangeSelection" +> + +type Events = + | { + type: "FOCUS.SET" + value: DateValue + } + | { + type: "VALUE.SET" + value: DateValue | (DateValue | null)[] + } + | { + focus?: boolean + type: "VALUE.CLEAR" + } + | { + type: "VALUE.TOGGLE" + value: DateValue + } + | { + type: "TRIGGER.CLICK" + } + | { + src?: string + type: "OPEN" + } + | { + type: "CLOSE" + } + | { + type: "CANCEL" + } + | { + previousEvent: Events + type: "CONTROLLED.OPEN" + } + | { + previousEvent: Events + type: "CONTROLLED.CLOSE" + } + | { + type: "INTERACT_OUTSIDE" + } + | { + type: "VIEW.SET" + view: DatePickerDateView + } + | { + type: "GOTO.NEXT" + view: DatePickerDateView + } + | { + type: "GOTO.PREV" + view: DatePickerDateView + } + | { + src: string | undefined + type: "VIEW.TOGGLE" + } + | { + type: "PRESET.TOGGLE" + } + | { + index: number + type: "INPUT.FOCUS" + } + | { + fixOnBlur: boolean + index: number + type: "INPUT.BLUR" + value: string + } + | { + index: number + type: "INPUT.ENTER" + value: string + } + | { + index: number + type: "INPUT.CHANGE" + value: string + } + | { + type: "PRESET.CLICK" + value: DateValue[] + } + | { + src: string + type: "TABLE.ESCAPE" + } + | { + columns: number + focus: boolean + type: "TABLE.HOME" + view: DatePickerDateView + } + | { + columns: number + focus: boolean + type: "TABLE.END" + view: DatePickerDateView + } + | { + columns: number + focus: boolean + type: "TABLE.ENTER" + view: DatePickerDateView + } + | { + columns: number + focus: boolean + type: "TABLE.ARROW_LEFT" + view: DatePickerDateView + } + | { + columns: number + focus: boolean + type: "TABLE.ARROW_RIGHT" + view: DatePickerDateView + } + | { + columns: number + focus: boolean + type: "TABLE.ARROW_UP" + view: DatePickerDateView + } + | { + columns: number + focus: boolean + type: "TABLE.ARROW_DOWN" + view: DatePickerDateView + } + | { + columns: number + focus: boolean + larger: boolean + type: "TABLE.PAGE_UP" + view: DatePickerDateView + } + | { + columns: number + focus: boolean + larger: boolean + type: "TABLE.PAGE_DOWN" + view: DatePickerDateView + } + | { + columns: number + focus: boolean + type: "HOME" + view: DatePickerDateView + } + | { + columns: number + focus: boolean + type: "END" + view: DatePickerDateView + } + | { + type: "TABLE.POINTER_LEAVE" + } + | { + type: "TABLE.POINTER_DOWN" + view: DatePickerDateView + } + | { + type: "TABLE.POINTER_UP" + view: DatePickerDateView + } + | { + cell: Exclude + type: "CELL.CLICK" + value: number + } + | { + cell: Extract + type: "CELL.CLICK" + value: DateValue + } + | { + cell: DatePickerDateView + focus: boolean + outsideRange?: boolean + type: "CELL.POINTER_MOVE" + value: DateValue + } + +type UnionKeys = T extends T ? keyof T : never +type UnionPropType = T extends {[P in K]?: unknown} + ? T[K] + : never +export type DatePickerActionEvent = { + [K in UnionKeys]?: UnionPropType +} + +export interface DatePickerSchema extends MachineSchema { + actions: Actions + computed: ComputedContext + context: PrivateContext + effects: EffectSchema< + | "setupLiveRegion" + | "trackDismissableElement" + | "trackFormControlState" + | "trackPositioning" + | "trapFocus" + > + events: Events + guards: GuardSchema< + | "canPreviewRange" + | "canSelectDate" + | "closeOnSelect" + | "hasSelectedRange" + | "isAboveMinView" + | "isDayPointerMoveOutsideVisibleMonth" + | "isDayView" + | "isInputValueEmpty" + | "isInteractOutsideEvent" + | "isInteractive" + | "isMonthView" + | "isMultiPicker" + | "isOpenControlled" + | "isRangePicker" + | "isYearView" + | "selectsToMinView" + | "shouldFixOnBlur" + | "shouldRestoreFocus" + > + ids: DatePickerElementIds + props: RequiredBy + refs: { + announcer?: LiveRegion | undefined + syncInputElementCleanup?: VoidFunction | undefined + valueSnapshot?: (DateValue | null)[] | undefined + } +} + +export interface DatePickerRange { + end: T + start: T +} + +export type DatePickerVisibleRange = DatePickerRange + +export interface DatePickerVisibleRangeText extends DatePickerRange { + formatted: string +} + +export interface DatePickerWeekDay { + long: string + narrow: string + short: string + value: DateValue +} + +export interface DatePickerMonthFormatOptions { + format?: "short" | "long" | undefined +} + +export interface DatePickerDateValueOffset { + visibleRange: DatePickerVisibleRange + visibleRangeText: {end: string; start: string} + weeks: DateValue[][] +} + +export interface DatePickerCell { + disabled?: boolean | undefined + label: string + value: number +} + +export interface DatePickerApiDayTableCellProps { + disabled?: boolean | undefined + value: DateValue + visibleRange?: DatePickerVisibleRange | undefined +} + +export interface DatePickerApiTableCellProps { + columns?: number | undefined + disabled?: boolean | undefined + value: number +} + +export interface DatePickerTableCellState { + readonly disabled: boolean + firstInHoveredRange: boolean + firstInRange: boolean + focused: boolean + inHoveredRange: boolean + inRange: boolean + lastInHoveredRange: boolean + lastInRange: boolean + outsideRange: boolean + selectable: boolean + selected: boolean + value: DateValue + valueText: string +} + +export interface DatePickerApiTableProps { + columns?: number | undefined + view?: DatePickerDateView | undefined +} + +export interface DatePickerApiMonthGridProps { + columns?: number | undefined + format?: "short" | "long" | undefined +} + +export type DatePickerMonthGridValue = DatePickerCell[][] + +export interface DatePickerApiYearGridProps { + columns?: number | undefined +} + +export type DatePickerYearGridValue = DatePickerCell[][] + +export interface DatePickerApiViewProps { + view?: DatePickerDateView | undefined +} + +export interface DatePickerApiViewTriggerProps extends DatePickerApiViewProps { + /** + * The view to switch to when the trigger is activated. When omitted, the + * trigger toggles to the next view instead. + */ + goToView?: DatePickerDateView | undefined +} + +export interface DatePickerApiHiddenInputProps { + /** + * The index of the selected date this hidden input represents. + */ + index?: number | undefined + /** + * The formatted value submitted with the form. + */ + value?: string | undefined +} + +export interface DatePickerApiInputProps { + /** + * Whether to fix the input value on blur. + * @default true + */ + fixOnBlur?: boolean | undefined + /** + * The index of the input to focus. + */ + index?: number | undefined +} + +export interface DatePickerApiLabelProps { + index?: number | undefined +} + +export type DatePickerPresetTriggerValue = DateValue[] | DateRangePreset + +export interface DatePickerApiPresetTriggerProps { + value: DatePickerPresetTriggerValue +} + +export interface DatePickerApi { + /** + * Discards selection, restores previous value, and closes the calendar. + */ + cancel: VoidFunction + /** + * Clears the selected date(s). + */ + clearValue: (options?: {focus?: boolean}) => void + /** + * Whether the date picker is disabled + */ + disabled: boolean + /** + * Whether the input is focused + */ + focused: boolean + /** + * The focused date. + */ + focusedValue: DateValue + /** + * The focused date as a Date object. + */ + focusedValueAsDate: Date + /** + * The focused date as a string. + */ + focusedValueAsString: string + /** + * Function to set the selected month. + */ + focusMonth: (month: number) => void + /** + * Function to set the selected year. + */ + focusYear: (year: number) => void + /** + * Formats the given date value based on the provided options. + */ + format: (value: DateValue, opts?: Intl.DateTimeFormatOptions) => string + /** + * Returns an array of days in the week index counted from the provided start + * date, or the first visible date if not given. + */ + getDaysInWeek: (week: number, from?: DateValue) => DateValue[] + /** + * Returns the state details for a given cell. + */ + getDayTableCellState: ( + props: DatePickerApiDayTableCellProps, + ) => DatePickerDayTableCellState + /** + * Returns the start and end years of the decade. + */ + getDecade: () => DatePickerRange + /** + * Returns the months of the year + */ + getMonths: (props?: DatePickerMonthFormatOptions) => DatePickerCell[] + /** + * Returns the months of the year based on the columns. + * Represented as an array of arrays of months. + */ + getMonthsGrid: ( + props?: DatePickerApiMonthGridProps, + ) => DatePickerMonthGridValue + /** + * Returns the state details for a given month cell. + */ + getMonthTableCellState: ( + props: DatePickerApiTableCellProps, + ) => DatePickerTableCellState + /** + * Returns the weeks of the month from the provided date. Represented as an array + * of arrays of dates. + */ + getMonthWeeks: (from?: DateValue) => DateValue[][] + /** + * Returns the offset of the month based on the provided number of months. + */ + getOffset: (duration: DateDuration) => DatePickerDateValueOffset + /** + * Returns the range of dates based on the provided date range preset. + */ + getRangePresetValue: (value: DateRangePreset) => DateValue[] + /** + * Returns the months of the year + */ + getYears: () => DatePickerCell[] + /** + * Returns the years of the decade based on the columns. + * Represented as an array of arrays of years. + */ + getYearsGrid: (props?: DatePickerApiYearGridProps) => DatePickerYearGridValue + /** + * Returns the state details for a given year cell. + */ + getYearTableCellState: ( + props: DatePickerApiTableCellProps, + ) => DatePickerTableCellState + /** + * Goes to the next month/year/decade. + */ + goToNext: VoidFunction + /** + * Goes to the previous month/year/decade. + */ + goToPrev: VoidFunction + /** + * Whether the date picker is rendered inline + */ + inline: boolean + /** + * Whether the date picker is invalid + */ + invalid: boolean + /** + * Whether the maximum number of selected dates has been reached. + */ + isMaxSelected: boolean + /** + * Returns whether the provided date is available (or can be selected) + */ + isUnavailable: (date: DateValue) => boolean + /** + * The maximum number of dates that can be selected (only for multiple + * selection mode). + */ + maxSelectedDates: number | undefined + /** + * The number of months to display + */ + numOfMonths: number + /** + * Whether the date picker is open + */ + open: boolean + /** + * Whether the presets panel is open + */ + presetsOpen: boolean + /** + * Whether the date picker is read-only + */ + readOnly: boolean + /** + * The selection mode (single, multiple, or range) + */ + selectionMode: DatePickerSelectionMode + /** + * Sets the selected date to today. + */ + selectToday: VoidFunction + /** + * Sets the focused date to the given date. + */ + setFocusedValue: (value: DateValue) => void + /** + * Function to open or close the calendar. + */ + setOpen: (open: boolean) => void + /** + * Sets the time for a specific date value. + * Converts CalendarDate to CalendarDateTime if needed. + */ + setTime: (time: DatePickerTime, index?: number) => void + /** + * Sets the selected date to the given date. + */ + setValue: (values: (DateValue | null)[]) => void + /** + * Sets the view of the date picker. + */ + setView: (view: DatePickerDateView) => void + /** + * Adds/removes a date in the selection. + */ + toggleValue: (value: DateValue) => void + /** + * The selected date. + */ + value: (DateValue | null)[] + /** + * The selected date as a Date object. + */ + valueAsDate: (Date | null)[] + /** + * The selected date as a string. + */ + valueAsString: string[] + /** + * The current view of the date picker + */ + view: DatePickerDateView + /** + * The visible range of dates. + */ + visibleRange: DatePickerVisibleRange + /** + * The human readable text for the visible range of dates. + */ + visibleRangeText: DatePickerVisibleRangeText + /** + * The days of the week. Represented as an array of strings. + */ + weekDays: DatePickerWeekDay[] + /** + * The weeks of the month. Represented as an array of arrays of dates. + */ + weeks: DateValue[][] + + // group: bindings + getClearTriggerBindings: ( + params: IdRegistrationProps, + ) => DatePickerClearTriggerBindings + getContentBindings: (params: IdRegistrationProps) => DatePickerContentBindings + getControlBindings: (params: IdRegistrationProps) => DatePickerControlBindings + getDayTableCellBindings: ( + props: DatePickerApiDayTableCellProps, + ) => DatePickerDayTableCellBindings + getDayTableCellTriggerBindings: ( + props: DatePickerApiDayTableCellProps, + ) => DatePickerDayTableCellTriggerBindings + getErrorIndicatorBindings: () => DatePickerErrorIndicatorBindings + getErrorTextBindings: ( + props: IdRegistrationProps, + ) => DatePickerErrorTextBindings + getHiddenInputBindings: ( + props: DatePickerApiHiddenInputProps, + ) => DatePickerHiddenInputBindings + getHintBindings: (props: IdRegistrationProps) => DatePickerHintBindings + getInputBindings: ( + params: IdRegistrationProps & DatePickerApiInputProps, + ) => DatePickerInputBindings + getInputGroupBindings: () => DatePickerInputGroupBindings + getInputGroupTriggerBindings: ( + params: IdRegistrationProps, + ) => DatePickerInputGroupTriggerBindings + getLabelBindings: (props?: DatePickerApiLabelProps) => DatePickerLabelBindings + getMonthTableCellBindings: ( + props: DatePickerApiTableCellProps, + ) => DatePickerMonthTableCellBindings + getMonthTableCellTriggerBindings: ( + props: DatePickerApiTableCellProps, + ) => DatePickerMonthTableCellTriggerBindings + getNextTriggerBindings: ( + props: DatePickerApiViewProps, + ) => DatePickerNextTriggerBindings + getPositionerBindings: ( + params: IdRegistrationProps, + ) => DatePickerPositionerBindings + getPresetsBindings: () => DatePickerPresetsBindings + getPresetsTriggerBindings: () => DatePickerPresetsTriggerBindings + getPresetTriggerBindings: ( + props: DatePickerApiPresetTriggerProps, + ) => DatePickerPresetTriggerBindings + getPrevTriggerBindings: ( + props: DatePickerApiViewProps, + ) => DatePickerPrevTriggerBindings + getRangeTextBindings: () => DatePickerRangeTextBindings + getRootBindings: () => DatePickerRootBindings + getTableBindings: (props: DatePickerApiTableProps) => DatePickerTableBindings + getTableBodyBindings: ( + props: DatePickerApiTableProps, + ) => DatePickerTableBodyBindings + getTableHeadBindings: ( + props: DatePickerApiTableProps, + ) => DatePickerTableHeadBindings + getTableHeaderBindings: ( + props: DatePickerApiTableProps, + ) => DatePickerTableHeaderBindings + getTableRowBindings: ( + props: DatePickerApiTableProps, + ) => DatePickerTableRowBindings + getTriggerBindings: (params: IdRegistrationProps) => DatePickerTriggerBindings + getViewBindings: (props: DatePickerApiViewProps) => DatePickerViewBindings + getViewCloseTriggerBindings: () => DatePickerViewCloseTriggerBindings + getViewControlBindings: ( + props: DatePickerApiViewProps, + ) => DatePickerViewControlBindings + getViewTriggerBindings: ( + props?: DatePickerApiViewTriggerProps, + ) => DatePickerViewTriggerBindings + getYearTableCellBindings: ( + props: DatePickerApiTableCellProps, + ) => DatePickerYearTableCellBindings + getYearTableCellTriggerBindings: ( + props: DatePickerApiTableCellProps, + ) => DatePickerYearTableCellTriggerBindings +} + +type CommonBindings = DirectionProperty + +type PartName = AnatomyPartName +interface Part

extends AnatomyPart<"datePicker", P> {} + +export interface DatePickerRootBindings extends CommonBindings, Part<"root"> { + "data-disabled": BooleanDataAttr + "data-empty": BooleanDataAttr + "data-readonly": BooleanDataAttr + "data-state": "open" | "closed" +} + +export interface DatePickerLabelBindings extends CommonBindings, Part<"label"> { + "data-disabled": BooleanDataAttr + "data-readonly": BooleanDataAttr + "data-state": "open" | "closed" + onClick: JSX.MouseEventHandler | undefined +} + +export interface DatePickerControlBindings + extends CommonBindings, Part<"control"> { + "data-disabled": BooleanDataAttr + "data-placeholder-shown": BooleanDataAttr + id: string +} + +export interface DatePickerHintBindings extends CommonBindings, Part<"hint"> { + "data-disabled": BooleanDataAttr + hidden: boolean + id: string +} + +export interface DatePickerErrorTextBindings + extends CommonBindings, Part<"errorText"> { + "aria-live": "polite" + hidden: boolean + id: string +} + +export interface DatePickerErrorIndicatorBindings + extends CommonBindings, Part<"errorIndicator"> { + "aria-label": string + hidden: boolean +} + +export interface DatePickerContentBindings + extends CommonBindings, Part<"content"> { + "aria-label": string + "aria-roledescription": "datepicker" + "data-inline": BooleanDataAttr + "data-placement": Placement + "data-presets-open": BooleanDataAttr + "data-side": PlacementSide | undefined + "data-state": "open" | "closed" + hidden: boolean + id: string + role: "dialog" | "group" + tabIndex: -1 +} + +export interface DatePickerTableBindings extends CommonBindings, Part<"table"> { + "aria-disabled": BooleanAriaAttr + "aria-label": string + "aria-multiselectable": BooleanAriaAttr + "aria-readonly": BooleanAriaAttr + "aria-roledescription": string + "data-columns": number + "data-view": DatePickerDateView + onKeyDown: JSX.KeyboardEventHandler + onPointerDown: JSX.PointerEventHandler + onPointerLeave: JSX.PointerEventHandler + onPointerUp: JSX.PointerEventHandler + role: "grid" + tabIndex: -1 +} + +export interface DatePickerTableHeadBindings + extends CommonBindings, Part<"tableHead"> { + "data-disabled": BooleanDataAttr + "data-view": DatePickerDateView +} + +export interface DatePickerTableHeaderBindings + extends CommonBindings, Part<"tableHeader"> { + "data-disabled": BooleanDataAttr + "data-view": DatePickerDateView +} + +export interface DatePickerTableBodyBindings + extends CommonBindings, Part<"tableBody"> { + "data-disabled": BooleanDataAttr + "data-view": DatePickerDateView +} + +export interface DatePickerTableRowBindings + extends CommonBindings, Part<"tableRow"> { + "aria-disabled": BooleanAriaAttr + "data-disabled": BooleanDataAttr + "data-view": DatePickerDateView +} + +export interface DatePickerDayTableCellBindings + extends CommonBindings, Part<"tableCell"> { + "aria-current": "date" | undefined + "aria-disabled": BooleanAriaAttr + "aria-invalid": BooleanAriaAttr + "aria-selected": boolean + "data-value": string + role: "gridcell" +} + +export interface DatePickerDayTableCellTriggerBindings + extends CommonBindings, Part<"tableCellTrigger"> { + "aria-disabled": BooleanAriaAttr + "aria-invalid": BooleanAriaAttr + "aria-label": string + "data-disabled": BooleanDataAttr + "data-focus": BooleanDataAttr + "data-hover-range-end": BooleanDataAttr + "data-hover-range-start": BooleanDataAttr + "data-in-hover-range": BooleanDataAttr + "data-in-range": BooleanDataAttr + "data-outside-range": BooleanDataAttr + "data-range-end": BooleanDataAttr + "data-range-start": BooleanDataAttr + "data-selectable": BooleanDataAttr + "data-selected": BooleanDataAttr + "data-today": BooleanDataAttr + "data-unavailable": BooleanDataAttr + "data-value": string + "data-view": "day" + "data-weekend": BooleanDataAttr + onClick: JSX.MouseEventHandler + onPointerMove: JSX.PointerEventHandler | undefined + role: "button" + tabIndex: 0 | -1 +} + +export interface DatePickerMonthTableCellBindings + extends CommonBindings, Part<"tableCell"> { + "aria-disabled": BooleanAriaAttr + "aria-selected": BooleanAriaAttr + colSpan: number | undefined + "data-selected": BooleanDataAttr + "data-value": number + role: "gridcell" +} + +export interface DatePickerMonthTableCellTriggerBindings + extends CommonBindings, Part<"tableCellTrigger"> { + "aria-disabled": BooleanAriaAttr + "aria-label": string + "data-disabled": BooleanDataAttr + "data-focus": BooleanDataAttr + "data-hover-range-end": BooleanDataAttr + "data-hover-range-start": BooleanDataAttr + "data-in-hover-range": BooleanDataAttr + "data-in-range": BooleanDataAttr + "data-outside-range": BooleanDataAttr + "data-range-end": BooleanDataAttr + "data-range-start": BooleanDataAttr + "data-selectable": BooleanDataAttr + "data-selected": BooleanDataAttr + "data-value": number + "data-view": "month" + onClick: JSX.MouseEventHandler + onPointerMove: JSX.PointerEventHandler | undefined + role: "button" + tabIndex: 0 | -1 +} + +export interface DatePickerYearTableCellBindings + extends CommonBindings, Part<"tableCell"> { + "aria-disabled": BooleanAriaAttr + "aria-selected": BooleanAriaAttr + colSpan: number | undefined + "data-selected": BooleanDataAttr + "data-value": number + role: "gridcell" +} + +export interface DatePickerYearTableCellTriggerBindings + extends CommonBindings, Part<"tableCellTrigger"> { + "aria-disabled": BooleanAriaAttr + "aria-label": string + "data-disabled": BooleanDataAttr + "data-focus": BooleanDataAttr + "data-hover-range-end": BooleanDataAttr + "data-hover-range-start": BooleanDataAttr + "data-in-hover-range": BooleanDataAttr + "data-in-range": BooleanDataAttr + "data-outside-range": BooleanDataAttr + "data-range-end": BooleanDataAttr + "data-range-start": BooleanDataAttr + "data-selectable": BooleanDataAttr + "data-selected": BooleanDataAttr + "data-value": number + "data-view": "year" + onClick: JSX.MouseEventHandler + onPointerMove: JSX.PointerEventHandler | undefined + role: "button" + tabIndex: 0 | -1 +} + +export interface DatePickerNextTriggerBindings + extends CommonBindings, Part<"nextTrigger"> { + "aria-label": string + "data-disabled": BooleanDataAttr + disabled: boolean + onClick: JSX.MouseEventHandler + type: "button" +} + +export interface DatePickerPrevTriggerBindings + extends CommonBindings, Part<"prevTrigger"> { + "aria-label": string + "data-disabled": BooleanDataAttr + disabled: boolean + onClick: JSX.MouseEventHandler + type: "button" +} + +export interface DatePickerRangeTextBindings + extends CommonBindings, Part<"rangeText"> {} + +export interface DatePickerClearTriggerBindings + extends CommonBindings, Part<"clearTrigger"> { + "aria-label": string + disabled: boolean + hidden: boolean + id: string + onClick: JSX.MouseEventHandler + type: "button" +} + +export interface DatePickerTriggerBindings + extends CommonBindings, Part<"trigger"> { + "aria-controls": string + "aria-expanded": boolean + "aria-haspopup": "dialog" + "aria-label": string + "data-placeholder-shown": BooleanDataAttr + "data-placement": Placement | undefined + "data-side": PlacementSide | undefined + "data-state": "open" | "closed" + disabled: boolean | undefined + id: string + onClick: JSX.MouseEventHandler + type: "button" +} + +export interface DatePickerViewBindings extends CommonBindings, Part<"view"> { + "data-view": DatePickerDateView + hidden: boolean + role: "application" +} + +export interface DatePickerViewTriggerBindings + extends CommonBindings, Part<"viewTrigger"> { + "aria-label": string + "data-view": DatePickerDateView + disabled: boolean | undefined + onClick: JSX.MouseEventHandler + type: "button" +} + +export interface DatePickerViewControlBindings + extends CommonBindings, Part<"viewControl"> { + "data-view": DatePickerDateView +} + +export interface DatePickerHiddenInputBindings + extends CommonBindings, Part<"hiddenInput"> { + "aria-hidden": true + "data-index": number + disabled: boolean | undefined + name: string | undefined + onChange: JSX.ChangeEventHandler + onFocus: JSX.FocusEventHandler + readOnly: boolean + required: boolean | undefined + style: JSX.CSSProperties + tabIndex: number + type: "text" + value: string +} + +export interface DatePickerInputGroupBindings extends CommonBindings { + "aria-describedby"?: string | undefined + "aria-invalid"?: BooleanAriaAttr + "data-invalid"?: BooleanDataAttr +} + +/** + * Bindings that turn the bordered field itself into the calendar trigger, used + * in `multiple` mode where no text input owns focus. + */ +export interface DatePickerInputGroupTriggerBindings extends CommonBindings { + "aria-controls": string + "aria-disabled": BooleanAriaAttr + "aria-expanded": boolean + "aria-haspopup": "dialog" + "aria-invalid": BooleanAriaAttr + "aria-label": string + "aria-readonly": BooleanAriaAttr + "aria-required": BooleanAriaAttr + "data-disabled": BooleanDataAttr + "data-placeholder-shown": BooleanDataAttr + "data-placement": Placement | undefined + "data-readonly": BooleanDataAttr + "data-side": PlacementSide | undefined + "data-state": "open" | "closed" + id: string + onClick: JSX.MouseEventHandler + onKeyDown: JSX.KeyboardEventHandler + role: "combobox" + tabIndex: number +} + +export interface DatePickerInputBindings extends CommonBindings, Part<"input"> { + "aria-describedby": string | undefined + "aria-description": string + "aria-invalid": BooleanAriaAttr + autoComplete: "off" + autoCorrect: "off" + "data-index": number + "data-invalid": BooleanDataAttr + "data-placeholder-shown": BooleanDataAttr + "data-state": "open" | "closed" + defaultValue: string + disabled: boolean | undefined + id: string + name: string | undefined + onBeforeInput: JSX.FormEventHandler + onBlur: JSX.FocusEventHandler + onClick: JSX.MouseEventHandler + onFocus: JSX.FocusEventHandler + onInput: JSX.FormEventHandler + onKeyDown: JSX.KeyboardEventHandler + placeholder: string + readOnly: boolean | undefined + required: boolean | undefined + spellCheck: "false" +} + +export interface DatePickerPositionerBindings + extends CommonBindings, Part<"positioner"> { + id: string + style: JSX.CSSProperties +} + +export interface DatePickerPresetTriggerBindings + extends CommonBindings, Part<"presetTrigger"> { + "aria-label": string + onClick: JSX.MouseEventHandler + type: "button" +} + +export interface DatePickerPresetsBindings + extends CommonBindings, Part<"presets"> { + hidden: boolean +} + +export interface DatePickerPresetsTriggerBindings + extends CommonBindings, Part<"presetsTrigger"> { + "aria-label": string + disabled: boolean | undefined + onClick: JSX.MouseEventHandler + type: "button" +} + +export interface DatePickerViewCloseTriggerBindings + extends CommonBindings, Part<"viewCloseTrigger"> { + "aria-label": string + disabled: boolean | undefined + onClick: JSX.MouseEventHandler + type: "button" +} + +/** Re-exported types */ + +export type {DateDuration, DateRangePreset, DateValue, PositioningOptions} diff --git a/packages/common/core/src/date-picker/date-picker.utils.ts b/packages/common/core/src/date-picker/date-picker.utils.ts new file mode 100644 index 000000000..97fb57f31 --- /dev/null +++ b/packages/common/core/src/date-picker/date-picker.utils.ts @@ -0,0 +1,292 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {DateFormatter, type DateValue} from "@internationalized/date" + +import {getDecadeRange} from "@qualcomm-ui/utils/date-utils" +import {match} from "@qualcomm-ui/utils/functions" +import {clampValue} from "@qualcomm-ui/utils/number" + +import type { + DatePickerDateView, + DatePickerIntlTranslations, + DatePickerSelectionMode, + DatePickerVisibleRangeText, +} from "./date-picker.types.js" + +export function adjustStartAndEndDate( + value: (DateValue | null)[], +): (DateValue | null)[] { + const [startDate, endDate] = value + if (!startDate || !endDate) { + return value + } + return startDate.compare(endDate) <= 0 ? value : [endDate, startDate] +} + +/** + * Range preserves positional holes (`[null, end]`) by design + */ +export function normalizeValueForMode( + values: (DateValue | null)[], + mode: DatePickerSelectionMode, +): (DateValue | null)[] { + if (mode === "single") { + return values.filter((date) => date != null).slice(0, 1) + } + if (mode === "multiple") { + return values.filter((date) => date != null) + } + return adjustStartAndEndDate(values.slice(0, 2)) +} + +export function isDateWithinRange( + date: DateValue, + value: (DateValue | null)[], +): boolean { + const [startDate, endDate] = value + if (!startDate || !endDate) { + return false + } + return startDate.compare(date) <= 0 && endDate.compare(date) >= 0 +} + +export function sortDates(values: (DateValue | null)[]): (DateValue | null)[] { + if (values.some((date) => date == null)) { + return values.slice() + } + return values.slice().sort((a, b) => a!.compare(b!)) +} + +export function getRoleDescription(view: DatePickerDateView): string { + return match(view, { + day: "calendar month", + month: "calendar year", + year: "calendar decade", + }) +} + +const PLACEHOLDERS: Record = { + day: "dd", + month: "mm", + year: "yyyy", +} + +export function getInputPlaceholder(locale: string): string { + return new DateFormatter(locale) + .formatToParts(new Date()) + .map((item) => PLACEHOLDERS[item.type] ?? item.value) + .join("") +} + +export const isValidDate = (value: DateValue): boolean => { + return ( + !Number.isNaN(value.day) && + !Number.isNaN(value.month) && + !Number.isNaN(value.year) + ) +} + +export const defaultTranslations: Required = { + clearTrigger: "Clear selected dates", + content: "calendar", + dayCell(state) { + if (state.unavailable) { + return `Not available. ${state.valueText}` + } + if (state.firstInRange) { + return `Starting range from ${state.valueText}` + } + if (state.lastInRange) { + return `Range ending at ${state.valueText}` + } + if (state.selected) { + return `Selected date. ${state.valueText}` + } + return `Choose ${state.valueText}` + }, + errorIndicator: "Error", + inputDescription(format) { + return `Date format: ${format}` + }, + nextTrigger(view) { + return match(view, { + day: "Switch to next month", + month: "Switch to next year", + year: "Switch to next decade", + }) + }, + // TODO: Revisit this + placeholder() { + return {day: "dd", month: "mm", year: "yyyy"} + }, + presetsTrigger(open) { + return open ? "Show calendar" : "Show presets" + }, + presetTrigger(value) { + const [start = "", end = ""] = value + return `select ${start} to ${end}` + }, + prevTrigger(view) { + return match(view, { + day: "Switch to previous month", + month: "Switch to previous year", + year: "Switch to previous decade", + }) + }, + rangeInputEnd: "End date", + rangeInputStart: "Start date", + trigger(state) { + if (state.open) { + return "Close calendar" + } + const [start, end] = state.valueText + if (state.selectionMode === "multiple") { + const count = state.valueText.filter(Boolean).length + return count ? `Change dates, ${count} selected` : "Choose dates" + } + if (state.selectionMode === "range") { + if (start && end) { + return `Change date range, ${start} to ${end}` + } + if (start) { + return `Change date range, from ${start}` + } + if (end) { + return `Change date range, until ${end}` + } + return "Choose date range" + } + return start ? `Change date, ${start}` : "Choose date" + }, + viewCloseTrigger: "Return to calendar", + viewTrigger(view, targetView) { + if (targetView) { + return match(targetView, { + day: "Switch to day view", + month: "Switch to month view", + year: "Switch to year view", + }) + } + return match(view, { + day: "Switch to year view", + month: "Switch to day view", + year: "Switch to month view", + }) + }, +} + +// 0 – day, 1 – month, 2 – year; +type DateViewNumber = 0 | 1 | 2 + +function viewToNumber( + view: DatePickerDateView | undefined, + fallback: DateViewNumber | undefined, +): DateViewNumber { + if (!view) { + return fallback || 0 + } + return view === "day" ? 0 : view === "month" ? 1 : 2 +} + +function viewNumberToView( + viewNumber: DateViewNumber | undefined, +): DatePickerDateView { + return viewNumber === 0 ? "day" : viewNumber === 1 ? "month" : "year" +} + +export function clampView( + view: DatePickerDateView | undefined, + minView: DatePickerDateView | undefined, + maxView: DatePickerDateView | undefined, +): DatePickerDateView { + return viewNumberToView( + clampValue( + viewToNumber(view, 0), + viewToNumber(minView, 0), + viewToNumber(maxView, 2), + ) as DateViewNumber, + ) +} + +export function isAboveMinView( + view: DatePickerDateView, + minView: DatePickerDateView, +): boolean { + return viewToNumber(view, 0) > viewToNumber(minView, 0) +} + +export function isBelowMinView( + view: DatePickerDateView, + minView: DatePickerDateView, +): boolean { + return viewToNumber(view, 0) < viewToNumber(minView, 0) +} + +export function getNextView( + view: DatePickerDateView, + minView: DatePickerDateView, + maxView: DatePickerDateView, +): DatePickerDateView { + const nextViewNumber = clampValue( + viewToNumber(view, 0) + 1, + 0, + 2, + ) as DateViewNumber + return clampView(viewNumberToView(nextViewNumber), minView, maxView) +} + +export function getPreviousView( + view: DatePickerDateView, + minView: DatePickerDateView, + maxView: DatePickerDateView, +): DatePickerDateView { + const prevViewNumber = clampValue( + viewToNumber(view, 0) - 1, + 0, + 2, + ) as DateViewNumber + return clampView(viewNumberToView(prevViewNumber), minView, maxView) +} + +const views: DatePickerDateView[] = ["day", "month", "year"] +export function eachView(cb: (view: DatePickerDateView) => void): void { + for (const view of views) { + cb(view) + } +} + +interface VisibleRangeTextOptions { + endValue: DateValue + locale: string + startValue: DateValue + timeZone: string + view: DatePickerDateView +} + +export function getVisibleRangeText( + opts: VisibleRangeTextOptions, +): DatePickerVisibleRangeText { + const {endValue, locale, startValue, timeZone, view} = opts + + if (view === "year") { + const years = getDecadeRange(startValue.year, {strict: true}) + const start = years.at(0)!.toString() + const end = years.at(-1)!.toString() + return {end, formatted: `${start} - ${end}`, start} + } + + const formatter = new DateFormatter(locale, { + calendar: startValue.calendar.identifier, + ...(view === "month" ? {} : {month: "long"}), + timeZone, + year: "numeric", + }) + const start = formatter.format(startValue.toDate(timeZone)) + const end = formatter.format(endValue.toDate(timeZone)) + const formatted = start === end ? start : `${start} - ${end}` + return {end, formatted, start} +} diff --git a/packages/common/core/src/date-picker/index.ts b/packages/common/core/src/date-picker/index.ts new file mode 100644 index 000000000..9974c616d --- /dev/null +++ b/packages/common/core/src/date-picker/index.ts @@ -0,0 +1,7 @@ +export * from "./date-picker.anatomy.js" +export * from "./date-picker.machine.js" +export type * from "./date-picker.types.js" +export * from "./date-picker.utils.js" +export * from "./date-picker.api.js" +export * from "./date-picker.props.js" +export * from "./date-picker.parse.js" diff --git a/packages/common/core/src/date-picker/internal/date-picker.dom.ts b/packages/common/core/src/date-picker/internal/date-picker.dom.ts new file mode 100644 index 000000000..f3f1e7337 --- /dev/null +++ b/packages/common/core/src/date-picker/internal/date-picker.dom.ts @@ -0,0 +1,77 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {isHTMLElement, query, queryAll} from "@qualcomm-ui/dom/query" +import type {ScopeDomElements, ScopeDomIds} from "@qualcomm-ui/utils/machine" + +import type { + DatePickerElementIds, + DatePickerScope, + DatePickerDateView, +} from "../date-picker.types.js" + +export const domIds: ScopeDomIds = { + clearTrigger: (scope: DatePickerScope) => scope.ids.get("clearTrigger"), + content: (scope: DatePickerScope) => scope.ids.get("content"), + control: (scope: DatePickerScope) => scope.ids.get("control"), + errorText: (scope: DatePickerScope) => scope.ids.get("errorText"), + hint: (scope: DatePickerScope) => scope.ids.get("hint"), + input: (scope, itemKey) => scope.ids.collection("input").get(itemKey), + positioner: (scope: DatePickerScope) => scope.ids.get("positioner"), + trigger: (scope: DatePickerScope) => scope.ids.get("trigger"), +} + +export const domEls: ScopeDomElements = { + clearTrigger: (scope: DatePickerScope) => + scope.getById(domIds.clearTrigger(scope)), + content: (scope: DatePickerScope) => scope.getById(domIds.content(scope)), + control: (scope: DatePickerScope) => scope.getById(domIds.control(scope)), + errorText: (scope: DatePickerScope) => scope.getById(domIds.errorText(scope)), + hint: (scope: DatePickerScope) => scope.getById(domIds.hint(scope)), + input: (scope: DatePickerScope, itemKey) => + scope.getById(domIds.input(scope, itemKey) as string), + positioner: (scope: DatePickerScope) => + scope.getById(domIds.positioner(scope)), + trigger: (scope: DatePickerScope) => scope.getById(domIds.trigger(scope)), +} + +export function getInputEls(scope: DatePickerScope): HTMLInputElement[] { + return queryAll( + domEls.control(scope), + `[data-date-picker-part=input]`, + ) +} + +export function getFocusedCell( + scope: DatePickerScope, + view: DatePickerDateView, +): HTMLElement | null { + return query( + domEls.content(scope), + `[data-date-picker-part=table-cell-trigger][data-view=${view}][data-focus]:not([data-outside-range])`, + ) +} + +/** + * True when the event originated from a control nested inside the field, such + * as a value tag or the clear button, rather than from the field itself. + */ +export function isInteractiveDescendantEvent(event: { + currentTarget: EventTarget | null + target: EventTarget | null +}): boolean { + const {currentTarget, target} = event + if (target === currentTarget) { + return false + } + if (!isHTMLElement(target) || !isHTMLElement(currentTarget)) { + return false + } + const interactiveEl = target.closest( + "a[href], button, input, select, textarea", + ) + return !!interactiveEl && interactiveEl !== currentTarget +} diff --git a/packages/common/core/src/date-picker/internal/index.ts b/packages/common/core/src/date-picker/internal/index.ts new file mode 100644 index 000000000..308c6e183 --- /dev/null +++ b/packages/common/core/src/date-picker/internal/index.ts @@ -0,0 +1 @@ +export * from "./date-picker.dom.js" diff --git a/packages/common/core/src/menu/menu.types.ts b/packages/common/core/src/menu/menu.types.ts index 2a301baa3..f0c6c8211 100644 --- a/packages/common/core/src/menu/menu.types.ts +++ b/packages/common/core/src/menu/menu.types.ts @@ -245,7 +245,7 @@ export interface MenuApiProps * The options used to dynamically position the menu * @inheritDoc */ - positioning?: PositioningOptions | undefined + positioning?: MenuPositioningOptions | undefined /** * Whether the pressing printable characters should trigger typeahead navigation @@ -254,6 +254,15 @@ export interface MenuApiProps typeahead?: boolean | undefined } +export interface MenuPositioningOptions extends PositioningOptions { + /** + * The initial placement of the floating element + * + * @default 'bottom-start' + */ + placement?: Placement | undefined +} + type PropsWithDefault = | "closeOnSelect" | "composite" diff --git a/packages/common/core/src/select/select.types.ts b/packages/common/core/src/select/select.types.ts index ca9fb773f..fd855f33d 100644 --- a/packages/common/core/src/select/select.types.ts +++ b/packages/common/core/src/select/select.types.ts @@ -132,12 +132,6 @@ export interface SelectElementIds { export interface SelectScope extends ScopeWithIds {} export interface SelectPositioningOptions extends PositioningOptions { - /** - * The main axis offset or gap between the reference and floating element - * @default 2 - */ - gutter?: number | undefined - /** * The initial placement of the floating element * @default 'bottom-start' diff --git a/packages/common/dom/src/floating-ui/types.ts b/packages/common/dom/src/floating-ui/types.ts index 78c343f4f..d46206782 100644 --- a/packages/common/dom/src/floating-ui/types.ts +++ b/packages/common/dom/src/floating-ui/types.ts @@ -128,7 +128,7 @@ export interface PositioningOptions { /** * The main axis offset or gap between the reference and floating element * - * @default 8 + * @default 2 */ gutter?: number | undefined diff --git a/packages/common/dom/src/focus-trap/trap-focus.ts b/packages/common/dom/src/focus-trap/trap-focus.ts index 6e99916dd..db7b62f43 100644 --- a/packages/common/dom/src/focus-trap/trap-focus.ts +++ b/packages/common/dom/src/focus-trap/trap-focus.ts @@ -9,27 +9,42 @@ import {getDocument, raf} from "@qualcomm-ui/dom/query" import {FocusTrap} from "./focus-trap.js" import type {FocusTrapOptions} from "./focus-trap.types.js" -type ElementOrGetter = HTMLElement | null | (() => HTMLElement | null) +type ElementOrNull = HTMLElement | null + +type ElementOrGetter = + | ElementOrNull + | ElementOrNull[] + | (() => ElementOrNull | ElementOrNull[]) export interface TrapFocusOptions extends Omit {} +/** + * Contain focus within one or more elements until the returned cleanup runs. + * + * Multiple containers form one tab cycle in the order given. They must not nest, + * and no trapped node may have a positive `tabindex`; both throw. + */ export function trapFocus(el: ElementOrGetter, options: TrapFocusOptions = {}) { let trap: FocusTrap | undefined const cleanup = raf(() => { - const contentEl = typeof el === "function" ? el() : el - if (!contentEl) { + const resolved = typeof el === "function" ? el() : el + const containers = (Array.isArray(resolved) ? resolved : [resolved]).filter( + (node): node is HTMLElement => !!node, + ) + + if (!containers.length) { return } - trap = new FocusTrap(contentEl, { + trap = new FocusTrap(containers, { allowOutsideClick: true, delayInitialFocus: false, escapeDeactivates: false, - fallbackFocus: contentEl, + fallbackFocus: containers[0], preventScroll: true, returnFocusOnDeactivate: true, ...options, - document: getDocument(contentEl), + document: getDocument(containers[0]), }) try { diff --git a/packages/common/dom/src/live-region/live-region.ts b/packages/common/dom/src/live-region/live-region.ts index c3caefa64..659a2ea86 100644 --- a/packages/common/dom/src/live-region/live-region.ts +++ b/packages/common/dom/src/live-region/live-region.ts @@ -13,7 +13,7 @@ export interface LiveRegionOptions { const ID = "__live-region__" -interface LiveRegion { +export interface LiveRegion { announce: (message: string, delay?: number) => void destroy: () => void toJSON(): string diff --git a/packages/common/dom/src/query/index.ts b/packages/common/dom/src/query/index.ts index aeec29a05..0946a9e30 100644 --- a/packages/common/dom/src/query/index.ts +++ b/packages/common/dom/src/query/index.ts @@ -23,6 +23,7 @@ export {MAX_Z_INDEX} from "./shared.js" export * from "./tabbable.js" export * from "./text-selection.js" export * from "./typeahead.js" +export type * from "./types.js" export * from "./visual-viewport.js" export * from "./visually-hidden.js" export * from "./wait-for.js" diff --git a/packages/common/mdx-vite/src/docs-plugin/__tests__/doc-props-indexer.spec.ts b/packages/common/mdx-vite/src/docs-plugin/__tests__/doc-props-indexer.spec.ts new file mode 100644 index 000000000..678a60362 --- /dev/null +++ b/packages/common/mdx-vite/src/docs-plugin/__tests__/doc-props-indexer.spec.ts @@ -0,0 +1,142 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {describe, expect, test} from "vitest" + +import type {PagePropType} from "@qualcomm-ui/mdx-common" +import type { + QuiPropDeclaration, + QuiPropTypes, +} from "@qualcomm-ui/typedoc-common" + +import {DocPropsIndexer} from "../doc-props/doc-props-indexer" + +function createProp(name: string): QuiPropDeclaration { + return { + name, + resolvedType: { + name, + prettyType: "boolean", + type: "boolean", + }, + } +} + +describe("DocPropsIndexer", () => { + test("lookup-only component reflections do not reserve prop ids", () => { + const closeOnSelect = createProp("closeOnSelect") + const locale = createProp("locale") + const props: Record = { + DatePicker: { + comment: {summary: [{kind: "text", text: "Date picker."}]}, + name: "DatePicker", + resolvedType: { + functionParameters: [closeOnSelect, locale], + name: "DatePicker", + prettyType: "() => ReactElement", + type: "signature", + }, + }, + DatePickerProps: { + name: "DatePickerProps", + props: [createProp("presets"), closeOnSelect, locale], + }, + DatePickerRoot: { + name: "DatePickerRoot", + }, + DatePickerRootProps: { + name: "DatePickerRootProps", + props: [closeOnSelect, locale], + }, + } + const indexer = new DocPropsIndexer(props) + const sections = indexer.build( + [ + '', + '', + ].join("\n"), + [{headingLevel: 3, id: "locale", tagName: "h3", textContent: "Locale"}], + ) + + expect(sections?.map((section) => section.heading?.id)).toEqual([ + "presets", + "closeOnSelect", + "locale-1", + ]) + expect(indexer.getDocProps().DatePicker.comment).toEqual( + props.DatePicker.comment, + ) + expect(indexer.getDocProps().DatePicker.props).toBeUndefined() + const rootProps = indexer.getDocProps().DatePickerRootProps + .props as PagePropType[] + expect(rootProps.map(({id}) => id)).toEqual(["closeOnSelect", "locale-1"]) + }) + + test("lookup-only entries do not overwrite explicitly indexed components", () => { + const value = createProp("value") + const props: Record = { + Widget: { + name: "Widget", + resolvedType: { + functionParameters: [value], + name: "Widget", + prettyType: "() => ReactElement", + type: "signature", + }, + }, + WidgetProps: { + name: "WidgetProps", + props: [value], + }, + } + const indexer = new DocPropsIndexer(props) + + indexer.build( + [ + '', + '', + ].join("\n"), + [], + ) + + expect( + indexer.getDocProps().Widget.resolvedType?.functionParameters?.[0], + ).toMatchObject({id: "value"}) + }) + + test("a lookup-only entry does not inherit props indexed by a previous page", () => { + const value = createProp("value") + const props: Record = { + Widget: { + comment: {summary: [{kind: "text", text: "Widget."}]}, + name: "Widget", + resolvedType: { + functionParameters: [value], + name: "Widget", + prettyType: "() => ReactElement", + type: "signature", + }, + }, + WidgetProps: { + name: "WidgetProps", + props: [value], + }, + } + const indexer = new DocPropsIndexer(props) + + indexer.build('', []) + expect( + indexer.getDocProps().Widget.resolvedType?.functionParameters, + ).toBeDefined() + + // A later page references only `WidgetProps`, which indexes `Widget` as + // lookup-only. It must get the lean entry, not the previous page's. + indexer.reset() + indexer.build('', []) + + expect(indexer.getDocProps().Widget).toEqual({ + comment: props.Widget.comment, + name: "Widget", + }) + }) +}) diff --git a/packages/common/mdx-vite/src/docs-plugin/doc-props/doc-props-indexer.ts b/packages/common/mdx-vite/src/docs-plugin/doc-props/doc-props-indexer.ts index 000fd5e25..a5e3110c8 100644 --- a/packages/common/mdx-vite/src/docs-plugin/doc-props/doc-props-indexer.ts +++ b/packages/common/mdx-vite/src/docs-plugin/doc-props/doc-props-indexer.ts @@ -79,6 +79,7 @@ export const docPropsJsxNodes: string[] = [ ] interface DocPropEntry { + lookupOnly?: boolean name: string omitFrom?: string[] } @@ -96,6 +97,7 @@ export class DocPropsIndexer { reset(): void { this.idService.reset() this.docPropsEntries = [] + this.pageDocProps = {} } /** @@ -123,7 +125,11 @@ export class DocPropsIndexer { this.docPropsEntries.push({name, omitFrom}) if (name.endsWith("Props")) { // also index the corresponding component for lookup on this page. - this.docPropsEntries.push({name: name.slice(0, -5), omitFrom}) + this.docPropsEntries.push({ + lookupOnly: true, + name: name.slice(0, -5), + omitFrom, + }) } } } @@ -175,6 +181,16 @@ export class DocPropsIndexer { return [] } + if (entry.lookupOnly) { + if (!this.pageDocProps[entry.name]) { + this.pageDocProps[entry.name] = { + comment: propTypes.comment, + name: propTypes.name, + } + } + return [] + } + const omittedProps = new Set( (entry.omitFrom ?? []) .map((entry) => { diff --git a/packages/common/qds-core/src/date-picker/date-picker.anatomy.ts b/packages/common/qds-core/src/date-picker/date-picker.anatomy.ts new file mode 100644 index 000000000..8acff0cf7 --- /dev/null +++ b/packages/common/qds-core/src/date-picker/date-picker.anatomy.ts @@ -0,0 +1,22 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {type Anatomy, createAnatomy} from "@qualcomm-ui/utils/anatomy" + +const parts = [ + "actions", + "controlGroup", + "divider", + "headline", + "headlineLabel", + "headlineValue", + "inputGroup", + "inputIcon", + "rangeSeparator", + "valueTags", +] as const + +export const qdsDatePickerAnatomy: Anatomy< + "datePicker", + (typeof parts)[number] +> = createAnatomy("datePicker").parts(...parts) diff --git a/packages/common/qds-core/src/date-picker/date-picker.api.ts b/packages/common/qds-core/src/date-picker/date-picker.api.ts new file mode 100644 index 000000000..278efbd40 --- /dev/null +++ b/packages/common/qds-core/src/date-picker/date-picker.api.ts @@ -0,0 +1,225 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {booleanDataAttr} from "@qualcomm-ui/utils/attributes" +import type {PropNormalizer} from "@qualcomm-ui/utils/machine" + +import {qdsDatePickerAnatomy} from "./date-picker.anatomy.js" +import {datePickerClasses} from "./date-picker.classes.js" +import type { + QdsDatePickerActionsBindings, + QdsDatePickerApi, + QdsDatePickerApiProps, + QdsDatePickerClearTriggerBindings, + QdsDatePickerContentBindings, + QdsDatePickerControlBindings, + QdsDatePickerControlGroupBindings, + QdsDatePickerDividerBindings, + QdsDatePickerErrorIndicatorBindings, + QdsDatePickerErrorTextBindings, + QdsDatePickerHeadlineBindings, + QdsDatePickerHeadlineLabelBindings, + QdsDatePickerHeadlineValueBindings, + QdsDatePickerHintBindings, + QdsDatePickerInputBindings, + QdsDatePickerInputGroupBindings, + QdsDatePickerInputIconBindings, + QdsDatePickerLabelBindings, + QdsDatePickerNextTriggerBindings, + QdsDatePickerPositionerBindings, + QdsDatePickerPresetsBindings, + QdsDatePickerPresetsTriggerBindings, + QdsDatePickerPresetTriggerBindings, + QdsDatePickerPrevTriggerBindings, + QdsDatePickerRangeSeparatorBindings, + QdsDatePickerRangeTextBindings, + QdsDatePickerRootBindings, + QdsDatePickerTableBindings, + QdsDatePickerTableBodyBindings, + QdsDatePickerTableCellBindings, + QdsDatePickerTableCellTriggerBindings, + QdsDatePickerTableHeadBindings, + QdsDatePickerTableHeaderBindings, + QdsDatePickerTableRowBindings, + QdsDatePickerTriggerBindings, + QdsDatePickerValueTagsBindings, + QdsDatePickerViewBindings, + QdsDatePickerViewCloseTriggerBindings, + QdsDatePickerViewControlBindings, + QdsDatePickerViewTriggerBindings, +} from "./date-picker.types.js" + +const parts = qdsDatePickerAnatomy.parts + +export function createQdsDatePickerApi( + props: QdsDatePickerApiProps, + normalize: PropNormalizer, +): QdsDatePickerApi { + const size = props.size || "md" + const triggerSize = size === "lg" ? "md" : size + + return { + size, + triggerSize, + + // group: bindings + getActionsBindings(): QdsDatePickerActionsBindings { + return normalize.element({ + ...parts.actions, + className: datePickerClasses.actions, + }) + }, + getClearTriggerBindings(): QdsDatePickerClearTriggerBindings { + return normalize.element({className: datePickerClasses.clearTrigger}) + }, + getContentBindings(): QdsDatePickerContentBindings { + return normalize.element({className: datePickerClasses.content}) + }, + getControlBindings(): QdsDatePickerControlBindings { + return normalize.element({className: datePickerClasses.control}) + }, + getControlGroupBindings(): QdsDatePickerControlGroupBindings { + return normalize.element({ + ...parts.controlGroup, + className: datePickerClasses.controlGroup, + "data-size": size, + }) + }, + getDividerBindings(): QdsDatePickerDividerBindings { + return normalize.element({ + ...parts.divider, + className: datePickerClasses.divider, + }) + }, + getErrorIndicatorBindings(): QdsDatePickerErrorIndicatorBindings { + return normalize.element({ + className: datePickerClasses.errorIndicator, + "data-size": size, + }) + }, + getErrorTextBindings(): QdsDatePickerErrorTextBindings { + return normalize.element({className: datePickerClasses.errorText}) + }, + getHeadlineBindings(): QdsDatePickerHeadlineBindings { + return normalize.element({ + ...parts.headline, + className: datePickerClasses.headline, + }) + }, + getHeadlineLabelBindings(): QdsDatePickerHeadlineLabelBindings { + return normalize.element({ + ...parts.headlineLabel, + className: datePickerClasses.headlineLabel, + }) + }, + getHeadlineValueBindings(): QdsDatePickerHeadlineValueBindings { + return normalize.element({ + ...parts.headlineValue, + className: datePickerClasses.headlineValue, + }) + }, + getHintBindings(): QdsDatePickerHintBindings { + return normalize.element({className: datePickerClasses.hint}) + }, + getInputBindings(): QdsDatePickerInputBindings { + return normalize.element({ + className: datePickerClasses.input, + "data-size": size, + }) + }, + getInputGroupBindings(): QdsDatePickerInputGroupBindings { + return normalize.element({ + ...parts.inputGroup, + className: datePickerClasses.inputGroup, + "data-size": size, + }) + }, + getInputIconBindings(): QdsDatePickerInputIconBindings { + return normalize.element({ + ...parts.inputIcon, + className: datePickerClasses.inputIcon, + }) + }, + getLabelBindings(): QdsDatePickerLabelBindings { + return normalize.element({ + className: datePickerClasses.label, + "data-size": size, + }) + }, + getNextTriggerBindings(): QdsDatePickerNextTriggerBindings { + return normalize.element({className: datePickerClasses.nextTrigger}) + }, + getPositionerBindings(): QdsDatePickerPositionerBindings { + return normalize.element({className: datePickerClasses.positioner}) + }, + getPresetsBindings(): QdsDatePickerPresetsBindings { + return normalize.element({className: datePickerClasses.presets}) + }, + getPresetsTriggerBindings(): QdsDatePickerPresetsTriggerBindings { + return normalize.element({className: datePickerClasses.presetsTrigger}) + }, + getPresetTriggerBindings(): QdsDatePickerPresetTriggerBindings { + return normalize.element({className: datePickerClasses.presetTrigger}) + }, + getPrevTriggerBindings(): QdsDatePickerPrevTriggerBindings { + return normalize.element({className: datePickerClasses.prevTrigger}) + }, + getRangeSeparatorBindings(): QdsDatePickerRangeSeparatorBindings { + return normalize.element({ + ...parts.rangeSeparator, + className: datePickerClasses.rangeSeparator, + }) + }, + getRangeTextBindings(): QdsDatePickerRangeTextBindings { + return normalize.element({className: datePickerClasses.rangeText}) + }, + getRootBindings(): QdsDatePickerRootBindings { + return normalize.element({ + className: datePickerClasses.root, + "data-hide-outside-days": booleanDataAttr(props.hideOutsideDays), + }) + }, + getTableBindings(): QdsDatePickerTableBindings { + return normalize.element({className: datePickerClasses.table}) + }, + getTableBodyBindings(): QdsDatePickerTableBodyBindings { + return normalize.element({className: datePickerClasses.tableBody}) + }, + getTableCellBindings(): QdsDatePickerTableCellBindings { + return normalize.element({className: datePickerClasses.tableCell}) + }, + getTableCellTriggerBindings(): QdsDatePickerTableCellTriggerBindings { + return normalize.element({className: datePickerClasses.tableCellTrigger}) + }, + getTableHeadBindings(): QdsDatePickerTableHeadBindings { + return normalize.element({className: datePickerClasses.tableHead}) + }, + getTableHeaderBindings(): QdsDatePickerTableHeaderBindings { + return normalize.element({className: datePickerClasses.tableHeader}) + }, + getTableRowBindings(): QdsDatePickerTableRowBindings { + return normalize.element({className: datePickerClasses.tableRow}) + }, + getTriggerBindings(): QdsDatePickerTriggerBindings { + return normalize.element({className: datePickerClasses.trigger}) + }, + getValueTagsBindings(): QdsDatePickerValueTagsBindings { + return normalize.element({ + ...parts.valueTags, + className: datePickerClasses.valueTags, + }) + }, + getViewBindings(): QdsDatePickerViewBindings { + return normalize.element({className: datePickerClasses.view}) + }, + getViewCloseTriggerBindings(): QdsDatePickerViewCloseTriggerBindings { + return normalize.element({className: datePickerClasses.viewCloseTrigger}) + }, + getViewControlBindings(): QdsDatePickerViewControlBindings { + return normalize.element({className: datePickerClasses.viewControl}) + }, + getViewTriggerBindings(): QdsDatePickerViewTriggerBindings { + return normalize.element({className: datePickerClasses.viewTrigger}) + }, + } +} diff --git a/packages/common/qds-core/src/date-picker/date-picker.classes.ts b/packages/common/qds-core/src/date-picker/date-picker.classes.ts new file mode 100644 index 000000000..5726061e8 --- /dev/null +++ b/packages/common/qds-core/src/date-picker/date-picker.classes.ts @@ -0,0 +1,43 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +export const datePickerClasses = { + actions: "qui-date-picker__actions", + clearTrigger: "qui-date-picker__clear-trigger", + content: "qui-date-picker__content", + control: "qui-date-picker__control", + controlGroup: "qui-input__root qui-date-picker__control-group", + divider: "qui-date-picker__divider", + errorIndicator: "qui-input__error-indicator", + errorText: "qui-input__error-text", + headline: "qui-date-picker__headline", + headlineLabel: "qui-date-picker__headline-label", + headlineValue: "qui-date-picker__headline-value", + hint: "qui-input__hint", + input: "qui-input__input qui-date-picker__input", + inputGroup: "qui-input__input-group qui-date-picker__input-group", + inputIcon: "qui-date-picker__input-icon", + label: "qui-input__label qui-date-picker__label", + nextTrigger: "qui-date-picker__next-trigger", + positioner: "qui-date-picker__positioner", + presets: "qui-date-picker__presets", + presetsTrigger: "qui-date-picker__presets-trigger", + presetTrigger: "qui-date-picker__preset-trigger", + prevTrigger: "qui-date-picker__prev-trigger", + rangeSeparator: "qui-date-picker__range-separator", + rangeText: "qui-date-picker__range-text", + root: "qui-date-picker__root", + table: "qui-date-picker__table", + tableBody: "qui-date-picker__table-body", + tableCell: "qui-date-picker__table-cell", + tableCellTrigger: "qui-date-picker__table-cell-trigger", + tableHead: "qui-date-picker__table-head", + tableHeader: "qui-date-picker__table-header", + tableRow: "qui-date-picker__table-row", + trigger: "qui-date-picker__trigger", + valueTags: "qui-date-picker__value-tags", + view: "qui-date-picker__view", + viewCloseTrigger: "qui-date-picker__view-close-trigger", + viewControl: "qui-date-picker__view-control", + viewTrigger: "qui-date-picker__view-trigger", +} as const diff --git a/packages/common/qds-core/src/date-picker/date-picker.types.ts b/packages/common/qds-core/src/date-picker/date-picker.types.ts new file mode 100644 index 000000000..cd177d003 --- /dev/null +++ b/packages/common/qds-core/src/date-picker/date-picker.types.ts @@ -0,0 +1,154 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {QdsInputSize} from "@qualcomm-ui/qds-core/input" +import type {AnatomyPart, AnatomyPartName} from "@qualcomm-ui/utils/anatomy" +import type {BooleanDataAttr} from "@qualcomm-ui/utils/attributes" + +import type {qdsDatePickerAnatomy} from "./date-picker.anatomy.js" +import type {datePickerClasses} from "./date-picker.classes.js" + +export interface QdsDatePickerApiProps { + /** + * Whether to hide days from the previous and next months in the current + * month view. By default those days are shown. + * + * @default false + */ + hideOutsideDays?: boolean + + /** + * The size of the input field and its elements. Does not affect the calendar pane. + * + * @default 'md' + */ + size?: QdsInputSize +} + +type DatePickerClasses = typeof datePickerClasses + +type PartName = AnatomyPartName +interface Part

extends AnatomyPart<"datePicker", P> {} + +interface ClassNameBindings { + className: DatePickerClasses[K] +} + +export interface QdsDatePickerRootBindings extends ClassNameBindings<"root"> { + "data-hide-outside-days": BooleanDataAttr +} +export interface QdsDatePickerControlGroupBindings + extends ClassNameBindings<"controlGroup">, Part<"controlGroup"> { + "data-size": QdsInputSize +} +export interface QdsDatePickerInputGroupBindings + extends ClassNameBindings<"inputGroup">, Part<"inputGroup"> { + "data-size": QdsInputSize +} +export interface QdsDatePickerInputBindings extends ClassNameBindings<"input"> { + "data-size": QdsInputSize +} +export interface QdsDatePickerLabelBindings extends ClassNameBindings<"label"> { + "data-size": QdsInputSize +} +export interface QdsDatePickerErrorIndicatorBindings extends ClassNameBindings<"errorIndicator"> { + "data-size": QdsInputSize +} +export interface QdsDatePickerActionsBindings + extends ClassNameBindings<"actions">, Part<"actions"> {} +export type QdsDatePickerClearTriggerBindings = + ClassNameBindings<"clearTrigger"> +export type QdsDatePickerContentBindings = ClassNameBindings<"content"> +export type QdsDatePickerControlBindings = ClassNameBindings<"control"> +export interface QdsDatePickerDividerBindings + extends ClassNameBindings<"divider">, Part<"divider"> {} +export type QdsDatePickerErrorTextBindings = ClassNameBindings<"errorText"> +export interface QdsDatePickerHeadlineBindings + extends ClassNameBindings<"headline">, Part<"headline"> {} +export interface QdsDatePickerHeadlineLabelBindings + extends ClassNameBindings<"headlineLabel">, Part<"headlineLabel"> {} +export interface QdsDatePickerHeadlineValueBindings + extends ClassNameBindings<"headlineValue">, Part<"headlineValue"> {} +export type QdsDatePickerHintBindings = ClassNameBindings<"hint"> +export interface QdsDatePickerInputIconBindings + extends ClassNameBindings<"inputIcon">, Part<"inputIcon"> {} +export interface QdsDatePickerRangeSeparatorBindings + extends ClassNameBindings<"rangeSeparator">, Part<"rangeSeparator"> {} +export interface QdsDatePickerValueTagsBindings + extends ClassNameBindings<"valueTags">, Part<"valueTags"> {} +export type QdsDatePickerNextTriggerBindings = ClassNameBindings<"nextTrigger"> +export type QdsDatePickerPositionerBindings = ClassNameBindings<"positioner"> +export type QdsDatePickerPresetsBindings = ClassNameBindings<"presets"> +export type QdsDatePickerPresetsTriggerBindings = + ClassNameBindings<"presetsTrigger"> +export type QdsDatePickerPresetTriggerBindings = + ClassNameBindings<"presetTrigger"> +export type QdsDatePickerPrevTriggerBindings = ClassNameBindings<"prevTrigger"> +export type QdsDatePickerRangeTextBindings = ClassNameBindings<"rangeText"> +export type QdsDatePickerTableBindings = ClassNameBindings<"table"> +export type QdsDatePickerTableBodyBindings = ClassNameBindings<"tableBody"> +export type QdsDatePickerTableCellBindings = ClassNameBindings<"tableCell"> +export type QdsDatePickerTableCellTriggerBindings = + ClassNameBindings<"tableCellTrigger"> +export type QdsDatePickerTableHeadBindings = ClassNameBindings<"tableHead"> +export type QdsDatePickerTableHeaderBindings = ClassNameBindings<"tableHeader"> +export type QdsDatePickerTableRowBindings = ClassNameBindings<"tableRow"> +export type QdsDatePickerTriggerBindings = ClassNameBindings<"trigger"> +export type QdsDatePickerViewBindings = ClassNameBindings<"view"> +export type QdsDatePickerViewCloseTriggerBindings = + ClassNameBindings<"viewCloseTrigger"> +export type QdsDatePickerViewControlBindings = ClassNameBindings<"viewControl"> +export type QdsDatePickerViewTriggerBindings = ClassNameBindings<"viewTrigger"> + +export interface QdsDatePickerApi { + /** + * The resolved input size. + */ + size: QdsInputSize + + /** + * The size to render the inline trigger buttons (calendar toggle, clear). + * The `lg` field uses the `md` button, so this is never `lg`. + */ + triggerSize: QdsInputSize + + // group: bindings + getActionsBindings(): QdsDatePickerActionsBindings + getClearTriggerBindings(): QdsDatePickerClearTriggerBindings + getContentBindings(): QdsDatePickerContentBindings + getControlBindings(): QdsDatePickerControlBindings + getControlGroupBindings(): QdsDatePickerControlGroupBindings + getDividerBindings(): QdsDatePickerDividerBindings + getErrorIndicatorBindings(): QdsDatePickerErrorIndicatorBindings + getErrorTextBindings(): QdsDatePickerErrorTextBindings + getHeadlineBindings(): QdsDatePickerHeadlineBindings + getHeadlineLabelBindings(): QdsDatePickerHeadlineLabelBindings + getHeadlineValueBindings(): QdsDatePickerHeadlineValueBindings + getHintBindings(): QdsDatePickerHintBindings + getInputBindings(): QdsDatePickerInputBindings + getInputGroupBindings(): QdsDatePickerInputGroupBindings + getInputIconBindings(): QdsDatePickerInputIconBindings + getLabelBindings(): QdsDatePickerLabelBindings + getNextTriggerBindings(): QdsDatePickerNextTriggerBindings + getPositionerBindings(): QdsDatePickerPositionerBindings + getPresetsBindings(): QdsDatePickerPresetsBindings + getPresetsTriggerBindings(): QdsDatePickerPresetsTriggerBindings + getPresetTriggerBindings(): QdsDatePickerPresetTriggerBindings + getPrevTriggerBindings(): QdsDatePickerPrevTriggerBindings + getRangeSeparatorBindings(): QdsDatePickerRangeSeparatorBindings + getRangeTextBindings(): QdsDatePickerRangeTextBindings + getRootBindings(): QdsDatePickerRootBindings + getTableBindings(): QdsDatePickerTableBindings + getTableBodyBindings(): QdsDatePickerTableBodyBindings + getTableCellBindings(): QdsDatePickerTableCellBindings + getTableCellTriggerBindings(): QdsDatePickerTableCellTriggerBindings + getTableHeadBindings(): QdsDatePickerTableHeadBindings + getTableHeaderBindings(): QdsDatePickerTableHeaderBindings + getTableRowBindings(): QdsDatePickerTableRowBindings + getTriggerBindings(): QdsDatePickerTriggerBindings + getValueTagsBindings(): QdsDatePickerValueTagsBindings + getViewBindings(): QdsDatePickerViewBindings + getViewCloseTriggerBindings(): QdsDatePickerViewCloseTriggerBindings + getViewControlBindings(): QdsDatePickerViewControlBindings + getViewTriggerBindings(): QdsDatePickerViewTriggerBindings +} diff --git a/packages/common/qds-core/src/date-picker/date-picker.utils.ts b/packages/common/qds-core/src/date-picker/date-picker.utils.ts new file mode 100644 index 000000000..495fab1df --- /dev/null +++ b/packages/common/qds-core/src/date-picker/date-picker.utils.ts @@ -0,0 +1,80 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type { + DateValue, + DatePickerDateView, + DatePickerSelectionMode, +} from "@qualcomm-ui/core/date-picker" + +export const datePickerViewColumns: Record = { + day: 7, + month: 4, + year: 4, +} + +export const datePickerHeadlineFormat: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "long", + weekday: "short", + year: "numeric", +} + +export const datePickerHeadlineRangeFormat: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + year: "numeric", +} + +export interface DatePickerHeadlineValueOptions { + /** Locale-aware formatter from the date picker api. */ + format: (value: DateValue, options?: Intl.DateTimeFormatOptions) => string + + formatOptions?: Intl.DateTimeFormatOptions + + moreLabel?: (count: number) => string + + placeholder?: string + + rangePlaceholder?: [string, string] + + selectionMode: DatePickerSelectionMode + + value: (DateValue | null)[] +} + +/** + * Builds the human readable headline text for the current selection. + */ +export function getDatePickerHeadlineValueText({ + format, + formatOptions, + moreLabel = (count) => `+${count} more`, + placeholder = "Select date", + rangePlaceholder = ["Start", "End"], + selectionMode, + value, +}: DatePickerHeadlineValueOptions): string { + if (selectionMode === "range") { + const options = formatOptions ?? datePickerHeadlineRangeFormat + const [start, end] = value + const startText = start ? format(start, options) : rangePlaceholder[0] + const endText = end ? format(end, options) : rangePlaceholder[1] + return `${startText} - ${endText}` + } + + if (selectionMode === "multiple") { + const dates = value.filter((date): date is DateValue => date != null) + if (dates.length === 0) { + return placeholder + } + const options = formatOptions ?? datePickerHeadlineRangeFormat + if (dates.length <= 2) { + return dates.map((date) => format(date, options)).join(", ") + } + return `${format(dates[0], options)} ${moreLabel(dates.length - 1)}` + } + + const options = formatOptions ?? datePickerHeadlineFormat + return value[0] ? format(value[0], options) : placeholder +} diff --git a/packages/common/qds-core/src/date-picker/index.ts b/packages/common/qds-core/src/date-picker/index.ts new file mode 100644 index 000000000..a57957a70 --- /dev/null +++ b/packages/common/qds-core/src/date-picker/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +export * from "./date-picker.anatomy.js" +export * from "./date-picker.api.js" +export * from "./date-picker.classes.js" +export * from "./date-picker.utils.js" +export type * from "./date-picker.types.js" diff --git a/packages/common/qds-core/src/date-picker/qds-date-picker.css b/packages/common/qds-core/src/date-picker/qds-date-picker.css new file mode 100644 index 000000000..cd7684346 --- /dev/null +++ b/packages/common/qds-core/src/date-picker/qds-date-picker.css @@ -0,0 +1,573 @@ +.qui-date-picker__root { + display: flex; + flex-direction: column; +} + +.qui-date-picker__control[data-disabled] { + pointer-events: none; +} + +.qui-date-picker__control-group .qui-date-picker__input-group[data-size] { + min-width: min-content; + + .qui-date-picker__input { + min-width: 3ch; + } +} + +/* overload the shared input-group end padding */ +.qui-date-picker__control-group .qui-date-picker__input-group { + padding-inline-end: var(--spacing-70); + + &[data-size="sm"] { + padding-inline-end: var(--spacing-50); + } + + &[data-size="lg"] { + padding-inline-end: var(--spacing-80); + } +} + +/* the lg field keeps the md gap */ +.qui-date-picker__input-group[data-size="lg"] { + --input-group-gap: var(--spacing-40); +} + +.qui-date-picker__control-group .qui-date-picker__input-group[data-size]:has(.qui-date-picker__value-tags) { + align-items: center; + height: auto; + min-height: var(--sizing-140); + + &[data-size="sm"] { + min-height: var(--sizing-120); + } + + &[data-size="lg"] { + min-height: var(--sizing-160); + } +} + +/* field state lives on the input, not the group: bridge via :has */ + +.qui-date-picker__input-group:has(.qui-date-picker__input:focus) { + border-color: var(--color-utility-focus-border); + outline: solid 1px var(--color-utility-focus-border); +} + +.qui-date-picker__input-group:has(.qui-date-picker__input[data-invalid]):not( + :has(.qui-date-picker__input:disabled) +) { + border-color: var(--color-interactive-border-danger-idle); + outline: solid 1px var(--color-interactive-border-danger-idle); +} + +.qui-date-picker__input-group:has(.qui-date-picker__input:read-only) { + background: var(--color-background-neutral-02); + border-color: var(--color-utility-disabled-border); +} + +.qui-date-picker__input-group:has(.qui-date-picker__input:disabled) { + background: var(--color-utility-disabled-background); + border-color: var(--color-utility-disabled-border); + color: var(--color-utility-disabled-text); +} + +/* in multiple mode the field is the trigger, so state lives on it directly */ + +.qui-date-picker__input-group[role="combobox"] { + cursor: pointer; + + &:focus, + &[data-state="open"] { + border-color: var(--color-utility-focus-border); + outline: solid 1px var(--color-utility-focus-border); + } + + &[aria-invalid="true"]:not([data-state="open"], [data-disabled]) { + border-color: var(--color-interactive-border-danger-idle); + outline: solid 1px var(--color-interactive-border-danger-idle); + } + + &[data-readonly] { + background: var(--color-background-neutral-02); + border-color: var(--color-utility-disabled-border); + cursor: default; + } + + &[data-disabled] { + background: var(--color-utility-disabled-background); + border-color: var(--color-utility-disabled-border); + color: var(--color-utility-disabled-text); + cursor: default; + outline: none; + } +} + +.qui-date-picker__input-icon { + --icon-size: 16px; + + color: var(--color-icon-neutral-secondary); + flex: 0 0 auto; + pointer-events: none; +} + +.qui-date-picker__input-group[data-disabled] .qui-date-picker__input-icon { + color: var(--color-utility-disabled-text); +} + +.qui-date-picker__range-separator { + align-items: center; + align-self: stretch; + color: var(--color-text-neutral-secondary); + display: inline-flex; + padding-inline: var(--spacing-30); +} + +.qui-date-picker__control-group .qui-date-picker__input-group[data-size]:has(.qui-date-picker__range-separator) { + min-width: min-content; + + .qui-date-picker__input { + field-sizing: content; + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + min-width: 9ch; + width: auto; + } + + .qui-date-picker__range-separator + .qui-date-picker__input { + margin-inline-end: auto; + } +} + +.qui-date-picker__value-tags { + align-items: center; + column-gap: var(--spacing-70); + display: inline-flex; + flex: 1 1 auto; + flex-wrap: wrap; + font: var(--font-static-body-sm-default); + min-width: 0; + overflow: hidden; + padding-block: var(--spacing-30); + row-gap: var(--spacing-40); +} + +.qui-date-picker__input-group[data-size="sm"] .qui-date-picker__value-tags { + font: var(--font-static-body-xs-default); +} + +.qui-date-picker__input-group[data-size="lg"] .qui-date-picker__value-tags { + font: var(--font-static-body-md-default); +} + +.qui-date-picker__value-tags[data-empty] { + color: var(--color-text-neutral-secondary); +} + +.qui-date-picker__divider { + align-self: center; + background: var(--color-border-neutral-01); + display: none; + height: var(--sizing-70); + width: 1px; +} + +.qui-date-picker__clear-trigger:not([hidden])+.qui-date-picker__divider { + display: block; +} + +.qui-date-picker__input-group .qui-input__error-indicator { + align-self: center; +} + +.qui-date-picker__root> :is(.qui-input__hint, .qui-input__error-text) { + margin-block-start: var(--spacing-40); +} + +/* calendar pane */ + +.qui-date-picker__content { + background: var(--color-surface-overlay); + border: 1px solid var(--color-border-neutral-01); + border-radius: var(--border-radius-xl); + box-shadow: var(--shadow-medium); + inline-size: calc(7 * var(--sizing-120) + 2 * var(--spacing-100)); + z-index: max( + calc(var(--z-index-dropdown) + var(--layer-index)), + calc(var(--layer-z-floor, 0) + 1) + ); +} + +.qui-date-picker__content[data-variant="inline"] { + border: 0; + box-shadow: none; +} + +/* headline */ + +.qui-date-picker__headline { + border-block-end: 1px solid var(--color-border-neutral-01); + box-sizing: border-box; + display: flex; + flex-direction: column; + padding: var(--spacing-90) var(--spacing-100) var(--spacing-70) var(--spacing-120); + width: 100%; +} + +.qui-date-picker__headline-label { + color: var(--color-text-neutral-secondary); + font: var(--font-static-body-xs-default); +} + +.qui-date-picker__headline-value { + color: var(--color-text-neutral-primary); + font: var(--font-static-body-lg-bold); + overflow-wrap: break-word; +} + +/* header */ + +.qui-date-picker__view-control { + align-items: center; + display: grid; + grid-template-columns: auto auto 1fr auto auto; + padding: var(--spacing-100); + + .qui-date-picker__prev-trigger { + grid-column-start: 4; + } + + .qui-date-picker__next-trigger { + margin-inline-start: var(--spacing-40); + } +} + +.qui-date-picker__view-control:has(.qui-date-picker__presets-trigger, .qui-date-picker__view-close-trigger) { + grid-template-columns: auto auto 1fr auto auto 1fr auto; + + :is(.qui-date-picker__presets-trigger, .qui-date-picker__view-close-trigger) { + grid-column-start: 7; + margin-inline-end: var(--spacing-50); + } +} + +.qui-date-picker__prev-trigger .qui-button__icon, +.qui-date-picker__next-trigger .qui-button__icon { + --icon-height: var(--sizing-70); + --icon-width: var(--sizing-70); +} + +/* footer actions */ + +.qui-date-picker__actions { + border-block-start: 1px solid var(--color-border-neutral-01); + box-sizing: border-box; + display: flex; + gap: var(--spacing-70); + justify-content: flex-end; + padding: var(--spacing-80) var(--spacing-100); + width: 100%; +} + +/* presets panel */ + +.qui-date-picker__content[data-presets-open] .qui-date-picker__table { + display: none; +} + +.qui-date-picker__presets { + display: flex; + flex-direction: column; + gap: var(--spacing-20); + max-block-size: calc(7 * var(--sizing-120)); + overflow-y: auto; + padding: 0 var(--spacing-100) var(--spacing-100); +} + +/* triggers look like menu items */ +.qui-date-picker__preset-trigger { + align-content: center; + background: var(--color-interactive-background-ghost-idle); + border: 0; + color: var(--color-text-neutral-primary); + cursor: pointer; + display: block; + font: var(--font-static-body-xs-default); + min-block-size: var(--sizing-120); + min-width: 0; + overflow: hidden; + padding-inline: var(--spacing-90); + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + + &:hover { + background: var(--color-interactive-background-ghost-hover); + } + + &:active { + background: var(--color-interactive-background-ghost-pressed); + } + + &:focus-visible { + outline: var(--sizing-20) solid var(--color-utility-focus-border); + outline-offset: calc(-1 * var(--sizing-20)); + } +} + +/* day/month/year table */ + +.qui-date-picker__table { + border-collapse: collapse; + font: var(--font-static-body-xs-default); + margin: 0 var(--spacing-100) var(--spacing-100); + user-select: none; +} + +.qui-date-picker__table-header, +.qui-date-picker__table-cell { + box-sizing: border-box; + height: var(--sizing-120); + padding: var(--sizing-30); + width: var(--sizing-120); +} + +.qui-date-picker__table-cell { + /* positioned ancestor for the trigger's hit-area pseudo */ + position: relative; +} + +.qui-date-picker__table-cell:not([aria-disabled="true"]) { + cursor: pointer; +} + +.qui-date-picker__table-header { + color: var(--color-text-neutral-secondary); + font: var(--font-static-body-xs-default); +} + +.qui-date-picker__table-cell-trigger { + align-items: center; + border-radius: var(--border-radius-rounded); + box-sizing: border-box; + color: var(--color-text-neutral-primary); + display: flex; + font: var(--font-static-body-xs-bold); + height: var(--sizing-90); + justify-content: center; + text-box: cap alphabetic; + width: var(--sizing-90); + + /* stretch hitbox to the parent */ + &::after { + content: ""; + inset: 0; + position: absolute; + } + + &[data-selectable] { + &:hover { + background: var(--color-interactive-background-ghost-hover); + } + + &:active { + background: var(--color-interactive-background-ghost-pressed); + } + + &:hover[data-selected] { + background: var(--color-interactive-background-primary-hover); + } + + &:active[data-selected] { + background: var(--color-interactive-background-primary-pressed); + } + } + + &:focus-visible { + outline: var(--sizing-20) solid var(--color-utility-focus-border); + outline-offset: var(--spacing-10); + } + + &[data-outside-range] { + color: var(--color-text-neutral-secondary); + } + + &[data-today] { + border: var(--border-width-sm) solid var(--color-border-brand-primary); + } + + &[data-selected] { + animation: qui-date-picker--selected-pop var(--animation-duration-fast) ease-out; + background: var(--color-interactive-background-primary-idle); + color: var(--color-utility-persistent-white); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } + } + + &[data-disabled] { + color: var(--color-utility-disabled-text); + + &[data-today] { + border: var(--border-width-sm) solid var(--color-utility-disabled-border); + } + + &[data-selected] { + background: var(--color-utility-disabled-background-solid); + border: 0; + color: var(--color-utility-persistent-white); + } + } +} + +/* hide days from adjacent months when hideOutsideDays is set */ +.qui-date-picker__root[data-hide-outside-days] .qui-date-picker__table-cell-trigger[data-view="day"][data-outside-range] { + visibility: hidden; +} + +@keyframes qui-date-picker--selected-pop { + 0% { + transform: scale(0.85); + } + + 60% { + transform: scale(1.05); + } + + 100% { + transform: scale(1); + } +} + +/* month + year views use grid */ + +.qui-date-picker__table[data-view="month"], +.qui-date-picker__table[data-view="year"] { + display: grid; + gap: var(--spacing-90); + grid-template-columns: repeat(var(--columns, 4), 1fr); + /* match the 7-column day grid width so the pane stays a fixed width */ + width: calc(7 * var(--sizing-120)); +} + +/* allow the cells to be direct grid items */ +:is(.qui-date-picker__table[data-view="month"], + .qui-date-picker__table[data-view="year"]) :is(.qui-date-picker__table-body, .qui-date-picker__table-row) { + display: contents; +} + +:is(.qui-date-picker__table[data-view="month"], + .qui-date-picker__table[data-view="year"]) .qui-date-picker__table-cell { + height: auto; + padding: 0; + width: auto; +} + +.qui-date-picker__table-cell-trigger[data-view="month"], +.qui-date-picker__table-cell-trigger[data-view="year"] { + border: var(--border-width-sm) solid var(--color-interactive-border-neutral-idle); + color: var(--color-interactive-text-neutral-idle); + font: var(--font-static-body-xs-default); + height: var(--sizing-80); + padding-inline: var(--spacing-70); + width: 100%; + + &[data-selected] { + border-color: transparent; + color: var(--color-utility-persistent-white); + } + + &[data-disabled] { + border-color: var(--color-utility-disabled-border); + color: var(--color-utility-disabled-text); + } +} + +/* range band */ + +.qui-date-picker__table[data-view="day"] .qui-date-picker__table-cell { + &:has([data-in-range]) { + isolation: isolate; + + &::before { + background: var(--color-background-brand-primary-subtle); + content: ""; + inset-block: var(--sizing-30); + inset-inline: 0; + position: absolute; + z-index: -1; + } + } + + &:has([data-in-range][data-disabled]:not([data-outside-range]))::before { + background: var(--color-utility-disabled-background); + } + + /* endpoints of a committed range fill only the half pointing into the range */ + &:has([data-range-start]:not([data-range-end]))::before { + inset-inline-start: 50%; + } + + &:has([data-range-end]:not([data-range-start]))::before { + inset-inline-end: 50%; + } + + /* endpoints of an in-progress range fill whichever half matches the direction */ + &:has([data-hover-range-start]:not([data-hover-range-end]))::before { + inset-inline: 50% 0; + } + + &:has([data-hover-range-end]:not([data-hover-range-start]))::before { + inset-inline: 0 50%; + } + + /* single cell (committed single date, or the anchor hovering itself): no band */ + &:has([data-range-start][data-range-end])::before, + &:has([data-hover-range-start][data-hover-range-end])::before { + content: none; + } + + /* multiline ranges need to extend to the card edges */ + &:first-child:has([data-in-range]):not( :has( :where([data-hover-range-start], + [data-range-start]:not([data-hover-range-end]))))::before { + inset-inline-start: calc(-1 * var(--spacing-100)); + } + + &:last-child:has([data-in-range]):not(:has(:where([data-range-end], [data-hover-range-end])))::before { + inset-inline-end: calc(-1 * var(--spacing-100)); + } + + /* endpoint with no day-circle (hover/hidden outside month) draws its own rounded cap */ + &:has([data-hover-range-end]:not(:hover):not([data-selected]))::before, + :where(.qui-date-picker__root[data-hide-outside-days]) &:has([data-outside-range][data-range-end]:not([data-range-start]))::before { + border-end-end-radius: var(--border-radius-rounded); + border-start-end-radius: var(--border-radius-rounded); + inset-inline-end: var(--sizing-30); + } + + &:has([data-hover-range-start]:not(:hover):not([data-selected]))::before, + :where(.qui-date-picker__root[data-hide-outside-days]) &:has([data-outside-range][data-range-start]:not([data-range-end]))::before { + border-end-start-radius: var(--border-radius-rounded); + border-start-start-radius: var(--border-radius-rounded); + inset-inline-start: var(--sizing-30); + } +} + +/* fix hover/active transparency showing the range band */ +.qui-date-picker__table-cell-trigger[data-view="day"][data-in-range][data-selectable]:not([data-selected]) { + &:hover { + background: + linear-gradient(var(--color-interactive-background-ghost-hover), + var(--color-interactive-background-ghost-hover)), + var(--color-surface-overlay); + } + + &:active { + background: + linear-gradient(var(--color-interactive-background-ghost-pressed), + var(--color-interactive-background-ghost-pressed)), + var(--color-surface-overlay); + } +} diff --git a/packages/common/qds-core/src/tag/tag.api.ts b/packages/common/qds-core/src/tag/tag.api.ts index 50b7f0632..dba92ea10 100644 --- a/packages/common/qds-core/src/tag/tag.api.ts +++ b/packages/common/qds-core/src/tag/tag.api.ts @@ -50,7 +50,7 @@ export function createQdsTagApi( getDismissButtonBindings(): QdsTagDismissButtonBindings { return normalize.button({ ...parts.dismissButton, - "aria-label": "Dismiss", + "aria-label": props.dismissLabel || "Dismiss", className: tagClasses.dismissButton, "data-disabled": booleanDataAttr(props.disabled), "data-size": size, diff --git a/packages/common/qds-core/src/tag/tag.types.ts b/packages/common/qds-core/src/tag/tag.types.ts index 18d6c80ba..880079bf0 100644 --- a/packages/common/qds-core/src/tag/tag.types.ts +++ b/packages/common/qds-core/src/tag/tag.types.ts @@ -57,6 +57,14 @@ export interface QdsTagApiProps { */ disabled?: boolean + /** + * Accessible label for the dismiss button. + * Only applicable when {@link variant} is `dismissable`. + * + * @default 'Dismiss' + */ + dismissLabel?: string + /** * Governs the color of the tag. * @default 'outline-brand' diff --git a/packages/common/utils/package.json b/packages/common/utils/package.json index 8d8eaf082..7a1208f10 100644 --- a/packages/common/utils/package.json +++ b/packages/common/utils/package.json @@ -38,6 +38,7 @@ "test:unit:ci": "cross-env NODE_OPTIONS='' vitest --run --pool=threads" }, "devDependencies": { + "@internationalized/date": "^3.6.4", "@qualcomm-ui/cli": "catalog:", "@qualcomm-ui/tsconfig": "catalog:", "@qualcomm-ui/vite": "catalog:", @@ -51,5 +52,8 @@ "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" + }, + "peerDependencies": { + "@internationalized/date": ">=3.6.4" } } diff --git a/packages/common/utils/src/date-utils/align.ts b/packages/common/utils/src/date-utils/align.ts new file mode 100644 index 000000000..65a172431 --- /dev/null +++ b/packages/common/utils/src/date-utils/align.ts @@ -0,0 +1,29 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {DateDuration, DateValue} from "@internationalized/date" + +import {alignCenter, alignEnd, alignStart} from "./constrain.js" +import type {DateAlignment} from "./types.js" + +export function alignDate( + date: DateValue, + alignment: DateAlignment, + duration: DateDuration, + locale: string, + min?: DateValue, + max?: DateValue, +): DateValue { + switch (alignment) { + case "start": + return alignStart(date, duration, locale, min, max) + case "end": + return alignEnd(date, duration, locale, min, max) + case "center": + default: + return alignCenter(date, duration, locale, min, max) + } +} diff --git a/packages/common/utils/src/date-utils/assertion.ts b/packages/common/utils/src/date-utils/assertion.ts new file mode 100644 index 000000000..45314eb20 --- /dev/null +++ b/packages/common/utils/src/date-utils/assertion.ts @@ -0,0 +1,90 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + type DateValue, + endOfMonth, + isSameDay, + startOfMonth, + toCalendarDateTime, +} from "@internationalized/date" + +import type {DateAvailableFn} from "./types.js" + +export function isDateEqual( + dateA: DateValue | null | undefined, + dateB?: DateValue | null, +): boolean { + if (dateA == null || dateB == null) { + return dateA === dateB + } + if (!("hour" in dateA) && !("hour" in dateB)) { + return isSameDay(dateA, dateB) + } + return toCalendarDateTime(dateA).compare(toCalendarDateTime(dateB)) === 0 +} + +export function isDateUnavailable( + date: DateValue | null, + isUnavailable: DateAvailableFn | undefined, + locale: string, + minValue?: DateValue | null, + maxValue?: DateValue | null, +): boolean { + if (!date) { + return false + } + if (isUnavailable?.(date, locale)) { + return true + } + return isDateOutsideRange(date, minValue, maxValue) +} + +export function isDateOutsideRange( + date: DateValue, + startDate?: DateValue | null, + endDate?: DateValue | null, +): boolean { + return ( + (startDate != null && date.compare(startDate) < 0) || + (endDate != null && date.compare(endDate) > 0) + ) +} + +export function isMonthOutsideRange( + date: DateValue, + startDate?: DateValue | null, + endDate?: DateValue | null, +): boolean { + return ( + (startDate != null && endOfMonth(date).compare(startDate) < 0) || + (endDate != null && startOfMonth(date).compare(endDate) > 0) + ) +} + +export function isPreviousRangeInvalid( + startDate: DateValue, + minValue?: DateValue | null, + maxValue?: DateValue | null, +): boolean { + const prevDate = startDate.subtract({days: 1}) + return ( + isSameDay(prevDate, startDate) || + isDateOutsideRange(prevDate, minValue, maxValue) + ) +} + +export function isNextRangeInvalid( + endDate: DateValue, + minValue?: DateValue | null, + maxValue?: DateValue | null, +): boolean { + const nextDate = endDate.add({days: 1}) + return ( + isSameDay(nextDate, endDate) || + isDateOutsideRange(nextDate, minValue, maxValue) + ) +} diff --git a/packages/common/utils/src/date-utils/constrain.ts b/packages/common/utils/src/date-utils/constrain.ts new file mode 100644 index 000000000..a439b6485 --- /dev/null +++ b/packages/common/utils/src/date-utils/constrain.ts @@ -0,0 +1,212 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + type DateDuration, + type DateValue, + maxDate, + minDate, + startOfMonth, + startOfWeek, + startOfYear, + toCalendarDate, +} from "@internationalized/date" + +/** Align date to start, end, or center of a duration */ +export function alignCenter( + date: DateValue, + duration: DateDuration, + locale: string, + min?: DateValue, + max?: DateValue, +): DateValue { + const halfDuration: DateDuration = {} + + for (const prop in duration) { + const key = prop as keyof DateDuration + + const value = duration[key] + if (value == null) { + continue + } + + halfDuration[key] = Math.floor(value / 2) + + if (halfDuration[key] > 0 && value % 2 === 0) { + halfDuration[key]-- + } + } + + const aligned = alignStart(date, duration, locale).subtract(halfDuration) + + return constrainStart(date, aligned, duration, locale, min, max) +} + +export function alignStart( + date: DateValue, + duration: DateDuration, + locale: string, + min?: DateValue, + max?: DateValue, +): DateValue { + // align to the start of the largest unit + let aligned = date + if (duration.years) { + aligned = startOfYear(date) + } else if (duration.months) { + aligned = startOfMonth(date) + } else if (duration.weeks) { + aligned = startOfWeek(date, locale) + } + + return constrainStart(date, aligned, duration, locale, min, max) +} + +export function alignEnd( + date: DateValue, + duration: DateDuration, + locale: string, + min?: DateValue, + max?: DateValue, +): DateValue { + const d: DateDuration = {...duration} + // subtract 1 from the smallest unit + if (d.days) { + d.days-- + } else if (d.weeks) { + d.weeks-- + } else if (d.months) { + d.months-- + } else if (d.years) { + d.years-- + } + + const aligned = alignStart(date, duration, locale).subtract(d) + return constrainStart(date, aligned, duration, locale, min, max) +} + +/** Constrain a date to a min/max range */ +export function constrainStart( + date: DateValue, + aligned: DateValue, + duration: DateDuration, + locale: string, + min?: DateValue, + max?: DateValue, +): DateValue { + if (min && date.compare(min) >= 0) { + // Ensure consistent date types by converting min to CalendarDate for alignment + // operations. This prevents time-component comparison issues in alignment + // calculations. + aligned = maxDate( + aligned, + alignStart(toCalendarDate(min), duration, locale), + )! + } + + if (max && date.compare(max) <= 0) { + // Ensure consistent date types by converting max to CalendarDate for alignment + // operations. This prevents time-component comparison issues in alignment + // calculations. + aligned = minDate(aligned, alignEnd(toCalendarDate(max), duration, locale))! + } + + return aligned +} + +export function constrainValue( + date: T, + minValue?: DateValue, + maxValue?: DateValue, +): T { + // Convert to CalendarDate for consistent date-only comparison + const dateOnly = toCalendarDate(date) + const minOnly = minValue ? toCalendarDate(minValue) : undefined + const maxOnly = maxValue ? toCalendarDate(maxValue) : undefined + + // Determine if date needs adjustment + let constrainedDateOnly = dateOnly + + if (minOnly) { + constrainedDateOnly = maxDate(constrainedDateOnly, minOnly)! + } + + if (maxOnly) { + constrainedDateOnly = minDate(constrainedDateOnly, maxOnly)! + } + + // If date didn't change, return original to preserve time components + if (constrainedDateOnly.compare(dateOnly) === 0) { + return date + } + + // Date changed - apply the date portion while preserving time if present. + // Check if original date has time components (CalendarDateTime or ZonedDateTime) + if ("hour" in date) { + return date.set({ + day: constrainedDateOnly.day, + month: constrainedDateOnly.month, + year: constrainedDateOnly.year, + }) as T + } + + // Original was CalendarDate, return the constrained CalendarDate + return constrainedDateOnly as T +} + +/** + * Clamp only the out-of-range date segments, preserving later segments when + * possible. + */ +export function constrainSegments( + date: T, + minValue?: DateValue, + maxValue?: DateValue, +): T { + const dateOnly = toCalendarDate(date) + const minOnly = minValue ? toCalendarDate(minValue) : undefined + const maxOnly = maxValue ? toCalendarDate(maxValue) : undefined + + let result = dateOnly + + if (minOnly && result.compare(minOnly) < 0) { + if (result.year < minOnly.year) { + result = result.set({year: minOnly.year}) + } + if (result.compare(minOnly) < 0 && result.month < minOnly.month) { + result = result.set({month: minOnly.month}) + } + if (result.compare(minOnly) < 0 && result.day < minOnly.day) { + result = result.set({day: minOnly.day}) + } + } + + if (maxOnly && result.compare(maxOnly) > 0) { + if (result.year > maxOnly.year) { + result = result.set({year: maxOnly.year}) + } + if (result.compare(maxOnly) > 0 && result.month > maxOnly.month) { + result = result.set({month: maxOnly.month}) + } + if (result.compare(maxOnly) > 0 && result.day > maxOnly.day) { + result = result.set({day: maxOnly.day}) + } + } + + if (result.compare(dateOnly) === 0) { + return date + } + + if ("hour" in date) { + return date.set({ + day: result.day, + month: result.month, + year: result.year, + }) as T + } + + return result as T +} diff --git a/packages/common/utils/src/date-utils/date-month.ts b/packages/common/utils/src/date-utils/date-month.ts new file mode 100644 index 000000000..acbd2f1f5 --- /dev/null +++ b/packages/common/utils/src/date-utils/date-month.ts @@ -0,0 +1,157 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + DateFormatter, + type DateValue, + endOfWeek, + getWeeksInMonth, + isSameDay, + startOfWeek, +} from "@internationalized/date" + +const daysOfTheWeek = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const +type DaysOfTheWeek = (typeof daysOfTheWeek)[number] +function normalizeFirstDayOfWeek( + firstDayOfWeek?: number, +): DaysOfTheWeek | undefined { + return firstDayOfWeek != null ? daysOfTheWeek[firstDayOfWeek] : undefined +} + +export function getStartOfWeek( + date: DateValue, + locale: string, + firstDayOfWeek?: number, +): DateValue { + const firstDay = normalizeFirstDayOfWeek(firstDayOfWeek) + return startOfWeek(date, locale, firstDay) +} + +export function getEndOfWeek( + date: DateValue, + locale: string, + firstDayOfWeek?: number, +): DateValue { + const firstDay = normalizeFirstDayOfWeek(firstDayOfWeek) + return endOfWeek(date, locale, firstDay) +} + +export function getDaysInWeek( + weekIndex: number, + from: DateValue, + locale: string, + firstDayOfWeek?: number, +): DateValue[] { + const weekDate = from.add({weeks: weekIndex}) + const dates: DateValue[] = [] + let date = getStartOfWeek(weekDate, locale, firstDayOfWeek) + while (dates.length < 7) { + dates.push(date) + const nextDate = date.add({days: 1}) + if (isSameDay(date, nextDate)) { + break + } + date = nextDate + } + return dates +} + +export function getMonthDays( + from: DateValue, + locale: string, + numOfWeeks?: number, + firstDayOfWeek?: number, +): DateValue[][] { + const firstDay = normalizeFirstDayOfWeek(firstDayOfWeek) + const monthWeeks = numOfWeeks ?? getWeeksInMonth(from, locale, firstDay) + const weeks = [...new Array(monthWeeks).keys()] + return weeks.map((week) => getDaysInWeek(week, from, locale, firstDayOfWeek)) +} + +export function getWeekdayFormats(locale: string, timeZone: string) { + const longFormat = new DateFormatter(locale, {timeZone, weekday: "long"}) + const shortFormat = new DateFormatter(locale, {timeZone, weekday: "short"}) + const narrowFormat = new DateFormatter(locale, {timeZone, weekday: "narrow"}) + return ( + value: T, + ): { + long: string + narrow: string + short: string + value: T + } => { + const date = value instanceof Date ? value : value.toDate(timeZone) + return { + long: longFormat.format(date), + narrow: narrowFormat.format(date), + short: shortFormat.format(date), + value, + } + } +} + +export function getWeekDays( + date: DateValue, + startOfWeekProp: number | undefined, + timeZone: string, + locale: string, +): {long: string; narrow: string; short: string; value: DateValue}[] { + const firstDayOfWeek = getStartOfWeek(date, locale, startOfWeekProp) + const weeks = [...new Array(7).keys()] + const format = getWeekdayFormats(locale, timeZone) + return weeks.map((index) => format(firstDayOfWeek.add({days: index}))) +} + +export function getMonthNames( + locale: string, + format: Intl.DateTimeFormatOptions["month"] = "long", + referenceDate?: DateValue, +): string[] { + if ( + !referenceDate || + referenceDate.calendar.identifier === "gregory" || + referenceDate.calendar.identifier === "iso8601" + ) { + const date = new Date(2021, 0, 1) + const monthNames: string[] = [] + for (let i = 0; i < 12; i++) { + monthNames.push(date.toLocaleString(locale, {month: format})) + date.setMonth(date.getMonth() + 1) + } + return monthNames + } + + const monthCount = referenceDate.calendar.getMonthsInYear(referenceDate) + const formatter = new DateFormatter(locale, { + calendar: referenceDate.calendar.identifier, + month: format, + }) + const monthNames: string[] = [] + for (let month = 1; month <= monthCount; month++) { + const d = referenceDate.set({month}) + monthNames.push(formatter.format(d.toDate("UTC"))) + } + return monthNames +} + +export function getWeekOfYear(date: DateValue, locale: string): number { + const mondayOfWeek = startOfWeek(date, locale, "mon") + const year = mondayOfWeek.year + const jan4 = mondayOfWeek.set({day: 4, month: 1}) + const week1Monday = startOfWeek(jan4, locale, "mon") + + const julianMonday = mondayOfWeek.calendar.toJulianDay(mondayOfWeek) + const julianWeek1 = week1Monday.calendar.toJulianDay(week1Monday) + + if (julianMonday >= julianWeek1) { + return 1 + Math.floor((julianMonday - julianWeek1) / 7) + } + + const prevJan4 = mondayOfWeek.set({day: 4, month: 1, year: year - 1}) + const prevWeek1Monday = startOfWeek(prevJan4, locale, "mon") + const julianPrevWeek1 = prevWeek1Monday.calendar.toJulianDay(prevWeek1Monday) + return 1 + Math.floor((julianMonday - julianPrevWeek1) / 7) +} diff --git a/packages/common/utils/src/date-utils/date-year.ts b/packages/common/utils/src/date-utils/date-year.ts new file mode 100644 index 000000000..768d03cb8 --- /dev/null +++ b/packages/common/utils/src/date-utils/date-year.ts @@ -0,0 +1,74 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {CalendarDate, type DateValue, toCalendar} from "@internationalized/date" + +export interface YearsRange { + from: number + to: number +} +export function getYearsRange(range: YearsRange): number[] { + const years: number[] = [] + for (let year = range.from; year <= range.to; year += 1) { + years.push(year) + } + return years +} + +const DEFAULT_MIN_YEAR = 1900 +const DEFAULT_MAX_YEAR = 2099 + +export function getDefaultYearRange( + referenceDate: DateValue, + min?: DateValue, + max?: DateValue, +): YearsRange { + const calendar = referenceDate.calendar + const fromYear = + min?.year ?? + toCalendar(new CalendarDate(DEFAULT_MIN_YEAR, 1, 1), calendar).year + const toYear = + max?.year ?? + toCalendar(new CalendarDate(DEFAULT_MAX_YEAR, 12, 31), calendar).year + return {from: fromYear, to: toYear} +} + +const FUTURE_YEAR_COERCION = 10 + +export function normalizeYear( + year: string | null | undefined, +): string | undefined { + if (!year) { + return + } + if (year.length === 3) { + return year.padEnd(4, "0") + } + if (year.length === 2) { + const currentYear = new Date().getFullYear() + const currentCentury = Math.floor(currentYear / 100) * 100 + const twoDigitYear = parseInt(year.slice(-2), 10) + const fullYear = currentCentury + twoDigitYear + return fullYear > currentYear + FUTURE_YEAR_COERCION + ? (fullYear - 100).toString() + : fullYear.toString() + } + return year +} + +export function getDecadeRange( + year: number, + opts?: {strict?: boolean}, +): number[] { + const chunkSize = opts?.strict ? 10 : 12 + const computedYear = year - (year % 10) + const years: number[] = [] + for (let i = 0; i < chunkSize; i += 1) { + const value = computedYear + i + years.push(value) + } + return years +} diff --git a/packages/common/utils/src/date-utils/duration.ts b/packages/common/utils/src/date-utils/duration.ts new file mode 100644 index 000000000..d73aff8cc --- /dev/null +++ b/packages/common/utils/src/date-utils/duration.ts @@ -0,0 +1,28 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {DateDuration, DateValue} from "@internationalized/date" + +export function getUnitDuration(duration: DateDuration): DateDuration { + const clone = {...duration} + for (const key in clone) { + clone[key as keyof typeof clone] = 1 + } + return clone +} + +export function getEndDate( + startDate: DateValue, + duration: DateDuration, +): DateValue { + const clone = {...duration} + if (clone.days) { + clone.days-- + } else { + clone.days = -1 + } + return startDate.add(clone) +} diff --git a/packages/common/utils/src/date-utils/format.ts b/packages/common/utils/src/date-utils/format.ts new file mode 100644 index 000000000..da1bd89fa --- /dev/null +++ b/packages/common/utils/src/date-utils/format.ts @@ -0,0 +1,69 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + type DateFormatter, + type DateValue, + isSameDay, +} from "@internationalized/date" + +import {getDayFormatter} from "./formatter.js" + +export function formatRange( + startDate: DateValue, + endDate: DateValue, + formatter: DateFormatter, + toString: (start: string, end: string) => string, + timeZone: string, +): string { + const parts = formatter.formatRangeToParts( + startDate.toDate(timeZone), + endDate.toDate(timeZone), + ) + let separatorIndex = -1 + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (part.source === "shared" && part.type === "literal") { + separatorIndex = i + } else if (part.source === "endRange") { + break + } + } + let start = "" + let end = "" + for (let i = 0; i < parts.length; i++) { + if (i < separatorIndex) { + start += parts[i].value + } else if (i > separatorIndex) { + end += parts[i].value + } + } + return toString(start, end) +} + +export function formatSelectedDate( + startDate: DateValue | null | undefined, + endDate: DateValue | null, + locale: string, + timeZone: string, +): string { + if (!startDate) { + return "" + } + const start = startDate + const end = endDate ?? startDate + const formatter = getDayFormatter(locale, timeZone) + if (isSameDay(start, end)) { + return formatter.format(start.toDate(timeZone)) + } + return formatRange( + start, + end, + formatter, + (start, end) => `${start} - ${end}`, + timeZone, + ) +} diff --git a/packages/common/utils/src/date-utils/formatter.ts b/packages/common/utils/src/date-utils/formatter.ts new file mode 100644 index 000000000..3d3462a82 --- /dev/null +++ b/packages/common/utils/src/date-utils/formatter.ts @@ -0,0 +1,46 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + DateFormatter, + type DateValue, + toCalendarDateTime, + today, +} from "@internationalized/date" + +import {getEraFormat} from "./get-era-format.js" + +export function getDayFormatter( + locale: string, + timeZone: string, + referenceDate?: DateValue, +): DateFormatter { + const date = referenceDate ?? toCalendarDateTime(today(timeZone)) + return new DateFormatter(locale, { + calendar: date.calendar.identifier, + day: "numeric", + era: getEraFormat(date), + month: "long", + timeZone, + weekday: "long", + year: "numeric", + }) +} + +export function getMonthFormatter( + locale: string, + timeZone: string, + referenceDate?: DateValue, +): DateFormatter { + const date = referenceDate ?? today(timeZone) + return new DateFormatter(locale, { + calendar: date.calendar.identifier, + era: getEraFormat(date), + month: "long", + timeZone, + year: "numeric", + }) +} diff --git a/packages/common/utils/src/date-utils/get-era-format.ts b/packages/common/utils/src/date-utils/get-era-format.ts new file mode 100644 index 000000000..c352aa20a --- /dev/null +++ b/packages/common/utils/src/date-utils/get-era-format.ts @@ -0,0 +1,18 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {DateValue} from "@internationalized/date" + +export function getEraFormat(date: DateValue | undefined): "short" | undefined { + if (!date) { + return undefined + } + const id = date.calendar.identifier + if (id === "gregory" || id === "iso8601") { + return date.era === "BC" ? "short" : undefined + } + return "short" +} diff --git a/packages/common/utils/src/date-utils/index.ts b/packages/common/utils/src/date-utils/index.ts new file mode 100644 index 000000000..a04a127d9 --- /dev/null +++ b/packages/common/utils/src/date-utils/index.ts @@ -0,0 +1,22 @@ +export { + CalendarDate, + getLocalTimeZone, + isWeekend, + parseDate, + today, + type DateValue, +} from "@internationalized/date" +export * from "./align.js" +export * from "./assertion.js" +export * from "./constrain.js" +export * from "./duration.js" +export * from "./format.js" +export * from "./formatter.js" +export * from "./date-month.js" +export * from "./date-year.js" +export * from "./locale.js" +export * from "./mutation.js" +export * from "./pagination.js" +export * from "./parse-date.js" +export * from "./preset.js" +export type {DateAdjustFn, DateGranularity, DateRangePreset} from "./types.js" diff --git a/packages/common/utils/src/date-utils/locale.ts b/packages/common/utils/src/date-utils/locale.ts new file mode 100644 index 000000000..0f07451e3 --- /dev/null +++ b/packages/common/utils/src/date-utils/locale.ts @@ -0,0 +1,39 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +export const isValidCharacter = ( + char: string | null, + separator: string, +): boolean => { + if (!char) { + return true + } + return /\d/.test(char) || separator.includes(char) || char.length !== 1 +} + +export const ensureValidCharacters = ( + value: string, + separator: string, +): string => { + return value + .split("") + .filter((char) => isValidCharacter(char, separator)) + .join("") +} + +const separatorCache = new Map() + +export function getLocaleSeparator(locale: string): string { + let separator = separatorCache.get(locale) + if (separator != null) { + return separator + } + const parts = new Intl.DateTimeFormat(locale).formatToParts(new Date()) + const literal = parts.find((part) => part.type === "literal") + separator = literal ? literal.value : "/" + separatorCache.set(locale, separator) + return separator +} diff --git a/packages/common/utils/src/date-utils/mutation.ts b/packages/common/utils/src/date-utils/mutation.ts new file mode 100644 index 000000000..771646ea4 --- /dev/null +++ b/packages/common/utils/src/date-utils/mutation.ts @@ -0,0 +1,63 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + type Calendar, + type DateValue, + getLocalTimeZone, + toCalendar, + toCalendarDateTime, + today, +} from "@internationalized/date" + +import {constrainValue} from "./constrain.js" +import type {DateAvailableFn} from "./types.js" + +export function getTodayDate( + timeZone?: string, + calendar?: Calendar, +): DateValue { + const tod = today(timeZone ?? getLocalTimeZone()) + if (calendar) { + return toCalendar(tod, calendar) + } + return tod +} + +export function setCalendar(date: DateValue, calendar: Calendar): DateValue { + return toCalendar(toCalendarDateTime(date), calendar) +} + +export function setDate( + date: DateValue, + startDate: DateValue, + isDateUnavailable: DateAvailableFn, + locale: string, + minValue: DateValue, + maxValue: DateValue, +): DateValue | undefined { + const result = constrainValue(date, minValue, maxValue) + return getPreviousAvailableDate(result, startDate, locale, isDateUnavailable) +} + +export function getPreviousAvailableDate( + date: DateValue, + minValue: DateValue, + locale: string, + isDateUnavailable?: DateAvailableFn, +): DateValue | undefined { + if (!isDateUnavailable) { + return date + } + + while (date.compare(minValue) >= 0 && isDateUnavailable(date, locale)) { + date = date.subtract({days: 1}) + } + + if (date.compare(minValue) >= 0) { + return date + } +} diff --git a/packages/common/utils/src/date-utils/pagination.ts b/packages/common/utils/src/date-utils/pagination.ts new file mode 100644 index 000000000..51d9ed0cd --- /dev/null +++ b/packages/common/utils/src/date-utils/pagination.ts @@ -0,0 +1,366 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + type DateDuration, + type DateValue, + endOfMonth, + endOfWeek, + startOfMonth, + startOfWeek, +} from "@internationalized/date" + +import {isDateOutsideRange} from "./assertion.js" +import { + alignEnd, + alignStart, + constrainStart, + constrainValue, +} from "./constrain.js" +import {getEndDate, getUnitDuration} from "./duration.js" + +export interface AdjustDateParams { + focusedDate: DateValue + startDate: DateValue +} + +export interface AdjustDateReturn extends AdjustDateParams { + endDate: DateValue +} + +export function getAdjustedDateFn( + visibleDuration: DateDuration, + locale: string, + minValue?: DateValue, + maxValue?: DateValue, +) { + return function getDate(options: AdjustDateParams): AdjustDateReturn { + const {focusedDate, startDate} = options + const endDate = getEndDate(startDate, visibleDuration) + + // If the focused date was moved to an invalid value, it can't be focused, so + // constrain it. + if (isDateOutsideRange(focusedDate, minValue, maxValue)) { + return { + endDate, + focusedDate: constrainValue(focusedDate, minValue, maxValue), + startDate, + } + } + + if (focusedDate.compare(startDate) < 0) { + return { + endDate, + focusedDate: constrainValue(focusedDate, minValue, maxValue), + startDate: alignEnd( + focusedDate, + visibleDuration, + locale, + minValue, + maxValue, + ), + } + } + + if (focusedDate.compare(endDate) > 0) { + return { + endDate, + focusedDate: constrainValue(focusedDate, minValue, maxValue), + startDate: alignStart( + focusedDate, + visibleDuration, + locale, + minValue, + maxValue, + ), + } + } + + return { + endDate, + focusedDate: constrainValue(focusedDate, minValue, maxValue), + startDate, + } + } +} + +/** Get next and previous page (for date range) */ +export function getNextPage( + focusedDate: DateValue, + startDate: DateValue, + visibleDuration: DateDuration, + locale: string, + minValue?: DateValue, + maxValue?: DateValue, +): AdjustDateReturn { + const adjust = getAdjustedDateFn(visibleDuration, locale, minValue, maxValue) + const start = startDate.add(visibleDuration) + + return adjust({ + focusedDate: focusedDate.add(visibleDuration), + startDate: alignStart( + constrainStart( + focusedDate, + start, + visibleDuration, + locale, + minValue, + maxValue, + ), + visibleDuration, + locale, + ), + }) +} + +export function getPreviousPage( + focusedDate: DateValue, + startDate: DateValue, + visibleDuration: DateDuration, + locale: string, + minValue?: DateValue, + maxValue?: DateValue, +): AdjustDateReturn { + const adjust = getAdjustedDateFn(visibleDuration, locale, minValue, maxValue) + const start = startDate.subtract(visibleDuration) + + return adjust({ + focusedDate: focusedDate.subtract(visibleDuration), + startDate: alignStart( + constrainStart( + focusedDate, + start, + visibleDuration, + locale, + minValue, + maxValue, + ), + visibleDuration, + locale, + ), + }) +} + +/** Get the next and previous row (for date range) */ +export function getNextRow( + focusedDate: DateValue, + startDate: DateValue, + visibleDuration: DateDuration, + locale: string, + minValue?: DateValue, + maxValue?: DateValue, +): AdjustDateReturn | undefined { + const adjust = getAdjustedDateFn(visibleDuration, locale, minValue, maxValue) + + if (visibleDuration.days) { + return getNextPage( + focusedDate, + startDate, + visibleDuration, + locale, + minValue, + maxValue, + ) + } + + if ( + visibleDuration.weeks || + visibleDuration.months || + visibleDuration.years + ) { + return adjust({ + focusedDate: focusedDate.add({weeks: 1}), + startDate, + }) + } +} + +export function getPreviousRow( + focusedDate: DateValue, + startDate: DateValue, + visibleDuration: DateDuration, + locale: string, + minValue?: DateValue, + maxValue?: DateValue, +): AdjustDateReturn | undefined { + const adjust = getAdjustedDateFn(visibleDuration, locale, minValue, maxValue) + + if (visibleDuration.days) { + return getPreviousPage( + focusedDate, + startDate, + visibleDuration, + locale, + minValue, + maxValue, + ) + } + + if ( + visibleDuration.weeks || + visibleDuration.months || + visibleDuration.years + ) { + return adjust({ + focusedDate: focusedDate.subtract({weeks: 1}), + startDate, + }) + } +} + +/** Get start and end date for a date section */ +export function getSectionStart( + focusedDate: DateValue, + startDate: DateValue, + visibleDuration: DateDuration, + locale: string, + minValue?: DateValue, + maxValue?: DateValue, +): AdjustDateReturn | undefined { + const adjust = getAdjustedDateFn(visibleDuration, locale, minValue, maxValue) + + if (visibleDuration.days) { + return adjust({ + focusedDate: startDate, + startDate, + }) + } + + if (visibleDuration.weeks) { + return adjust({ + focusedDate: startOfWeek(focusedDate, locale), + startDate, + }) + } + + if (visibleDuration.months || visibleDuration.years) { + return adjust({ + focusedDate: startOfMonth(focusedDate), + startDate, + }) + } +} + +export function getSectionEnd( + focusedDate: DateValue, + startDate: DateValue, + visibleDuration: DateDuration, + locale: string, + minValue?: DateValue, + maxValue?: DateValue, +): AdjustDateReturn | undefined { + const adjust = getAdjustedDateFn(visibleDuration, locale, minValue, maxValue) + const endDate = getEndDate(startDate, visibleDuration) + + if (visibleDuration.days) { + return adjust({ + focusedDate: endDate, + startDate, + }) + } + + if (visibleDuration.weeks) { + return adjust({ + focusedDate: endOfWeek(focusedDate, locale), + startDate, + }) + } + + if (visibleDuration.months || visibleDuration.years) { + return adjust({ + focusedDate: endOfMonth(focusedDate), + startDate, + }) + } +} + +export function getNextSection( + focusedDate: DateValue, + startDate: DateValue, + larger: boolean, + visibleDuration: DateDuration, + locale: string, + minValue?: DateValue, + maxValue?: DateValue, +): AdjustDateReturn | undefined { + const adjust = getAdjustedDateFn(visibleDuration, locale, minValue, maxValue) + + if (!larger && !visibleDuration.days) { + return adjust({ + focusedDate: focusedDate.add(getUnitDuration(visibleDuration)), + startDate, + }) + } + + if (visibleDuration.days) { + return getNextPage( + focusedDate, + startDate, + visibleDuration, + locale, + minValue, + maxValue, + ) + } + + if (visibleDuration.weeks) { + return adjust({ + focusedDate: focusedDate.add({months: 1}), + startDate, + }) + } + + if (visibleDuration.months || visibleDuration.years) { + return adjust({ + focusedDate: focusedDate.add({years: 1}), + startDate, + }) + } +} + +export function getPreviousSection( + focusedDate: DateValue, + startDate: DateValue, + larger: boolean, + visibleDuration: DateDuration, + locale: string, + minValue?: DateValue, + maxValue?: DateValue, +): AdjustDateReturn | undefined { + const adjust = getAdjustedDateFn(visibleDuration, locale, minValue, maxValue) + + if (!larger && !visibleDuration.days) { + return adjust({ + focusedDate: focusedDate.subtract(getUnitDuration(visibleDuration)), + startDate, + }) + } + + if (visibleDuration.days) { + return getPreviousPage( + focusedDate, + startDate, + visibleDuration, + locale, + minValue, + maxValue, + ) + } + + if (visibleDuration.weeks) { + return adjust({ + focusedDate: focusedDate.subtract({months: 1}), + startDate, + }) + } + + if (visibleDuration.months || visibleDuration.years) { + return adjust({ + focusedDate: focusedDate.subtract({years: 1}), + startDate, + }) + } +} diff --git a/packages/common/utils/src/date-utils/parse-date.ts b/packages/common/utils/src/date-utils/parse-date.ts new file mode 100644 index 000000000..5874e389f --- /dev/null +++ b/packages/common/utils/src/date-utils/parse-date.ts @@ -0,0 +1,99 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + CalendarDate, + DateFormatter, + type DateValue, +} from "@internationalized/date" + +import {normalizeYear} from "./date-year.js" + +const isValidYear = (year: string | null | undefined): year is string => + year != null && year.length === 4 +const isValidMonth = (month: string | null | undefined): month is string => + month != null && parseFloat(month) <= 12 +const isValidDay = (day: string | null | undefined): day is string => + day != null && parseFloat(day) <= 31 + +export function parseDateString( + date: string, + locale: string, + timeZone: string, +): DateValue | undefined { + const regex = createRegex(locale, timeZone) + + let {day, month, year} = extract(regex, date) ?? {} + + const hasMatch = year != null || month != null || day != null + + if (hasMatch) { + const curr = new Date() + year ||= curr.getFullYear().toString() + month ||= (curr.getMonth() + 1).toString() + day ||= curr.getDate().toString() + } + + if (!isValidYear(year)) { + year = normalizeYear(year) + } + + if (isValidYear(year) && isValidMonth(month) && isValidDay(day)) { + const parsed = new CalendarDate(+year, +month, +day) + // Reject impossible dates rather than keep the coerced ones. + if (parsed.month === +month && parsed.day === +day) { + return parsed + } + } +} + +function createRegex(locale: string, timeZone: string) { + const formatter = new DateFormatter(locale, { + day: "numeric", + month: "numeric", + timeZone, + year: "numeric", + }) + const parts = formatter.formatToParts(new Date(2000, 11, 25)) + return parts + .map(({type, value}) => + type === "literal" ? `${value}?` : `((?!=<${type}>)\\d+)?`, + ) + .join("") +} + +interface DateParts { + day: string | null + month: string | null + year: string | null +} + +type DatePart = keyof DateParts + +function extract(pattern: string | RegExp, str: string) { + const matches = str.match(pattern) + return pattern + .toString() + .match(/<(.+?)>/g) + ?.map((group) => { + const groupMatches = group.match(/<(.+)>/) + if (!groupMatches || groupMatches.length <= 0) { + return null + } + return group.match(/<(.+)>/)?.[1] + }) + .reduce((acc, curr, index) => { + if (!curr) { + return acc + } + if (matches && matches.length > index) { + acc[curr as DatePart] = matches[index + 1] + } else { + acc[curr as DatePart] = null + } + return acc + }, {} as DateParts) +} diff --git a/packages/common/utils/src/date-utils/preset.ts b/packages/common/utils/src/date-utils/preset.ts new file mode 100644 index 000000000..09394f7d7 --- /dev/null +++ b/packages/common/utils/src/date-utils/preset.ts @@ -0,0 +1,106 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + type DateValue, + endOfMonth, + endOfWeek, + endOfYear, + now, + startOfMonth, + startOfWeek, + startOfYear, + toCalendarDate, +} from "@internationalized/date" + +import type {DateRangePreset} from "./types.js" + +export function getDateRangePreset( + preset: DateRangePreset, + locale: string, + timeZone: string, +): [DateValue, DateValue] { + // Convert ZonedDateTime to CalendarDate to ensure consistent date type without + // time components. This prevents issues with mixed date types in comparisons and + // range operations. + const today = toCalendarDate(now(timeZone)) + + switch (preset) { + case "thisWeek": + return [startOfWeek(today, locale), endOfWeek(today, locale)] + case "thisMonth": + return [startOfMonth(today), today] + case "thisQuarter": + return [ + startOfMonth(today).add({months: -((today.month - 1) % 3)}), + today, + ] + case "thisYear": + return [startOfYear(today), today] + case "last3Days": + return [today.add({days: -2}), today] + case "last7Days": + return [today.add({days: -6}), today] + case "last14Days": + return [today.add({days: -13}), today] + case "last30Days": + return [today.add({days: -29}), today] + case "last90Days": + return [today.add({days: -89}), today] + case "lastMonth": + return [ + startOfMonth(today.add({months: -1})), + endOfMonth(today.add({months: -1})), + ] + case "lastQuarter": + return [ + startOfMonth(today.add({months: -((today.month - 1) % 3) - 3})), + endOfMonth(today.add({months: -((today.month - 1) % 3) - 1})), + ] + case "lastWeek": + return [ + startOfWeek(today, locale).add({weeks: -1}), + endOfWeek(today, locale).add({weeks: -1}), + ] + case "lastYear": + return [ + startOfYear(today.add({years: -1})), + endOfYear(today.add({years: -1})), + ] + case "next3Days": + return [today, today.add({days: 2})] + case "next7Days": + return [today, today.add({days: 6})] + case "next14Days": + return [today, today.add({days: 13})] + case "next30Days": + return [today, today.add({days: 29})] + case "next90Days": + return [today, today.add({days: 89})] + case "nextMonth": + return [ + startOfMonth(today.add({months: 1})), + endOfMonth(today.add({months: 1})), + ] + case "nextQuarter": + return [ + startOfMonth(today.add({months: -((today.month - 1) % 3) + 3})), + endOfMonth(today.add({months: -((today.month - 1) % 3) + 5})), + ] + case "nextWeek": + return [ + startOfWeek(today, locale).add({weeks: 1}), + endOfWeek(today, locale).add({weeks: 1}), + ] + case "nextYear": + return [ + startOfYear(today.add({years: 1})), + endOfYear(today.add({years: 1})), + ] + default: + throw new Error(`Invalid date range preset: ${preset as string}`) + } +} diff --git a/packages/common/utils/src/date-utils/types.ts b/packages/common/utils/src/date-utils/types.ts new file mode 100644 index 000000000..db7adcd71 --- /dev/null +++ b/packages/common/utils/src/date-utils/types.ts @@ -0,0 +1,58 @@ +// Modified from https://github.com/chakra-ui/zag +// MIT License +// Changes from Qualcomm Technologies, Inc. are provided under the following license: +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {DateFormatter, DateValue} from "@internationalized/date" + +export type DateGranularity = + | "day" + | "hour" + | "minute" + | "second" + | "year" + | "month" +export type DateAlignment = "start" | "end" | "center" + +export type GetFormatterFn = ( + options: Intl.DateTimeFormatOptions, +) => DateFormatter +export type DateAvailableFn = (date: DateValue, locale: string) => boolean +export type GetPlaceholderFn = (options: { + field: string + locale: string +}) => string +export type DateAdjustFn = (options: { + focusedDate: DateValue + startDate: DateValue +}) => { + endDate: DateValue + focusedDate: DateValue + startDate: DateValue +} +export type DateFormatOptions = Intl.ResolvedDateTimeFormatOptions + +export type DateRangePreset = + | "thisWeek" + | "lastWeek" + | "nextWeek" + | "thisMonth" + | "lastMonth" + | "nextMonth" + | "thisQuarter" + | "lastQuarter" + | "nextQuarter" + | "thisYear" + | "lastYear" + | "nextYear" + | "last3Days" + | "last7Days" + | "last14Days" + | "last30Days" + | "last90Days" + | "next3Days" + | "next7Days" + | "next14Days" + | "next30Days" + | "next90Days" diff --git a/packages/common/utils/src/machine/jsx.ts b/packages/common/utils/src/machine/jsx.ts index 16bb4e251..d4d3aad4c 100644 --- a/packages/common/utils/src/machine/jsx.ts +++ b/packages/common/utils/src/machine/jsx.ts @@ -435,6 +435,12 @@ export namespace JSX { * @see aria-labelledby */ "aria-describedby"?: string | undefined + /** + * Defines a string value that describes or annotates the current element. + * Prefer `aria-describedby` unless the description has no host element: + * assistive-technology support for this attribute is narrower. + */ + "aria-description"?: string | undefined /** * Identifies the element that provides a detailed, extended description for the * object. diff --git a/packages/common/utils/src/object/object.ts b/packages/common/utils/src/object/object.ts index 709e146db..a6ac8395f 100644 --- a/packages/common/utils/src/object/object.ts +++ b/packages/common/utils/src/object/object.ts @@ -4,9 +4,7 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -export function compact | undefined>( - obj: T, -): T { +export function compact(obj: T): T { if (!isPlainObject(obj) || obj === undefined) { return obj } diff --git a/packages/debug-apps/angular-csr/src/app/app.routes.ts b/packages/debug-apps/angular-csr/src/app/app.routes.ts index b8b5afa83..029f31375 100644 --- a/packages/debug-apps/angular-csr/src/app/app.routes.ts +++ b/packages/debug-apps/angular-csr/src/app/app.routes.ts @@ -9,6 +9,7 @@ import { CheckboxPage, CollapsiblePage, ComboboxPage, + DatePickerPage, DialogPage, DividerPage, DrawerPage, @@ -50,6 +51,7 @@ export const routes: Routes = [ {component: CheckboxPage, path: "checkbox"}, {component: CollapsiblePage, path: "collapsible"}, {component: ComboboxPage, path: "combobox"}, + {component: DatePickerPage, path: "date-picker"}, {component: DialogPage, path: "dialog"}, {component: DividerPage, path: "divider"}, {component: DrawerPage, path: "drawer"}, diff --git a/packages/debug-apps/angular-csr/src/app/components/date-picker.ts b/packages/debug-apps/angular-csr/src/app/components/date-picker.ts new file mode 100644 index 000000000..b452c797d --- /dev/null +++ b/packages/debug-apps/angular-csr/src/app/components/date-picker.ts @@ -0,0 +1,165 @@ +import {Component} from "@angular/core" + +import {DatePickerActionsDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-actions-demo" +import {DatePickerCompositeDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-composite-demo" +import {DatePickerCustomTriggerDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-custom-trigger-demo" +import {DatePickerHideOutsideDaysDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-hide-outside-days-demo" +import {DatePickerHintDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-hint-demo" +import {DatePickerInlineDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-inline-demo" +import {DatePickerLocaleDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-locale-demo" +import {DatePickerMinMaxDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-min-max-demo" +import {DatePickerMultipleDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-multiple-demo" +import {DatePickerOpenOnClickDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-open-on-click-demo" +import {DatePickerPresetsDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-presets-demo" +import {DatePickerRangeDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-range-demo" +import {DatePickerReactiveFormsDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-reactive-forms-demo" +import {DatePickerSingleDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-single-demo" +import {DatePickerSizesDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-sizes-demo" +import {DatePickerStatesDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-states-demo" +import {DatePickerTemplateFormsDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-template-forms-demo" +import {DatePickerUnavailableDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-unavailable-demo" +import {DatePickerWithinDialogDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-within-dialog-demo" + +@Component({ + imports: [ + DatePickerActionsDemo, + DatePickerCompositeDemo, + DatePickerCustomTriggerDemo, + DatePickerHideOutsideDaysDemo, + DatePickerHintDemo, + DatePickerInlineDemo, + DatePickerLocaleDemo, + DatePickerMinMaxDemo, + DatePickerMultipleDemo, + DatePickerOpenOnClickDemo, + DatePickerPresetsDemo, + DatePickerRangeDemo, + DatePickerReactiveFormsDemo, + DatePickerSingleDemo, + DatePickerSizesDemo, + DatePickerStatesDemo, + DatePickerTemplateFormsDemo, + DatePickerUnavailableDemo, + DatePickerWithinDialogDemo, + ], + selector: "app-date-picker", + template: ` +

+
+

Action Buttons

+
+ +
+
+
+

Composite

+
+ +
+
+
+

Custom Trigger

+
+ +
+
+
+

Hide Outside Days

+
+ +
+
+
+

Hint

+
+ +
+
+
+

Inline

+
+ +
+
+
+

Locale

+
+ +
+
+
+

Min Max

+
+ +
+
+
+

Multiple

+
+ +
+
+
+

Open On Click

+
+ +
+
+
+

Presets

+
+ +
+
+
+

Range

+
+ +
+
+
+

Reactive Forms

+
+ +
+
+
+

Single

+
+ +
+
+
+

Sizes

+
+ +
+
+
+

States

+
+ +
+
+
+

Template Forms

+
+ +
+
+
+

Unavailable

+
+ +
+
+
+

Within Dialog

+
+ +
+
+
+ `, +}) +export class DatePickerPage {} diff --git a/packages/debug-apps/angular-csr/src/app/components/index.ts b/packages/debug-apps/angular-csr/src/app/components/index.ts index 0cf089466..cfdcce13e 100644 --- a/packages/debug-apps/angular-csr/src/app/components/index.ts +++ b/packages/debug-apps/angular-csr/src/app/components/index.ts @@ -6,6 +6,7 @@ export * from "./button" export * from "./checkbox" export * from "./collapsible" export * from "./combobox" +export * from "./date-picker" export * from "./dialog" export * from "./divider" export * from "./drawer" diff --git a/packages/debug-apps/angular-csr/src/app/home.ts b/packages/debug-apps/angular-csr/src/app/home.ts index 6537abee8..4caf477a5 100644 --- a/packages/debug-apps/angular-csr/src/app/home.ts +++ b/packages/debug-apps/angular-csr/src/app/home.ts @@ -46,6 +46,7 @@ export class Home { {name: "Checkbox", path: "/checkbox"}, {name: "Collapsible", path: "/collapsible"}, {name: "Combobox", path: "/combobox"}, + {name: "Date Picker", path: "/date-picker"}, {name: "Dialog", path: "/dialog"}, {name: "Divider", path: "/divider"}, {name: "Drawer", path: "/drawer"}, diff --git a/packages/debug-apps/angular-ssr/src/app/app.routes.ts b/packages/debug-apps/angular-ssr/src/app/app.routes.ts index e01c2177b..d39649887 100644 --- a/packages/debug-apps/angular-ssr/src/app/app.routes.ts +++ b/packages/debug-apps/angular-ssr/src/app/app.routes.ts @@ -12,6 +12,7 @@ import { CheckboxPage, CollapsiblePage, ComboboxPage, + DatePickerPage, DialogPage, DividerPage, DrawerPage, @@ -60,6 +61,7 @@ export const routes: Routes = [ {component: CheckboxGroupPage, path: "checkbox-group"}, {component: CollapsiblePage, path: "collapsible"}, {component: ComboboxPage, path: "combobox"}, + {component: DatePickerPage, path: "date-picker"}, {component: DialogPage, path: "dialog"}, {component: DividerPage, path: "divider"}, {component: DrawerPage, path: "drawer"}, diff --git a/packages/debug-apps/angular-ssr/src/app/components/date-picker.ts b/packages/debug-apps/angular-ssr/src/app/components/date-picker.ts new file mode 100644 index 000000000..b452c797d --- /dev/null +++ b/packages/debug-apps/angular-ssr/src/app/components/date-picker.ts @@ -0,0 +1,165 @@ +import {Component} from "@angular/core" + +import {DatePickerActionsDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-actions-demo" +import {DatePickerCompositeDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-composite-demo" +import {DatePickerCustomTriggerDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-custom-trigger-demo" +import {DatePickerHideOutsideDaysDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-hide-outside-days-demo" +import {DatePickerHintDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-hint-demo" +import {DatePickerInlineDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-inline-demo" +import {DatePickerLocaleDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-locale-demo" +import {DatePickerMinMaxDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-min-max-demo" +import {DatePickerMultipleDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-multiple-demo" +import {DatePickerOpenOnClickDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-open-on-click-demo" +import {DatePickerPresetsDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-presets-demo" +import {DatePickerRangeDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-range-demo" +import {DatePickerReactiveFormsDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-reactive-forms-demo" +import {DatePickerSingleDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-single-demo" +import {DatePickerSizesDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-sizes-demo" +import {DatePickerStatesDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-states-demo" +import {DatePickerTemplateFormsDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-template-forms-demo" +import {DatePickerUnavailableDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-unavailable-demo" +import {DatePickerWithinDialogDemo} from "@qualcomm-ui/angular-docs/components+/date-picker+/demos/date-picker-within-dialog-demo" + +@Component({ + imports: [ + DatePickerActionsDemo, + DatePickerCompositeDemo, + DatePickerCustomTriggerDemo, + DatePickerHideOutsideDaysDemo, + DatePickerHintDemo, + DatePickerInlineDemo, + DatePickerLocaleDemo, + DatePickerMinMaxDemo, + DatePickerMultipleDemo, + DatePickerOpenOnClickDemo, + DatePickerPresetsDemo, + DatePickerRangeDemo, + DatePickerReactiveFormsDemo, + DatePickerSingleDemo, + DatePickerSizesDemo, + DatePickerStatesDemo, + DatePickerTemplateFormsDemo, + DatePickerUnavailableDemo, + DatePickerWithinDialogDemo, + ], + selector: "app-date-picker", + template: ` +
+
+

Action Buttons

+
+ +
+
+
+

Composite

+
+ +
+
+
+

Custom Trigger

+
+ +
+
+
+

Hide Outside Days

+
+ +
+
+
+

Hint

+
+ +
+
+
+

Inline

+
+ +
+
+
+

Locale

+
+ +
+
+
+

Min Max

+
+ +
+
+
+

Multiple

+
+ +
+
+
+

Open On Click

+
+ +
+
+
+

Presets

+
+ +
+
+
+

Range

+
+ +
+
+
+

Reactive Forms

+
+ +
+
+
+

Single

+
+ +
+
+
+

Sizes

+
+ +
+
+
+

States

+
+ +
+
+
+

Template Forms

+
+ +
+
+
+

Unavailable

+
+ +
+
+
+

Within Dialog

+
+ +
+
+
+ `, +}) +export class DatePickerPage {} diff --git a/packages/debug-apps/angular-ssr/src/app/components/index.ts b/packages/debug-apps/angular-ssr/src/app/components/index.ts index 4af690d3b..3b4d9f686 100644 --- a/packages/debug-apps/angular-ssr/src/app/components/index.ts +++ b/packages/debug-apps/angular-ssr/src/app/components/index.ts @@ -9,6 +9,7 @@ export * from "./checkbox" export * from "./checkbox-group" export * from "./collapsible" export * from "./combobox" +export * from "./date-picker" export * from "./dialog" export * from "./divider" export * from "./drawer" diff --git a/packages/debug-apps/angular-ssr/src/app/home.ts b/packages/debug-apps/angular-ssr/src/app/home.ts index 5fdd72d00..2b581d946 100644 --- a/packages/debug-apps/angular-ssr/src/app/home.ts +++ b/packages/debug-apps/angular-ssr/src/app/home.ts @@ -58,6 +58,7 @@ export class Home { {name: "Checkbox Group", path: "/checkbox-group"}, {name: "Collapsible", path: "/collapsible"}, {name: "Combobox", path: "/combobox"}, + {name: "Date Picker", path: "/date-picker"}, {name: "Dialog", path: "/dialog"}, {name: "Divider", path: "/divider"}, {name: "Drawer", path: "/drawer"}, diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/_date-picker.mdx b/packages/docs/angular-docs/src/routes/components+/date-picker+/_date-picker.mdx new file mode 100644 index 000000000..02068cbe9 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/_date-picker.mdx @@ -0,0 +1,894 @@ +--- +title: Date Picker +description: The date picker lets users select a single date, multiple dates, or a date range from a calendar, with day, month, and year views. +component: DatePicker +aliases: [calendar, datepicker, date input] +--- + +import { + TypeDocProps, + TypeDocAngularAttributes, +} from "@qualcomm-ui/react-mdx/typedoc" +import {ComponentExplorer, QdsDemo} from "~components/demo" + +# {frontmatter.title} + +{frontmatter.description} + +```tsx +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" +``` + +## Overview + +- The value is an array of `DateValue` from [@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/), not a native `Date`. The type is re-exported from `@qualcomm-ui/angular/date-picker`, along with `parseDate`, `today`, `getLocalTimeZone`, and `CalendarDate`. +- Build values with `parseDate("2026-08-14")` for fixed dates, or `today(getLocalTimeZone())` for relative ones. Both return a `CalendarDate`, a date with no time attached. +- To keep a time alongside the date, start from a [`CalendarDateTime`](https://react-aria.adobe.com/internationalized/date/CalendarDateTime) or [`ZonedDateTime`](https://react-aria.adobe.com/internationalized/date/ZonedDateTime) instead. Picking a new day preserves the time. +- Avoid converting a native `Date`. Only its local year, month, and day are read, so a UTC timestamp such as `new Date("2026-08-14T00:00:00Z")` becomes August 13 in time zones behind UTC. Pass the ISO date string to `parseDate` instead. + +## Examples + +### Single Date + +The default [selectionMode](./#selectionMode). Type a date into the field in the locale format, or pick one from the calendar. + + + +### Child Directives + +::: terms +slots, child directives, projection +::: + +Provide child directives to customize specific elements while keeping the simple API's default structure. Anything not provided falls back to the default. + +`q-date-picker-control`, `q-date-picker-input-group`, `q-date-picker-hint`, `q-date-picker-error-text`, `q-date-picker-headline`, `q-date-picker-headline-label`, `q-date-picker-headline-value`, `q-date-picker-presets`, and `q-date-picker-actions` can all be provided this way. Use this to reach inputs the simple API does not expose, such as [moreLabel](./#moreLabel) on the headline value. + +Providing `q-date-picker-actions` also adds the footer, so [closeOnSelect](./#closeOnSelect) does not need to be set. The `inline` variant never shows a footer, so it ignores that directive. + + + +### Composite + +::: terms +composite api, parts, anatomy +::: + +Build with the composite API for granular control. The simple `` renders exactly this tree, including the three `q-date-picker-view` blocks for the day, month, and year views. + + + +### Range + +::: terms +date range, start date, end date +::: + +Set `selectionMode="range"` to collect a start and end date. Use the [separator](./#separator) input on `q-date-picker`, or on the composite [`q-date-picker-input-group`](./#separator-1), to change the character between the two inputs; it defaults to `-`. The calendar shows a band between the two dates. + +A range can be partially filled, in which case the missing end is `null`. + + + +### Multiple + +::: terms +multi select, multiple dates, tags +::: + +Set `selectionMode="multiple"` to collect several dates, shown as dismissible tags in the field. The field is display-only in this mode, so dates are picked from the calendar rather than typed. + +Use [maxSelectedDates](./#maxSelectedDates) to cap how many dates can be selected. + +The popover always shows [action buttons](./#action-buttons) in this mode. + + + +### Action Buttons + +::: terms +cancel, ok, footer, apply +::: + +Set [closeOnSelect](./#closeOnSelect) to `false` to add a footer with Cancel and OK buttons. `selectionMode="multiple"` always includes it. + +Selections apply as they are made. OK keeps them and closes the popover, while Cancel, `Escape`, and clicking outside all revert to the value the popover opened with. The field's clear button applies immediately and is not affected by Cancel. + + + +### Open on Click + +::: terms +click to expand, expand on click +::: + +Set [openOnClick](./#openOnClick) to automatically open the popover when the user clicks the field. This keeps focus in it so typing continues. + + + +### Presets + +::: terms +shortcuts, quick select, relative dates +::: + +Use the [presets](./#presets-1) input to offer common selections, such as the last 7 days. A button in the day view header opens the presets panel. + +Each preset is a `{label, value}` pair, where `value` is either a named range such as `"next7Days"` or an explicit `DateValue[]`. + + + +### Sizes + +::: terms +small, medium, large +::: + +This component supports three [size](./#size) options to accommodate different layout densities. + +The available sizes are `sm`, `md`, and `lg`. The default size is `md`. Size applies to the input field only; the calendar is unaffected. + + + +### Hint and Error Text + +::: terms +helper text, validation message, error indicator +::: + +Add a [hint](./#hint) to provide additional context below the field. When [invalid](./#invalid) is `true`, the [errorText](./#errorText) replaces it and the field shows an error indicator icon. + + + +### States + +::: terms +disabled state, read-only, readonly, invalid state +::: + +The date picker supports disabled, read-only, and invalid states. When you bind a reactive form control, set the state through the control rather than these inputs. + + + +### Min and Max Dates + +::: terms +date bounds, range limit +::: + +Use the [min](./#min) and [max](./#max) inputs to bound the selectable dates. Dates outside the bounds are disabled, and typed values are clamped into range. + + + +### Outside Days + +::: terms +adjacent months, leading days, trailing days +::: + +Days from the previous and next month fill the first and last week of the grid. Set [hideOutsideDays](./#hideOutsideDays) to leave those cells empty instead. + + + +### Unavailable Dates + +::: terms +blocked dates, holidays, disabled dates +::: + +Use the [isDateUnavailable](./#isDateUnavailable) input to block individual dates that [min](./#min) and [max](./#max) cannot express, such as weekends or holidays. + + + +### Locale + +::: terms +internationalization, i18n, language, date format +::: + +Use the [locale](./#locale-1) input to change how dates are displayed and parsed. It takes a [BCP 47 language tag](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag) and defaults to `en-US`. The month and weekday names, the first day of the week, the placeholder, and the separators the field accepts all follow from it. For example, `de-DE` gives a `dd.mm.yyyy` placeholder and accepts `.` as the separator. + +Use the [startOfWeek](./#startOfWeek) input to override only the first day of the week. + + + +### Inline + +::: terms +always open, embedded calendar, flat +::: + +Set [variant](./#variant) to `inline` to render an always-visible calendar with no field or popover. This variant never shows action buttons and has no inputs, so it does not submit a value with a native form; read the selection through the bound form control. + +The inline variant shows a headline with the label and current value. Set `[headline]="false"` to remove it. + + + +### Custom Trigger + +::: terms +structural directive, button trigger, unstyled trigger, api +::: + +[`*datePickerContext`](./#date-picker-context) is a structural directive that hands you the picker api, so you can drive your own presentation from the picker's state. Use it to read the current value, format your own label, or call actions such as `clearValue`. + +```angular-html + + {{ api.valueAsString[0] || "No date selected" }} + +``` + +[`q-date-picker-trigger`](./#q-date-picker-trigger) pairs with it to replace the field entirely. It is a behavioral, unstyled trigger with no styled wrapper, so compose it with `q-button` on the same element. Place it inside [`q-date-picker-control`](./#q-date-picker-control), which anchors the popover. + +```angular-html + +``` + +Accessibility attributes, focus restoration, and dismissal behavior come from the picker, so you do not write them yourself. + + + +### Within Dialog + +::: terms +modal, disable portal +::: + +Set [disablePortal](./#disablePortal) to render the calendar popover in place rather than at the end of `document.body`. This is required inside a [Dialog](/components/dialog) or [Popover](/components/popover). + + + +## Forms + +::: terms +formdata, native validation, submit, reset +::: + +The control value is an array of `DateValue | null`, and entries can be `null` when a range is partially filled. + +The selection is also submitted natively under [name](./#name), and the number of entries follows the selection mode: one for a single date, two for a range, and one per date in `multiple` mode. Entries arrive in document order, so a range is always start then end. + +A range keeps the position of each end, so clearing only the start submits an empty first entry with the end date second. Read a range by position rather than by taking the first non-empty entry. + +Resetting the form restores the initial value. + +### Reactive Forms + +::: terms +FormControl, FormGroup, ReactiveFormsModule, Validators +::: + +Bind a `FormControl` with `[formControl]` or `formControlName`. The [disabled](./#disabled), [invalid](./#invalid), and [required](./#required) inputs have no effect in this case; use the equivalent form control bindings instead. + +In `range` mode, a required control also applies a validator that rejects a partially filled range. + + + +### Template Forms + +::: terms +ngModel, template-driven, FormsModule, two-way binding +::: + +Bind `ngModel` for template-driven forms. Here the [disabled](./#disabled), [readOnly](./#readOnly), [invalid](./#invalid), and [required](./#required) inputs govern the state of the control. + + + +## Explorer + + + +## API + +### \ + +The simple date picker extends the [q-date-picker-root](./#q-date-picker-root) directive with the following properties: + + + +## Composite API + +This section describes the elements of the date picker's composite API. + +### q-date-picker-root + + + + + +### q-date-picker-label + + + + + +### q-date-picker-control + + + + + +### q-date-picker-input-group + +See [Shortcuts](./#shortcuts) for the tree this renders in each selection mode. + + + + + +### q-date-picker-input-group-trigger + +The bordered field inside the input group. In `multiple` mode it also acts as the +calendar trigger, because no text input owns focus there. + + + + + +### q-date-picker-input + +A range picker renders two inputs, distinguished by `index`. + + + + + +### q-date-picker-value-tags + + + + + +### q-date-picker-clear-trigger + +Applies the clear behavior to any element, without QDS styling. Use it to build a +custom clear control. For the styled control used inside the input group, see +[q-date-picker-input-clear-trigger](./#q-date-picker-input-clear-trigger). + + + + + +### q-date-picker-input-clear-trigger + +The clear trigger as it is styled inside the input group: a +[q-date-picker-clear-trigger](./#q-date-picker-clear-trigger) rendered as an +[icon button](/components/icon-button) with compact density and a default `X` icon. +Project content to replace the icon. + + + + + +### q-date-picker-error-indicator + + + + + +### q-date-picker-trigger + +Applies the open and close behavior to any element, without QDS styling. Use it +to build a custom trigger. For the styled control used inside the input group, +see [q-date-picker-input-trigger](./#q-date-picker-input-trigger). + + + + + +### q-date-picker-input-trigger + +The calendar trigger as it is styled inside the input group: a +[q-date-picker-trigger](./#q-date-picker-trigger) rendered as an +[icon button](/components/icon-button) with compact density and a default `Calendar` +icon. Project content to replace the icon. + +In `multiple` mode the input group renders a non-interactive calendar icon +instead, because `q-date-picker-input-group-trigger` carries the trigger +bindings. + + + + + +### q-date-picker-hint + + + + + +### q-date-picker-error-text + + + + + +### q-date-picker-positioner + + + + + +### q-date-picker-content + + + + + +### q-date-picker-headline + + + + + +### q-date-picker-headline-label + +The default caption is `Date range` in range mode and `Date` otherwise. Pass content to override it. + + + + + +### q-date-picker-headline-value + +In `multiple` mode this shows up to two dates then appends a count suffix. + + + + + +### q-date-picker-view + + + + + +### q-date-picker-view-control + + + + + +### q-date-picker-view-trigger + + + + + +### q-date-picker-view-close-trigger + + + + + +### q-date-picker-prev-trigger + +Disabled when the previous page falls entirely outside `min`. + + + + + +### q-date-picker-next-trigger + +Disabled when the next page falls entirely outside `max`. + + + + + +### q-date-picker-month-text + + + +### q-date-picker-year-text + + + +### q-date-picker-range-text + + + + + +### q-date-picker-table + +The column count is fixed per view. + + + + + +### q-date-picker-table-head + + + + + +### q-date-picker-table-header + + + + + +### q-date-picker-table-body + + + + + +### q-date-picker-table-row + + + + + +### q-date-picker-table-cell + +The bindings differ by the view the cell belongs to. + + + + + +### q-date-picker-table-cell-trigger + +The bindings differ by the view the cell belongs to. + + + + + +### q-date-picker-day-grid-header + + + +### q-date-picker-day-grid + + + +### q-date-picker-month-grid + + + +### q-date-picker-year-grid + +Renders the year cells for the year view. Takes no inputs. + +### q-date-picker-presets + + + + + +### q-date-picker-presets-trigger + + + + + +### q-date-picker-preset-trigger + + + + + +### q-date-picker-actions + + + + + +### q-date-picker-cancel-trigger + + + +### q-date-picker-ok-trigger + + + +### datePickerContext + + + +### DatePickerApi + +The api handed to the `*datePickerContext` structural directive. + + + +## Data Structures + +### DatePickerPositioningOptions + + + +## Shortcuts + +### q-date-picker-input-group + +Groups the field parts into one bordered control. For a single date picker it renders roughly this structure: + +```angular-html + +
+ + + + + +
+``` + +The two buttons are compact icon buttons, and the empty span is the divider between the input and the triggers. See [q-date-picker-input-clear-trigger](./#q-date-picker-input-clear-trigger) and [q-date-picker-input-trigger](./#q-date-picker-input-trigger). + +In `range` mode it renders two inputs with the `separator` between them: + +```angular-html + +{{ separator }} + +``` + +In `multiple` mode it renders `q-date-picker-value-tags` in place of the inputs: + +```angular-html + +``` + +Multiple mode also replaces the calendar trigger with a non-interactive calendar icon, because the field itself carries the trigger bindings. + +### q-date-picker-day-grid-header + +Renders the weekday header row of the day view. This shortcut is equivalent to: + +```angular-html + + + @for (weekDay of weekDays; track $index) { + + {{ weekDay[format] }} + + } + + +``` + +### q-date-picker-day-grid + +Renders the day cells of the visible month. This shortcut is equivalent to: + +```angular-html + + @for (week of weeks; track $index) { + + @for (day of week; track $index) { + +
{{ day.day }}
+ + } + + } + +``` + +### q-date-picker-month-grid + +Renders the month cells of the month view. This shortcut is equivalent to: + +```angular-html + + @for (months of getMonthsGrid({columns, format}); track $index) { + + @for (month of months; track $index) { + +
{{ month.label }}
+ + } + + } + +``` + +### q-date-picker-year-grid + +Renders the year cells of the year view. This shortcut is equivalent to: + +```angular-html + + @for (years of getYearsGrid({columns}); track $index) { + + @for (year of years; track $index) { + +
{{ year.label }}
+ + } + + } + +``` diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-actions-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-actions-demo.ts new file mode 100644 index 000000000..339be456c --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-actions-demo.ts @@ -0,0 +1,18 @@ +import {Component} from "@angular/core" + +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-actions-demo", + template: ` + + + + `, +}) +export class DatePickerActionsDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-child-directives-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-child-directives-demo.ts new file mode 100644 index 000000000..f530eae41 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-child-directives-demo.ts @@ -0,0 +1,42 @@ +import {Component} from "@angular/core" + +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-child-directives-demo", + template: ` +
+ + +
+ + +
+
+ + + Travel dates + + + +
+ `, +}) +export class DatePickerChildDirectivesDemo { + readonly defaultValue = [ + parseDate("2026-08-10"), + parseDate("2026-08-14"), + parseDate("2026-08-19"), + ] + + readonly moreLabel = (count: number) => `and ${count} more` +} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-composite-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-composite-demo.ts new file mode 100644 index 000000000..6eab19466 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-composite-demo.ts @@ -0,0 +1,94 @@ +import {Component, signal} from "@angular/core" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" +import type { + DatePickerValueChangeDetails, + DateValue, +} from "@qualcomm-ui/core/date-picker" + +@Component({ + imports: [DatePickerModule, PortalDirective], + selector: "date-picker-composite-demo", + template: ` + +
+
+
+
+
Choose a date in mm/dd/yyyy format
+
A departure date is required
+ + +
+
+
+
+ + + + +
+ + + +
+
+ +
+
+ + + + + +
+ + +
+
+ +
+
+ + + + + +
+ + +
+
+
+
+
+
+ + `, +}) +export class DatePickerCompositeDemo { + readonly value = signal<(DateValue | null)[]>([]) + + onValueChange(details: DatePickerValueChangeDetails) { + this.value.set(details.value) + } +} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-custom-trigger-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-custom-trigger-demo.ts new file mode 100644 index 000000000..f963b2de8 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-custom-trigger-demo.ts @@ -0,0 +1,103 @@ +import {Component} from "@angular/core" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {ButtonModule} from "@qualcomm-ui/angular/button" +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [ButtonModule, DatePickerModule, PortalDirective], + selector: "date-picker-custom-trigger-demo", + template: ` + +
+
+ + @if (api.value.length) { + + } +
+ + +
+
+
+ + +
+ +
+
+ + + + +
+ + + +
+
+ +
+
+ + + + + +
+ + +
+
+ +
+
+ + + + + +
+ + +
+
+ +
+ + +
+
+
+
+
+ + `, +}) +export class DatePickerCustomTriggerDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-explorer-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-explorer-demo.ts new file mode 100644 index 000000000..4d72587d3 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-explorer-demo.ts @@ -0,0 +1,33 @@ +import {Component} from "@angular/core" + +import { + type DatePickerPreset, + DatePickerModule, + getLocalTimeZone, + parseDate, + today, +} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-explorer-demo", + template: ` + + `, +}) +export class DatePickerExplorerDemo { + readonly defaultValue = [parseDate("2026-08-14")] + + readonly presets: DatePickerPreset[] = [ + {label: "Today", value: [today(getLocalTimeZone())]}, + {label: "Tomorrow", value: [today(getLocalTimeZone()).add({days: 1})]}, + ] +} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-hide-outside-days-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-hide-outside-days-demo.ts new file mode 100644 index 000000000..e278486c5 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-hide-outside-days-demo.ts @@ -0,0 +1,16 @@ +import {Component} from "@angular/core" + +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-hide-outside-days-demo", + template: ` +
+ + + +
+ `, +}) +export class DatePickerHideOutsideDaysDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-hint-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-hint-demo.ts new file mode 100644 index 000000000..e7ae954c3 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-hint-demo.ts @@ -0,0 +1,27 @@ +import {Component} from "@angular/core" + +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-hint-demo", + template: ` +
+ + + + +
+ `, +}) +export class DatePickerHintDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-inline-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-inline-demo.ts new file mode 100644 index 000000000..e9fab7fce --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-inline-demo.ts @@ -0,0 +1,17 @@ +import {Component} from "@angular/core" + +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-inline-demo", + template: ` +
+ + + + +
+ `, +}) +export class DatePickerInlineDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-locale-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-locale-demo.ts new file mode 100644 index 000000000..b7b9655f2 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-locale-demo.ts @@ -0,0 +1,43 @@ +import {Component, signal} from "@angular/core" + +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" +import {RadioModule} from "@qualcomm-ui/angular/radio" + +@Component({ + imports: [DatePickerModule, RadioModule], + selector: "date-picker-locale-demo", + template: ` +
+ + + + +
+
Locale
+
+ @for (item of locales; track item.value) { + + } +
+
+
+ `, +}) +export class DatePickerLocaleDemo { + readonly locale = signal("en-US") + + readonly locales = [ + {label: "English (US)", value: "en-US"}, + {label: "German", value: "de-DE"}, + {label: "Japanese", value: "ja-JP"}, + ] + + onLocaleChange(value: string | null) { + this.locale.set(value ?? "en-US") + } +} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-min-max-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-min-max-demo.ts new file mode 100644 index 000000000..89d53b868 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-min-max-demo.ts @@ -0,0 +1,27 @@ +import {Component} from "@angular/core" + +import { + DatePickerModule, + getLocalTimeZone, + today, +} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-min-max-demo", + template: ` + + + + `, +}) +export class DatePickerMinMaxDemo { + readonly min = today(getLocalTimeZone()) + readonly max = this.min.add({days: 30}) +} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-multiple-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-multiple-demo.ts new file mode 100644 index 000000000..da1550431 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-multiple-demo.ts @@ -0,0 +1,19 @@ +import {Component} from "@angular/core" + +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-multiple-demo", + template: ` + + + + `, +}) +export class DatePickerMultipleDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-open-on-click-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-open-on-click-demo.ts new file mode 100644 index 000000000..0352494f8 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-open-on-click-demo.ts @@ -0,0 +1,14 @@ +import {Component} from "@angular/core" + +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-open-on-click-demo", + template: ` + + + + `, +}) +export class DatePickerOpenOnClickDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-presets-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-presets-demo.ts new file mode 100644 index 000000000..301cc0555 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-presets-demo.ts @@ -0,0 +1,33 @@ +import {Component} from "@angular/core" + +import { + type DatePickerPreset, + DatePickerModule, +} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-presets-demo", + template: ` + + + + `, +}) +export class DatePickerPresetsDemo { + readonly presets: DatePickerPreset[] = [ + {label: "Next 7 days", value: "next7Days"}, + {label: "Next 14 days", value: "next14Days"}, + {label: "Next 30 days", value: "next30Days"}, + {label: "Next 90 days", value: "next90Days"}, + {label: "Next week", value: "nextWeek"}, + {label: "Next month", value: "nextMonth"}, + {label: "Next quarter", value: "nextQuarter"}, + {label: "Next year", value: "nextYear"}, + ] +} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-range-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-range-demo.ts new file mode 100644 index 000000000..63a47e0c7 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-range-demo.ts @@ -0,0 +1,14 @@ +import {Component} from "@angular/core" + +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-range-demo", + template: ` + + + + `, +}) +export class DatePickerRangeDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-reactive-forms-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-reactive-forms-demo.ts new file mode 100644 index 000000000..48705e0c1 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-reactive-forms-demo.ts @@ -0,0 +1,68 @@ +import {Component, inject} from "@angular/core" +import {FormBuilder, ReactiveFormsModule, Validators} from "@angular/forms" + +import {ButtonModule} from "@qualcomm-ui/angular/button" +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" +import type {DateValue} from "@qualcomm-ui/core/date-picker" + +@Component({ + imports: [DatePickerModule, ReactiveFormsModule, ButtonModule], + selector: "date-picker-reactive-forms-demo", + template: ` +
+ + +
+ + +
+ + `, +}) +export class DatePickerReactiveFormsDemo { + private fb = inject(FormBuilder) + + form = this.fb.group({ + departureDate: [[] as (DateValue | null)[], Validators.required], + }) + + onSubmit() { + if (this.form.valid) { + console.log("Form submitted:", { + ...this.form.value, + }) + } + } + + reset() { + this.form.reset({ + departureDate: [], + }) + } +} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-single-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-single-demo.ts new file mode 100644 index 000000000..7e6359edb --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-single-demo.ts @@ -0,0 +1,14 @@ +import {Component} from "@angular/core" + +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-single-demo", + template: ` + + + + `, +}) +export class DatePickerSingleDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-sizes-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-sizes-demo.ts new file mode 100644 index 000000000..3ead077e1 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-sizes-demo.ts @@ -0,0 +1,18 @@ +import {Component} from "@angular/core" + +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-sizes-demo", + template: ` +
+ + + + + +
+ `, +}) +export class DatePickerSizesDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-states-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-states-demo.ts new file mode 100644 index 000000000..c9a90098c --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-states-demo.ts @@ -0,0 +1,36 @@ +import {Component} from "@angular/core" + +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-states-demo", + template: ` +
+ + + + + +
+ `, +}) +export class DatePickerStatesDemo { + readonly departureDate = [parseDate("2026-08-14")] +} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-template-forms-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-template-forms-demo.ts new file mode 100644 index 000000000..76622dfdd --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-template-forms-demo.ts @@ -0,0 +1,33 @@ +import {Component, computed, signal} from "@angular/core" +import {FormsModule} from "@angular/forms" + +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" +import type {DateValue} from "@qualcomm-ui/core/date-picker" + +@Component({ + imports: [DatePickerModule, FormsModule], + selector: "date-picker-template-forms-demo", + template: ` +
+ + + + + + {{ formattedValue() }} + +
+ `, +}) +export class DatePickerTemplateFormsDemo { + readonly value = signal<(DateValue | null)[]>([parseDate("2026-08-14")]) + + readonly formattedValue = computed( + () => this.value()[0]?.toString() ?? "No date selected", + ) +} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-unavailable-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-unavailable-demo.ts new file mode 100644 index 000000000..592405dd6 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-unavailable-demo.ts @@ -0,0 +1,21 @@ +import {Component} from "@angular/core" + +import {DatePickerModule, isWeekend} from "@qualcomm-ui/angular/date-picker" + +@Component({ + imports: [DatePickerModule], + selector: "date-picker-unavailable-demo", + template: ` + + + + `, +}) +export class DatePickerUnavailableDemo { + readonly isWeekend = isWeekend +} diff --git a/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-within-dialog-demo.ts b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-within-dialog-demo.ts new file mode 100644 index 000000000..8aab35819 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/date-picker+/demos/date-picker-within-dialog-demo.ts @@ -0,0 +1,39 @@ +import {Component} from "@angular/core" + +import {ButtonModule} from "@qualcomm-ui/angular/button" +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" +import {DialogModule} from "@qualcomm-ui/angular/dialog" + +@Component({ + imports: [DatePickerModule, ButtonModule, DialogModule], + selector: "date-picker-within-dialog-demo", + template: ` +
+ + +
+
Book a Flight
+ + + + +
+ +
+ +
+
+
+ `, +}) +export class DatePickerWithinDialogDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/menu+/_menu.mdx b/packages/docs/angular-docs/src/routes/components+/menu+/_menu.mdx index b1db0846d..c27e699b8 100644 --- a/packages/docs/angular-docs/src/routes/components+/menu+/_menu.mdx +++ b/packages/docs/angular-docs/src/routes/components+/menu+/_menu.mdx @@ -370,8 +370,8 @@ Use the [positioning.hideWhenDetached](./#positioning) prop to hide the menu whe ]} /> -### PositioningOptions +### MenuPositioningOptions The [positioning](./#positioning) prop accepts an object with the following shape: - + diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/_date-picker.mdx b/packages/docs/react-docs/src/routes/components+/date-picker+/_date-picker.mdx new file mode 100644 index 000000000..64dfbbe7b --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/_date-picker.mdx @@ -0,0 +1,891 @@ +--- +title: Date Picker +description: The date picker lets users select a single date, multiple dates, or a date range from a calendar, with day, month, and year views. +component: DatePicker +aliases: [calendar, datepicker, date input] +--- + +import {TypeDocProps, TypeDocAttributes} from "@qualcomm-ui/react-mdx/typedoc" +import {ComponentExplorer, Demo} from "~components/demo" +import * as demos from "./demos" + +# {frontmatter.title} + +{frontmatter.description} + +```tsx +import {DatePicker} from "@qualcomm-ui/react/date-picker" +``` + +## Overview + +- The value is an array of `DateValue` from [@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/), not a native `Date`. The type is re-exported from `@qualcomm-ui/react/date-picker`. +- Build values with `parseDate("2026-08-14")` for fixed dates, or `today(getLocalTimeZone())` for relative ones. Both return a `CalendarDate`, a date with no time attached. +- To keep a time alongside the date, start from a [`CalendarDateTime`](https://react-aria.adobe.com/internationalized/date/CalendarDateTime) or [`ZonedDateTime`](https://react-aria.adobe.com/internationalized/date/ZonedDateTime) instead. Picking a new day preserves the time. +- Avoid converting a native `Date`. Only its local year, month, and day are read, so a UTC timestamp such as `new Date("2026-08-14T00:00:00Z")` becomes August 13 in time zones behind UTC. Pass the ISO date string to `parseDate` instead. + +## Examples + +### Single Date + +The default [selectionMode](./#selectionMode). Type a date into the field in the locale format, or pick one from the calendar. + + + +### Composite + +::: terms +composite api, parts, anatomy +::: + +Build with the composite API for granular control. The simple API `DatePicker` renders exactly this tree, including the three `DatePicker.View` blocks for the day, month, and year views. + + + +### Range + +::: terms +date range, start date, end date +::: + +Set `selectionMode="range"` to collect a start and end date. Use the [`separator`](./#separator) prop on `DatePicker.InputGroup` to change the character between the two inputs; it defaults to `-`. The calendar shows a band between the two dates. + +A range can be partially filled, in which case the missing end is `null`. + + + +### Multiple + +::: terms +multi select, multiple dates, tags +::: + +Set `selectionMode="multiple"` to collect several dates, shown as dismissible tags in the field. The field is display-only in this mode, so dates are picked from the calendar rather than typed. + +Use [maxSelectedDates](./#maxSelectedDates) to cap how many dates can be selected. + +The popover always shows [action buttons](./#action-buttons) in this mode. + + + +### Action Buttons + +::: terms +cancel, ok, footer, apply +::: + +Set [closeOnSelect](./#closeOnSelect) to `false` to add a footer with Cancel and OK buttons. `selectionMode="multiple"` always includes it. + +Selections apply as they are made. OK keeps them and closes the popover, while Cancel, `Escape`, and clicking outside all revert to the value the popover opened with. The field's clear button applies immediately and is not affected by Cancel. + + + +### Open on Click + +::: terms +click to expand, expand on click +::: + +Set [openOnClick](./#openOnClick) to automatically open the popover when the user clicks the field. This keeps focus in it so typing continues. + + + +### Presets + +::: terms +shortcuts, quick select, relative dates +::: + +Use the [presets](./#presets-1) prop to offer common selections, such as the last 7 days. A button in the day view header opens the presets panel. + +Each preset is a `{label, value}` pair, where `value` is either a named range such as `"next7Days"` or an explicit `DateValue[]`. + + + +### Sizes + +::: terms +small, medium, large +::: + +This component supports three [size](./#size) options to accommodate different layout densities. + +The available sizes are `sm`, `md`, and `lg`. The default size is `md`. Size applies to the input field only; the calendar is unaffected. + + + +### Hint and Error Text + +::: terms +helper text, validation message, error indicator +::: + +Add a [hint](./#hint) to provide additional context below the field. When [invalid](./#invalid) is `true`, the [errorText](./#errorText) replaces it and the field shows an error indicator icon. + + + +### States + +::: terms +disabled state, read-only, readonly, invalid state +::: + +The date picker supports disabled, read-only, and invalid states. + + + +### Min and Max Dates + +::: terms +date bounds, range limit +::: + +Use the [min](./#min) and [max](./#max) props to bound the selectable dates. Dates outside the bounds are disabled, and typed values are clamped into range. + + + +### Outside Days + +::: terms +adjacent months, leading days, trailing days +::: + +Days from the previous and next month fill the first and last week of the grid. Set [hideOutsideDays](./#hideOutsideDays) to leave those cells empty instead. + + + +### Unavailable Dates + +::: terms +blocked dates, holidays, disabled dates +::: + +Use the [isDateUnavailable](./#isDateUnavailable) prop to block individual dates that [min](./#min) and [max](./#max) cannot express, such as weekends or holidays. + + + +### Locale + +::: terms +internationalization, i18n, language, date format +::: + +Use the [locale](./#locale-1) prop to change how dates are displayed and parsed. It takes a [BCP 47 language tag](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag) and defaults to `en-US`. The month and weekday names, the first day of the week, the placeholder, and the separators the field accepts all follow from it. For example, `de-DE` gives a `dd.mm.yyyy` placeholder and accepts `.` as the separator. + +Use the [startOfWeek](./#startOfWeek) prop to override only the first day of the week. + + + +### Controlled State + +::: terms +external state, react state, useState +::: + +Set the initial value using the [`defaultValue`](./#defaultValue) prop, or use [`value`](./#value) and [`onValueChange`](./#onValueChange) to control the value manually. These props follow our [controlled state](/patterns/controlled-state) pattern. + +The value is always an array, and entries can be `null` when a range is partially filled. + + + +### Inline + +::: terms +always open, embedded calendar, flat +::: + +Set [`variant`](./#variant) to `inline` to render an always-visible calendar with no field or popover. This variant never shows action buttons and has no inputs, so it does not submit a value with a native form; read the selection through [`value`](./#value) and [`onValueChange`](./#onValueChange). + +The inline variant shows a headline with the label and current value. Set [`headline`](./#headline) to `false` to remove it. Use [moreLabel](./#moreLabel) and [rangePlaceholder](./#rangePlaceholder) to customize it. + + + +### Custom Trigger + +::: terms +render prop, button trigger, unstyled trigger, api, render prop +::: + +[`DatePicker.Context`](./#date-picker-context) is a render prop that hands you the picker api, so you can drive your own presentation from the picker's state. Use it to read the current value, format your own label, or call actions such as `clearValue`. + +```tsx + + {(api) => {api.valueAsString[0] || "No date selected"}} + +``` + +[`DatePicker.Trigger`](./#date-picker-trigger) pairs with it to replace the field entirely. Pass `render` to supply your own element; it renders a plain ` + ) : null} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-explorer-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-explorer-demo.tsx new file mode 100644 index 000000000..d03b8f7fa --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-explorer-demo.tsx @@ -0,0 +1,25 @@ +import type {ReactElement} from "react" + +import { + DatePicker, + getLocalTimeZone, + parseDate, + today, +} from "@qualcomm-ui/react/date-picker" + +export function DatePickerExplorerDemo(): ReactElement { + return ( + + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-hide-outside-days-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-hide-outside-days-demo.tsx new file mode 100644 index 000000000..c87e160b3 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-hide-outside-days-demo.tsx @@ -0,0 +1,13 @@ +import type {ReactElement} from "react" + +import {DatePicker} from "@qualcomm-ui/react/date-picker" + +export function DatePickerHideOutsideDaysDemo(): ReactElement { + return ( +
+ {/* preview */} + + {/* preview */} +
+ ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-hint-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-hint-demo.tsx new file mode 100644 index 000000000..f459c358a --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-hint-demo.tsx @@ -0,0 +1,24 @@ +import type {ReactElement} from "react" + +import {DatePicker} from "@qualcomm-ui/react/date-picker" + +export function DatePickerHintDemo(): ReactElement { + return ( +
+ {/* preview */} + + + {/* preview */} +
+ ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-hook-form-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-hook-form-demo.tsx new file mode 100644 index 000000000..9e6574003 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-hook-form-demo.tsx @@ -0,0 +1,97 @@ +import type {ReactElement} from "react" + +import {type} from "arktype" +import {Controller, type SubmitHandler, useForm} from "react-hook-form" + +import type {DateValue} from "@qualcomm-ui/core/date-picker" +import {Button} from "@qualcomm-ui/react/button" +import {DatePicker} from "@qualcomm-ui/react/date-picker" +import {createToaster, Toaster} from "@qualcomm-ui/react/toast" + +const valueSchema = type({ + departureDate: type("unknown[] > 0").configure({ + message: "A departure date is required", + }), +}) + +type ValueSchema = typeof valueSchema.infer + +const toaster = createToaster({ + overlap: true, + placement: "bottom-end", +}) + +export function DatePickerHookFormDemo(): ReactElement { + const { + control, + formState: {isSubmitting}, + handleSubmit, + setError, + } = useForm({ + defaultValues: { + departureDate: [], + }, + }) + + const handleFormSubmit: SubmitHandler = (data) => { + const validation = valueSchema(data) + + if (validation instanceof type.errors) { + for (const error of validation) { + const field = error.path?.[0] as keyof ValueSchema + if (field) { + setError(field, { + message: error.message, + }) + } + } + return + } + + toaster.create({ + label: "Form submitted", + type: "success", + }) + } + + return ( + <> + +
void handleSubmit(handleFormSubmit)(event)} + > + ( + onChange(details.value)} + required + value={value as DateValue[]} + {...fieldProps} + /> + )} + /> +
+ +
+ + + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-inline-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-inline-demo.tsx new file mode 100644 index 000000000..a9aef7100 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-inline-demo.tsx @@ -0,0 +1,14 @@ +import type {ReactElement} from "react" + +import {DatePicker} from "@qualcomm-ui/react/date-picker" + +export function DatePickerInlineDemo(): ReactElement { + return ( +
+ {/* preview */} + + + {/* preview */} +
+ ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-locale-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-locale-demo.tsx new file mode 100644 index 000000000..dd2ada7cd --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-locale-demo.tsx @@ -0,0 +1,35 @@ +import {type ReactElement, useState} from "react" + +import {DatePicker} from "@qualcomm-ui/react/date-picker" +import {Radio, RadioGroup} from "@qualcomm-ui/react/radio" + +const locales = [ + {label: "English (US)", value: "en-US"}, + {label: "German", value: "de-DE"}, + {label: "Japanese", value: "ja-JP"}, +] + +export function DatePickerLocaleDemo(): ReactElement { + const [locale, setLocale] = useState("en-US") + + return ( +
+ {/* preview */} + + {/* preview */} + + setLocale(value ?? "en-US")} + orientation="horizontal" + value={locale} + > + Locale + + {locales.map(({label, value}) => ( + + ))} + + +
+ ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-min-max-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-min-max-demo.tsx new file mode 100644 index 000000000..36a3518e0 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-min-max-demo.tsx @@ -0,0 +1,23 @@ +import {type ReactElement, useMemo} from "react" + +import { + DatePicker, + getLocalTimeZone, + today, +} from "@qualcomm-ui/react/date-picker" + +export function DatePickerMinMaxDemo(): ReactElement { + const now = useMemo(() => today(getLocalTimeZone()), []) + + return ( + // preview + + // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-multiple-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-multiple-demo.tsx new file mode 100644 index 000000000..ad2df7264 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-multiple-demo.tsx @@ -0,0 +1,16 @@ +import type {ReactElement} from "react" + +import {DatePicker} from "@qualcomm-ui/react/date-picker" + +export function DatePickerMultipleDemo(): ReactElement { + return ( + // preview + + // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-open-on-click-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-open-on-click-demo.tsx new file mode 100644 index 000000000..55d889264 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-open-on-click-demo.tsx @@ -0,0 +1,11 @@ +import type {ReactElement} from "react" + +import {DatePicker} from "@qualcomm-ui/react/date-picker" + +export function DatePickerOpenOnClickDemo(): ReactElement { + return ( + // preview + + // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-presets-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-presets-demo.tsx new file mode 100644 index 000000000..f63823977 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-presets-demo.tsx @@ -0,0 +1,25 @@ +import type {ReactElement} from "react" + +import {DatePicker, type DatePickerPreset} from "@qualcomm-ui/react/date-picker" + +const presets: DatePickerPreset[] = [ + {label: "Next 7 days", value: "next7Days"}, + {label: "Next 14 days", value: "next14Days"}, + {label: "Next 30 days", value: "next30Days"}, + {label: "Next 90 days", value: "next90Days"}, + {label: "Next week", value: "nextWeek"}, + {label: "Next month", value: "nextMonth"}, + {label: "Next quarter", value: "nextQuarter"}, + {label: "Next year", value: "nextYear"}, +] + +export function DatePickerPresetsDemo(): ReactElement { + return ( + + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-range-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-range-demo.tsx new file mode 100644 index 000000000..315448aec --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-range-demo.tsx @@ -0,0 +1,11 @@ +import type {ReactElement} from "react" + +import {DatePicker} from "@qualcomm-ui/react/date-picker" + +export function DatePickerRangeDemo(): ReactElement { + return ( + // preview + + // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-single-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-single-demo.tsx new file mode 100644 index 000000000..8c9489cf9 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-single-demo.tsx @@ -0,0 +1,11 @@ +import type {ReactElement} from "react" + +import {DatePicker} from "@qualcomm-ui/react/date-picker" + +export function DatePickerSingleDemo(): ReactElement { + return ( + // preview + + // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-sizes-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-sizes-demo.tsx new file mode 100644 index 000000000..78609b856 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-sizes-demo.tsx @@ -0,0 +1,15 @@ +import type {ReactElement} from "react" + +import {DatePicker} from "@qualcomm-ui/react/date-picker" + +export function DatePickerSizesDemo(): ReactElement { + return ( +
+ {/* preview */} + + + + {/* preview */} +
+ ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-states-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-states-demo.tsx new file mode 100644 index 000000000..988130627 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-states-demo.tsx @@ -0,0 +1,33 @@ +import type {ReactElement} from "react" + +import {DatePicker, parseDate} from "@qualcomm-ui/react/date-picker" + +const departureDate = [parseDate("2026-08-14")] + +export function DatePickerStatesDemo(): ReactElement { + return ( +
+ {/* preview */} + + + + {/* preview */} +
+ ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-tanstack-form-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-tanstack-form-demo.tsx new file mode 100644 index 000000000..9392d51aa --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-tanstack-form-demo.tsx @@ -0,0 +1,72 @@ +import type {ReactElement} from "react" + +import {useForm} from "@tanstack/react-form" + +import {Button} from "@qualcomm-ui/react/button" +import {DatePicker, type DateValue} from "@qualcomm-ui/react/date-picker" +import {createToaster, Toaster} from "@qualcomm-ui/react/toast" + +const toaster = createToaster({ + overlap: true, + placement: "bottom-end", +}) + +export function DatePickerTanstackFormDemo(): ReactElement { + const form = useForm({ + defaultValues: { + departureDate: [] as (DateValue | null)[], + }, + onSubmit: () => { + toaster.create({ + label: "Form submitted", + type: "success", + }) + }, + }) + + return ( + <> + +
{ + event.preventDefault() + event.stopPropagation() + void form.handleSubmit() + }} + > + + value.length === 0 ? "A departure date is required" : undefined, + }} + > + {(field) => ( + 0} + label="Departure date" + onValueChange={(details) => field.handleChange(details.value)} + required + value={field.state.value} + /> + )} + +
+ +
+
+ + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-unavailable-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-unavailable-demo.tsx new file mode 100644 index 000000000..0552baa6f --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-unavailable-demo.tsx @@ -0,0 +1,16 @@ +import type {ReactElement} from "react" + +import {DatePicker, isWeekend} from "@qualcomm-ui/react/date-picker" + +export function DatePickerUnavailableDemo(): ReactElement { + return ( + // preview + + // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-within-dialog-demo.tsx b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-within-dialog-demo.tsx new file mode 100644 index 000000000..2ce849234 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/date-picker-within-dialog-demo.tsx @@ -0,0 +1,36 @@ +import type {ReactElement} from "react" + +import {Button} from "@qualcomm-ui/react/button" +import {DatePicker} from "@qualcomm-ui/react/date-picker" +import {Dialog} from "@qualcomm-ui/react/dialog" + +export function DatePickerWithinDialogDemo(): ReactElement { + return ( + + + + + + + Book a Flight + + + + + + + + + + + + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/date-picker+/demos/index.ts b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/index.ts new file mode 100644 index 000000000..fe6c923a4 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/date-picker+/demos/index.ts @@ -0,0 +1,21 @@ +export * from "./date-picker-actions-demo" +export * from "./date-picker-composite-demo" +export * from "./date-picker-controlled-state-demo" +export * from "./date-picker-custom-trigger-demo" +export * from "./date-picker-explorer-demo" +export * from "./date-picker-hide-outside-days-demo" +export * from "./date-picker-hint-demo" +export * from "./date-picker-hook-form-demo" +export * from "./date-picker-inline-demo" +export * from "./date-picker-locale-demo" +export * from "./date-picker-min-max-demo" +export * from "./date-picker-multiple-demo" +export * from "./date-picker-open-on-click-demo" +export * from "./date-picker-presets-demo" +export * from "./date-picker-range-demo" +export * from "./date-picker-single-demo" +export * from "./date-picker-sizes-demo" +export * from "./date-picker-states-demo" +export * from "./date-picker-tanstack-form-demo" +export * from "./date-picker-unavailable-demo" +export * from "./date-picker-within-dialog-demo" diff --git a/packages/docs/react-docs/src/routes/components+/menu+/_menu.mdx b/packages/docs/react-docs/src/routes/components+/menu+/_menu.mdx index abde14d04..36bff1144 100644 --- a/packages/docs/react-docs/src/routes/components+/menu+/_menu.mdx +++ b/packages/docs/react-docs/src/routes/components+/menu+/_menu.mdx @@ -243,11 +243,11 @@ Use the [positioning.hideWhenDetached](./#positioning) prop to hide the menu whe -### PositioningOptions +### MenuPositioningOptions The [positioning](./#positioning) prop accepts an object with the following shape: - + ## Element API diff --git a/packages/docs/react-docs/src/routes/components+/progress-ring+/_progress-ring.mdx b/packages/docs/react-docs/src/routes/components+/progress-ring+/_progress-ring.mdx index 1c1b9961e..337d873e1 100644 --- a/packages/docs/react-docs/src/routes/components+/progress-ring+/_progress-ring.mdx +++ b/packages/docs/react-docs/src/routes/components+/progress-ring+/_progress-ring.mdx @@ -59,7 +59,7 @@ Build with the composite API for granular control. This API requires you to prov completion percentage ::: -When the [value](./#value-1) prop is set, the filled portion of the circle will represent the total percent relative to [max](./#max) and [min](./#min). This is referred to as the "determinate" state. +When the [value](./#value-2) prop is set, the filled portion of the circle will represent the total percent relative to [max](./#max-1) and [min](./#min-1). This is referred to as the "determinate" state. () + readonly positioning = input() /** * The controlled presence of the node. diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-clear-trigger.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-clear-trigger.directive.ts new file mode 100644 index 000000000..277cacfff --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-clear-trigger.directive.ts @@ -0,0 +1,33 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerClearTriggerDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly hostId = computed(() => useId(this, this.id())) + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getClearTriggerBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-content.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-content.directive.ts new file mode 100644 index 000000000..b3f87b843 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-content.directive.ts @@ -0,0 +1,43 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, inject, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import { + PresenceContextService, + usePresenceRenderer, +} from "@qualcomm-ui/angular-core/presence" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerContentDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly hostId = computed(() => useId(this, this.id())) + protected readonly onDestroy = useOnDestroy() + protected readonly presenceService = inject(PresenceContextService) + protected readonly presenceRendererEffect = usePresenceRenderer() + + protected readonly trackBindings = useTrackBindings(() => + mergeProps( + this.datePickerContext().getContentBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + this.presenceService.getPresenceBindings(), + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-control.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-control.directive.ts new file mode 100644 index 000000000..5a4c43465 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-control.directive.ts @@ -0,0 +1,33 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerControlDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly hostId = computed(() => useId(this, this.id())) + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getControlBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-error-indicator.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-error-indicator.directive.ts new file mode 100644 index 000000000..c0375b96e --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-error-indicator.directive.ts @@ -0,0 +1,21 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerErrorIndicatorDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getErrorIndicatorBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-error-text.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-error-text.directive.ts new file mode 100644 index 000000000..484a3ae38 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-error-text.directive.ts @@ -0,0 +1,33 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerErrorTextDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly hostId = computed(() => useId(this, this.id())) + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getErrorTextBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-hint.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-hint.directive.ts new file mode 100644 index 000000000..4368120a2 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-hint.directive.ts @@ -0,0 +1,33 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerHintDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly hostId = computed(() => useId(this, this.id())) + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getHintBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-input.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-input.directive.ts new file mode 100644 index 000000000..3ed5fbdce --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-input.directive.ts @@ -0,0 +1,81 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + booleanAttribute, + computed, + Directive, + inject, + input, + numberAttribute, + type OnInit, +} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {INPUT_FORM_CONTROL_CONTEXT} from "@qualcomm-ui/angular-core/input" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type {DatePickerApiInputProps} from "@qualcomm-ui/core/date-picker" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerInputDirective + implements SignalifyInput, OnInit +{ + /** + * Whether to fix the input value on blur. + * + * @default true + */ + readonly fixOnBlur = input(undefined, { + transform: booleanAttribute, + }) + + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + /** + * The index of the input. Range pickers render one input per boundary, `0` for + * the start date and `1` for the end date. + */ + readonly index = input(undefined, { + transform: numberAttribute, + }) + + protected readonly datePickerContext = useDatePickerContext() + protected readonly hostId = computed(() => useId(this, this.id())) + protected readonly onDestroy = useOnDestroy() + + protected readonly formControlContext = inject(INPUT_FORM_CONTROL_CONTEXT, { + optional: true, + }) + + /** + * Deferred: must land after the machine's blur transition, which `send` queues. + */ + protected readonly reportTouched = () => { + queueMicrotask(() => this.formControlContext?.onTouched?.()) + } + + protected readonly trackBindings = useTrackBindings(() => + mergeProps( + this.datePickerContext().getInputBindings({ + fixOnBlur: this.fixOnBlur(), + id: this.hostId(), + index: this.index(), + onDestroy: this.onDestroy, + }), + {onfocusout: this.reportTouched}, + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-label.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-label.directive.ts new file mode 100644 index 000000000..1355028fe --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-label.directive.ts @@ -0,0 +1,33 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, input, numberAttribute, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type {DatePickerApiLabelProps} from "@qualcomm-ui/core/date-picker" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerLabelDirective + implements SignalifyInput, OnInit +{ + /** + * The index of the input this label describes. Range pickers render one input + * per boundary, `0` for the start date and `1` for the end date. + */ + readonly index = input(undefined, { + transform: numberAttribute, + }) + + protected readonly datePickerContext = useDatePickerContext() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getLabelBindings({index: this.index()}), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-next-trigger.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-next-trigger.directive.ts new file mode 100644 index 000000000..7b0133a8a --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-next-trigger.directive.ts @@ -0,0 +1,41 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type { + DatePickerDateView, + DatePickerApiViewProps, +} from "@qualcomm-ui/core/date-picker" + +import { + useDatePickerContext, + useDatePickerViewContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerNextTriggerDirective + implements SignalifyInput, OnInit +{ + /** + * The view to page. Defaults to the enclosing `View`. + */ + readonly view = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly viewContext = useDatePickerViewContext() + + protected readonly coreBindings = computed(() => + this.datePickerContext().getNextTriggerBindings({ + view: this.view() ?? this.viewContext().view, + }), + ) + + protected readonly trackBindings = useTrackBindings(() => this.coreBindings()) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-positioner.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-positioner.directive.ts new file mode 100644 index 000000000..7faec3469 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-positioner.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import {usePresenceRenderer} from "@qualcomm-ui/angular-core/presence" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerPositionerDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly hostId = computed(() => useId(this, this.id())) + protected readonly onDestroy = useOnDestroy() + protected readonly presenceRendererEffect = usePresenceRenderer() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getPositionerBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-preset-trigger.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-preset-trigger.directive.ts new file mode 100644 index 000000000..7bf0b0a53 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-preset-trigger.directive.ts @@ -0,0 +1,34 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, input, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type { + DatePickerApiPresetTriggerProps, + DatePickerPresetTriggerValue, +} from "@qualcomm-ui/core/date-picker" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerPresetTriggerDirective + implements SignalifyInput, OnInit +{ + /** + * The value applied when the preset is selected. Either a named range preset + * (e.g. `"next7Days"`) or an explicit array of dates. + */ + readonly value = input.required() + + protected readonly datePickerContext = useDatePickerContext() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getPresetTriggerBindings({value: this.value()}), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-presets-trigger.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-presets-trigger.directive.ts new file mode 100644 index 000000000..02c22e4e7 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-presets-trigger.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerPresetsTriggerDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + + protected readonly coreBindings = computed(() => + this.datePickerContext().getPresetsTriggerBindings(), + ) + + protected readonly trackBindings = useTrackBindings(() => this.coreBindings()) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-presets.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-presets.directive.ts new file mode 100644 index 000000000..948705665 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-presets.directive.ts @@ -0,0 +1,21 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerPresetsDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getPresetsBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-prev-trigger.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-prev-trigger.directive.ts new file mode 100644 index 000000000..e23e24c35 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-prev-trigger.directive.ts @@ -0,0 +1,41 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type { + DatePickerDateView, + DatePickerApiViewProps, +} from "@qualcomm-ui/core/date-picker" + +import { + useDatePickerContext, + useDatePickerViewContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerPrevTriggerDirective + implements SignalifyInput, OnInit +{ + /** + * The view to page. Defaults to the enclosing `View`. + */ + readonly view = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly viewContext = useDatePickerViewContext() + + protected readonly coreBindings = computed(() => + this.datePickerContext().getPrevTriggerBindings({ + view: this.view() ?? this.viewContext().view, + }), + ) + + protected readonly trackBindings = useTrackBindings(() => this.coreBindings()) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-range-text.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-range-text.directive.ts new file mode 100644 index 000000000..692c23bec --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-range-text.directive.ts @@ -0,0 +1,21 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerRangeTextDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getRangeTextBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-root.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-root.directive.ts new file mode 100644 index 000000000..102a52726 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-root.directive.ts @@ -0,0 +1,527 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + booleanAttribute, + computed, + Directive, + effect, + inject, + input, + numberAttribute, + type OnInit, + output, + untracked, +} from "@angular/core" + +import {useIsMounted} from "@qualcomm-ui/angular-core/common" +import {AbstractBaseListCollectionFormControlDirective} from "@qualcomm-ui/angular-core/input" +import { + normalizeProps, + useMachine, + useTrackBindings, +} from "@qualcomm-ui/angular-core/machine" +import {PresenceContextService} from "@qualcomm-ui/angular-core/presence" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import { + createDatePickerApi, + type DatePickerApiProps, + datePickerMachine, + type DateValue, + type DatePickerDateView, + type DatePickerFocusChangeDetails, + type DatePickerIntlTranslations, + type DatePickerLocaleDetails, + type DatePickerOpenChangeDetails, + type DatePickerPositioningOptions, + type DatePickerSelectionMode, + type DatePickerValueChangeDetails, + type DatePickerViewChangeDetails, + type DatePickerViewOnSelect, + type DatePickerVisibleRangeChangeDetails, +} from "@qualcomm-ui/core/date-picker" +import { + createPresenceApi, + type PresenceApiProps, + presenceMachine, +} from "@qualcomm-ui/core/presence" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" +import type {Direction} from "@qualcomm-ui/utils/direction" +import {type Explicit, isDefined} from "@qualcomm-ui/utils/guard" + +import { + DatePickerContextService, + DatePickerViewContextService, +} from "./date-picker-context.service" +import {completeRangeValidator} from "./date-picker.validators" + +@Directive() +export class CoreDatePickerRootDirective + extends AbstractBaseListCollectionFormControlDirective + implements + Omit< + SignalifyInput, + "defaultValue" | "numOfMonths" | "value" + >, + SignalifyInput, + OnInit +{ + /** + * Whether the calendar should close after the date selection is complete. + * This is ignored when the selection mode is `multiple`. + * + * @default true + */ + readonly closeOnSelect = input(undefined, { + transform: booleanAttribute, + }) + + /** + * A function that creates a Calendar object for a given calendar identifier. + * Enables non-Gregorian calendar support (Persian, Buddhist, Islamic, etc.) + * without bundling all calendars by default. + */ + readonly createCalendar = input() + + /** + * The initial focused date when rendered. Use when you don't need to control + * the focused date of the date picker. + */ + readonly defaultFocusedValue = input() + + /** + * The initial open state of the date picker when rendered. Use when you don't + * need to control the open state of the date picker. + */ + readonly defaultOpen = input(undefined, { + transform: booleanAttribute, + }) + + /** + * The default view of the calendar. + * + * @default 'day' + */ + readonly defaultView = input() + + /** + * The document's text/writing direction. + * + * @default 'ltr' + */ + readonly dir = input() + + /** + * Whether the calendar should have a fixed number of weeks. This renders the + * calendar with 6 weeks instead of 5 or 6. + */ + readonly fixedWeeks = input(undefined, { + transform: booleanAttribute, + }) + + /** + * The controlled focused date. + */ + readonly focusedValue = input() + + /** + * The format of the date to display in the input. + */ + readonly format = input< + ((date: DateValue, details: DatePickerLocaleDetails) => string) | undefined + >() + + /** + * Whether to synchronize the present change immediately or defer it to the + * next frame. + * + * @default false + */ + readonly immediate = input(undefined, { + transform: booleanAttribute, + }) + + /** + * Whether to render the date picker inline. + */ + readonly inline = input(undefined, { + transform: booleanAttribute, + }) + + /** + * Returns whether a date of the calendar is available. + */ + readonly isDateUnavailable = input< + ((date: DateValue, locale: string) => boolean) | undefined + >() + + /** + * When true, the component will not be rendered in the DOM until it becomes + * visible or active. + * + * @default false + */ + readonly lazyMount = input(undefined, { + transform: booleanAttribute, + }) + + /** + * The locale (BCP 47 language tag) to use when formatting the date. + * + * @default 'en-US' + */ + readonly locale = input() + + /** + * The maximum date that can be selected. + */ + readonly max = input() + + /** + * The maximum number of dates that can be selected. This is only applicable + * when {@link selectionMode} is `multiple`. + */ + readonly maxSelectedDates = input(undefined, { + transform: numberAttribute, + }) + + /** + * The maximum view of the calendar. + * + * @default 'year' + */ + readonly maxView = input() + + /** + * The minimum date that can be selected. + */ + readonly min = input() + + /** + * The minimum view of the calendar. + * + * @default 'day' + */ + readonly minView = input() + + /** + * The controlled open state of the date picker. + */ + readonly open = input(undefined, { + transform: booleanAttribute, + }) + + /** + * Whether to open the calendar when the input is clicked. + * + * @default false + */ + readonly openOnClick = input(undefined, { + transform: booleanAttribute, + }) + + /** + * Whether days outside the visible range can be selected. + * + * @default false + */ + readonly outsideDaySelectable = input( + undefined, + {transform: booleanAttribute}, + ) + + /** + * Function to parse the date from the input back to a `DateValue`. + */ + readonly parse = input< + | (( + value: string, + details: DatePickerLocaleDetails, + ) => DateValue | undefined) + | undefined + >() + + /** + * The placeholder text to display in the input. + */ + readonly placeholder = input() + + /** + * The user provided options used to position the date picker content. + * + * @inheritDoc + */ + readonly positioning = input() + + /** + * The controlled presence of the node. + */ + readonly present = input(undefined) + + /** + * The selection mode of the calendar. + * - `single` - only one date can be selected + * - `multiple` - multiple dates can be selected + * - `range` - a range of dates can be selected + * + * @default 'single' + */ + readonly selectionMode = input() + + /** + * Whether to allow the initial presence animation. + * + * @default false + */ + readonly skipAnimationOnMount = input( + undefined, + {transform: booleanAttribute}, + ) + + /** + * The first day of the week. `0` is Sunday, `6` is Saturday. + */ + readonly startOfWeek = input(undefined, { + transform: numberAttribute, + }) + + /** + * The time zone to use. + * + * @default 'UTC' + */ + readonly timeZone = input() + + /** + * The localized messages to use. + */ + readonly translations = input() + + /** + * When true, the component will be completely removed from the DOM when it + * becomes inactive or hidden, rather than just being hidden with CSS. + * + * @default false + */ + readonly unmountOnExit = input(undefined, { + transform: booleanAttribute, + }) + + /** + * The controlled view of the calendar. + */ + readonly view = input() + + /** + * The view to show after a selection. + * - `previous` - year to month to day + * - `min` - the current view to the minimum view + * + * @default 'min' + */ + readonly viewOnSelect = input() + + /** + * Function called when the animation ends in the closed state. + */ + readonly exitCompleted = output() + + /** + * Function called when the focused date changes. + */ + readonly focusChanged = output() + + /** + * Function called when the calendar opens or closes. + */ + readonly openChanged = output() + + /** + * Function called when the selected date(s) change. + */ + readonly valueChanged = output() + + /** + * Function called when the view changes. + */ + readonly viewChanged = output() + + /** + * Function called when the visible range changes. + */ + readonly visibleRangeChanged = output() + + protected readonly isMounted = useIsMounted() + protected readonly datePickerContext = inject(DatePickerContextService) + protected readonly presenceService = inject(PresenceContextService) + + /** + * Parts rendered outside a `View` still need a view to resolve against. The + * `View` directive provides its own instance, which shadows this one. + */ + protected readonly viewContext = inject(DatePickerViewContextService) + + protected readonly resolvedInline = computed(() => this.inline()) + + protected readonly resolvedCloseOnSelect = computed(() => + this.closeOnSelect(), + ) + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext.context().getRootBindings(), + ) + + constructor() { + super() + + effect(() => { + const control = this.control + if (!control) { + return + } + const needsValidator = + this.selectionMode() === "range" && !!this.isRequired() + const hasValidator = control.hasValidator(completeRangeValidator) + if (needsValidator === hasValidator) { + return + } + if (needsValidator) { + control.addValidators(completeRangeValidator) + } else { + control.removeValidators(completeRangeValidator) + } + }) + + this.onDestroy(() => { + this.control?.removeValidators(completeRangeValidator) + }) + } + + override ngOnInit() { + super.ngOnInit() + + this.viewContext.init(computed(() => ({view: "day"}))) + + const machine = useMachine( + datePickerMachine, + computed>(() => ({ + closeOnSelect: this.resolvedCloseOnSelect(), + createCalendar: this.createCalendar(), + defaultFocusedValue: this.defaultFocusedValue(), + defaultOpen: this.defaultOpen(), + defaultValue: this.defaultValue(), + defaultView: this.defaultView(), + dir: this.dir(), + disabled: this.isDisabled(), + fixedWeeks: this.fixedWeeks(), + focusedValue: this.focusedValue(), + format: this.format(), + inline: this.resolvedInline(), + invalid: this.isInvalid(), + isDateUnavailable: this.isDateUnavailable(), + locale: this.locale(), + max: this.max(), + maxSelectedDates: this.maxSelectedDates(), + maxView: this.maxView(), + min: this.min(), + minView: this.minView(), + name: this.name(), + numOfMonths: undefined, + onFocusChange: (details) => { + if (this.isMounted()) { + this.focusChanged.emit(details) + } + }, + onOpenChange: (details) => { + if (this.isMounted()) { + this.openChanged.emit(details) + if (!details.open) { + this.onTouched() + } + } + }, + onValueChange: (details) => { + if (!this.control) { + if (this.isMounted()) { + this.valueChanged.emit(details) + } + this.value.set(details.value) + return + } + this.onChange(details.value) + if (!this.control?.touched) { + this.control.markAsTouched?.() + } + if (!this.control?.dirty) { + this.control.markAsDirty?.() + } + }, + onViewChange: (details) => { + if (this.isMounted()) { + this.viewChanged.emit(details) + } + }, + onVisibleRangeChange: (details) => { + if (this.isMounted()) { + this.visibleRangeChanged.emit(details) + } + }, + open: this.open(), + openOnClick: this.openOnClick(), + outsideDaySelectable: this.outsideDaySelectable(), + parse: this.parse(), + placeholder: this.placeholder(), + positioning: this.positioning(), + readOnly: this.readOnly(), + required: this.isRequired(), + selectionMode: this.selectionMode(), + startOfWeek: this.startOfWeek(), + timeZone: this.timeZone(), + translations: this.translations(), + value: this.control ? this.value() : undefined, + view: this.view(), + viewOnSelect: this.viewOnSelect(), + })), + this.injector, + ) + + const datePickerApi = computed(() => + createDatePickerApi(machine, normalizeProps), + ) + + this.datePickerContext.init(datePickerApi) + + const presence = useMachine( + presenceMachine, + computed>(() => { + const present = this.present() + const api = datePickerApi() + const showing = isDefined(present) ? present : api.open + untracked(() => { + if (this.presenceService.initialized()) { + this.presenceService.immediatePresent = showing + } + }) + return { + immediate: this.immediate(), + lazyMount: this.lazyMount(), + onExitComplete: () => { + if (this.isMounted()) { + this.exitCompleted.emit() + } + }, + present: showing, + skipAnimationOnMount: this.skipAnimationOnMount(), + unmountOnExit: this.unmountOnExit(), + } + }), + this.injector, + ) + + this.presenceService.init( + computed(() => createPresenceApi(presence, normalizeProps)), + ) + + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-body.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-body.directive.ts new file mode 100644 index 000000000..136b113c0 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-body.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import { + useDatePickerContext, + useDatePickerTableContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerTableBodyDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + protected readonly tableContext = useDatePickerTableContext() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getTableBodyBindings(this.tableContext()), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-cell-trigger.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-cell-trigger.directive.ts new file mode 100644 index 000000000..f85b26efa --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-cell-trigger.directive.ts @@ -0,0 +1,47 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type { + DatePickerApiDayTableCellProps, + DatePickerApiTableCellProps, +} from "@qualcomm-ui/core/date-picker" + +import { + useDatePickerContext, + useDatePickerTableCellContext, + useDatePickerTableContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerTableCellTriggerDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + protected readonly tableContext = useDatePickerTableContext() + protected readonly cellContext = useDatePickerTableCellContext() + + protected readonly trackBindings = useTrackBindings(() => { + const api = this.datePickerContext() + const cellProps = this.cellContext() + + switch (this.tableContext().view) { + case "month": + return api.getMonthTableCellTriggerBindings( + cellProps as DatePickerApiTableCellProps, + ) + case "year": + return api.getYearTableCellTriggerBindings( + cellProps as DatePickerApiTableCellProps, + ) + default: + return api.getDayTableCellTriggerBindings( + cellProps as DatePickerApiDayTableCellProps, + ) + } + }) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-cell.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-cell.directive.ts new file mode 100644 index 000000000..01285507a --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-cell.directive.ts @@ -0,0 +1,96 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + booleanAttribute, + computed, + Directive, + inject, + input, + numberAttribute, + type OnInit, +} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type { + DateValue, + DatePickerApiDayTableCellProps, + DatePickerApiTableCellProps, + DatePickerVisibleRange, +} from "@qualcomm-ui/core/date-picker" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" + +import { + DatePickerTableCellContextService, + useDatePickerContext, + useDatePickerTableContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerTableCellDirective implements OnInit { + /** + * The number of columns in the grid. Only applies to the month and year views. + */ + readonly columns = input(undefined, { + transform: numberAttribute, + }) + + /** + * Whether this cell is not selectable. + */ + readonly disabled = input(undefined, { + transform: booleanAttribute, + }) + + /** + * The cell value. A `DateValue` in the day view, the month or year number + * otherwise. + */ + readonly value = input.required() + + /** + * The visible range of the enclosing grid. Only applies to the day view. + */ + readonly visibleRange = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly tableContext = useDatePickerTableContext() + protected readonly cellContextService = inject( + DatePickerTableCellContextService, + ) + + protected readonly trackBindings = useTrackBindings(() => { + const api = this.datePickerContext() + const cellProps = this.cellContextService.context() + + switch (this.tableContext().view) { + case "month": + return api.getMonthTableCellBindings( + cellProps as DatePickerApiTableCellProps, + ) + case "year": + return api.getYearTableCellBindings( + cellProps as DatePickerApiTableCellProps, + ) + default: + return api.getDayTableCellBindings( + cellProps as DatePickerApiDayTableCellProps, + ) + } + }) + + ngOnInit() { + this.cellContextService.init( + computed( + () => + ({ + columns: this.columns(), + disabled: this.disabled(), + value: this.value(), + visibleRange: this.visibleRange(), + }) as DatePickerApiDayTableCellProps | DatePickerApiTableCellProps, + ), + ) + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-head.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-head.directive.ts new file mode 100644 index 000000000..b700fe69b --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-head.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import { + useDatePickerContext, + useDatePickerTableContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerTableHeadDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + protected readonly tableContext = useDatePickerTableContext() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getTableHeadBindings(this.tableContext()), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-header.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-header.directive.ts new file mode 100644 index 000000000..91e2a0624 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-header.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import { + useDatePickerContext, + useDatePickerTableContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerTableHeaderDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + protected readonly tableContext = useDatePickerTableContext() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getTableHeaderBindings(this.tableContext()), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-row.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-row.directive.ts new file mode 100644 index 000000000..de7d793c7 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table-row.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import { + useDatePickerContext, + useDatePickerTableContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerTableRowDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + protected readonly tableContext = useDatePickerTableContext() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getTableRowBindings(this.tableContext()), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-table.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table.directive.ts new file mode 100644 index 000000000..f891bebc9 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-table.directive.ts @@ -0,0 +1,67 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + computed, + Directive, + inject, + input, + numberAttribute, + type OnInit, +} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type { + DatePickerDateView, + DatePickerApiTableProps, +} from "@qualcomm-ui/core/date-picker" + +import { + DatePickerTableContextService, + useDatePickerContext, + useDatePickerViewContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerTableDirective + implements SignalifyInput, OnInit +{ + /** + * The number of columns in the grid. + */ + readonly columns = input(undefined, { + transform: numberAttribute, + }) + + /** + * The calendar view this grid renders. Defaults to the enclosing `View`. + */ + readonly view = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly viewContext = useDatePickerViewContext() + protected readonly tableContextService = inject(DatePickerTableContextService) + + protected readonly resolvedView = computed( + () => this.view() ?? this.viewContext().view, + ) + + protected readonly resolvedColumns = computed(() => this.columns()) + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getTableBindings( + this.tableContextService.context(), + ), + ) + + ngOnInit() { + this.tableContextService.init( + computed(() => ({ + columns: this.resolvedColumns(), + view: this.resolvedView(), + })), + ) + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-trigger.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-trigger.directive.ts new file mode 100644 index 000000000..3cd81fb48 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-trigger.directive.ts @@ -0,0 +1,33 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerTriggerDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly hostId = computed(() => useId(this, this.id())) + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getTriggerBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-view-close-trigger.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-view-close-trigger.directive.ts new file mode 100644 index 000000000..abc623dc9 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-view-close-trigger.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useDatePickerContext} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerViewCloseTriggerDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + + protected readonly coreBindings = computed(() => + this.datePickerContext().getViewCloseTriggerBindings(), + ) + + protected readonly trackBindings = useTrackBindings(() => this.coreBindings()) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-view-control.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-view-control.directive.ts new file mode 100644 index 000000000..c870dfdd8 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-view-control.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import { + useDatePickerContext, + useDatePickerViewContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerViewControlDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + protected readonly viewContext = useDatePickerViewContext() + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getViewControlBindings(this.viewContext()), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-view-trigger.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-view-trigger.directive.ts new file mode 100644 index 000000000..f2b8fb3ae --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-view-trigger.directive.ts @@ -0,0 +1,37 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {DatePickerDateView} from "@qualcomm-ui/core/date-picker" + +import { + useDatePickerContext, + useDatePickerViewContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerViewTriggerDirective implements OnInit { + /** + * Switch directly to this view when activated. When omitted, the trigger + * toggles to the next view. + */ + readonly view = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly viewContext = useDatePickerViewContext() + + protected readonly coreBindings = computed(() => + this.datePickerContext().getViewTriggerBindings({ + goToView: this.view(), + view: this.viewContext().view, + }), + ) + + protected readonly trackBindings = useTrackBindings(() => this.coreBindings()) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/core-date-picker-view.directive.ts b/packages/frameworks/angular-core/src/date-picker/core-date-picker-view.directive.ts new file mode 100644 index 000000000..c45e6503b --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/core-date-picker-view.directive.ts @@ -0,0 +1,40 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, inject, input, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type { + DatePickerDateView, + DatePickerApiViewProps, +} from "@qualcomm-ui/core/date-picker" + +import { + DatePickerViewContextService, + useDatePickerContext, +} from "./date-picker-context.service" + +@Directive() +export class CoreDatePickerViewDirective + implements SignalifyInput, OnInit +{ + /** + * The calendar view this element renders. + * + * @default 'day' + */ + readonly view = input() + + protected readonly datePickerContext = useDatePickerContext() + protected readonly viewContextService = inject(DatePickerViewContextService) + + protected readonly trackBindings = useTrackBindings(() => + this.datePickerContext().getViewBindings(this.viewContextService.context()), + ) + + ngOnInit() { + this.viewContextService.init(computed(() => ({view: this.view()}))) + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/date-picker/date-picker-context.service.ts b/packages/frameworks/angular-core/src/date-picker/date-picker-context.service.ts new file mode 100644 index 000000000..be7c32a2e --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/date-picker-context.service.ts @@ -0,0 +1,72 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Injectable} from "@angular/core" + +import { + type ApiContext, + BaseApiContextService, + createApiContext, +} from "@qualcomm-ui/angular-core/machine" +import type { + DatePickerApi, + DatePickerApiDayTableCellProps, + DatePickerApiTableCellProps, + DatePickerApiTableProps, + DatePickerApiViewProps, +} from "@qualcomm-ui/core/date-picker" + +@Injectable() +export class DatePickerContextService extends BaseApiContextService {} + +export const [ + DATE_PICKER_CONTEXT, + useDatePickerContext, + provideDatePickerContext, +]: ApiContext = createApiContext( + "DatePickerContext", + DatePickerContextService, +) + +@Injectable() +export class DatePickerViewContextService extends BaseApiContextService {} + +export const [ + DATE_PICKER_VIEW_CONTEXT, + useDatePickerViewContext, + provideDatePickerViewContext, +]: ApiContext = + createApiContext( + "DatePickerViewContext", + DatePickerViewContextService, + ) + +@Injectable() +export class DatePickerTableContextService extends BaseApiContextService {} + +export const [ + DATE_PICKER_TABLE_CONTEXT, + useDatePickerTableContext, + provideDatePickerTableContext, +]: ApiContext = + createApiContext( + "DatePickerTableContext", + DatePickerTableContextService, + ) + +export type DatePickerTableCellContext = + | DatePickerApiDayTableCellProps + | DatePickerApiTableCellProps + +@Injectable() +export class DatePickerTableCellContextService extends BaseApiContextService {} + +export const [ + DATE_PICKER_TABLE_CELL_CONTEXT, + useDatePickerTableCellContext, + provideDatePickerTableCellContext, +]: ApiContext = + createApiContext( + "DatePickerTableCellContext", + DatePickerTableCellContextService, + ) diff --git a/packages/frameworks/angular-core/src/date-picker/date-picker.validators.ts b/packages/frameworks/angular-core/src/date-picker/date-picker.validators.ts new file mode 100644 index 000000000..1458073e8 --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/date-picker.validators.ts @@ -0,0 +1,16 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {AbstractControl, ValidationErrors} from "@angular/forms" + +export function completeRangeValidator( + control: AbstractControl, +): ValidationErrors | null { + const value = control.value as unknown[] | null | undefined + if (!Array.isArray(value) || value.length === 0) { + return null + } + return value[0] != null && value[1] != null + ? null + : {required: {value: control.value}} +} diff --git a/packages/frameworks/angular-core/src/date-picker/index.ts b/packages/frameworks/angular-core/src/date-picker/index.ts new file mode 100644 index 000000000..849e5d19b --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/index.ts @@ -0,0 +1,29 @@ +export * from "./core-date-picker-clear-trigger.directive" +export * from "./core-date-picker-content.directive" +export * from "./core-date-picker-control.directive" +export * from "./core-date-picker-error-indicator.directive" +export * from "./core-date-picker-error-text.directive" +export * from "./core-date-picker-hint.directive" +export * from "./core-date-picker-input.directive" +export * from "./core-date-picker-label.directive" +export * from "./core-date-picker-next-trigger.directive" +export * from "./core-date-picker-positioner.directive" +export * from "./core-date-picker-preset-trigger.directive" +export * from "./core-date-picker-presets-trigger.directive" +export * from "./core-date-picker-presets.directive" +export * from "./core-date-picker-prev-trigger.directive" +export * from "./core-date-picker-range-text.directive" +export * from "./core-date-picker-root.directive" +export * from "./core-date-picker-table-body.directive" +export * from "./core-date-picker-table-cell-trigger.directive" +export * from "./core-date-picker-table-cell.directive" +export * from "./core-date-picker-table-head.directive" +export * from "./core-date-picker-table-header.directive" +export * from "./core-date-picker-table-row.directive" +export * from "./core-date-picker-table.directive" +export * from "./core-date-picker-trigger.directive" +export * from "./core-date-picker-view-close-trigger.directive" +export * from "./core-date-picker-view-control.directive" +export * from "./core-date-picker-view-trigger.directive" +export * from "./core-date-picker-view.directive" +export * from "./date-picker-context.service" diff --git a/packages/frameworks/angular-core/src/date-picker/ng-package.json b/packages/frameworks/angular-core/src/date-picker/ng-package.json new file mode 100644 index 000000000..7e82164ba --- /dev/null +++ b/packages/frameworks/angular-core/src/date-picker/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/frameworks/angular-core/src/input/abstract-list-collection-form-control.directive.ts b/packages/frameworks/angular-core/src/input/abstract-list-collection-form-control.directive.ts index dc7ba2481..8009aaba5 100644 --- a/packages/frameworks/angular-core/src/input/abstract-list-collection-form-control.directive.ts +++ b/packages/frameworks/angular-core/src/input/abstract-list-collection-form-control.directive.ts @@ -316,14 +316,17 @@ export abstract class AbstractBaseListCollectionFormControlDirective< registerOnChange(fn: (value: any) => void): void { this.onChange = (_value) => { - fn(_value) + // set first: a parent's rollback in `valueChanges` must land last this.value.set(_value ?? []) + fn(_value) } } protected onTouched = () => {} registerOnTouched(fn: () => void): void { this.onTouched = fn + // let descendant parts that own a focusable element report blur + this.formControlContext.onTouched = fn } /** diff --git a/packages/frameworks/angular-core/src/input/input-form-control-provider.ts b/packages/frameworks/angular-core/src/input/input-form-control-provider.ts index 9b3573a5d..dfc493b57 100644 --- a/packages/frameworks/angular-core/src/input/input-form-control-provider.ts +++ b/packages/frameworks/angular-core/src/input/input-form-control-provider.ts @@ -25,6 +25,12 @@ export interface InputFormControlContext { /** `true` when used with template-driven forms (`[(ngModel)]`, `ngModel`). */ isTemplateDrivenForm: () => boolean ngControl: NgControl | null + + /** + * The CVA touch callback. Unlike `markAsTouched`, it also flushes the pending + * value of an `updateOn: 'blur'` control. + */ + onTouched?: () => void } export type FormControlKnownErrors = Record< diff --git a/packages/frameworks/angular-core/src/machine/api-context.directive.ts b/packages/frameworks/angular-core/src/machine/api-context.directive.ts index 88ddc871b..b90517856 100644 --- a/packages/frameworks/angular-core/src/machine/api-context.directive.ts +++ b/packages/frameworks/angular-core/src/machine/api-context.directive.ts @@ -39,17 +39,30 @@ export abstract class ApiContextDirective { return } - effect( - () => { - this.viewContainerRef.clear() - const apiInstance = contextService.context() + /** + * A getter, not a snapshot: tracking the read inside the view lets Angular + * update on API change without destroying and rebuilding the DOM. + */ + const viewContext = { + get $implicit() { + return contextService.context() + }, + get [contextName]() { + return contextService.context() + }, + } - if (apiInstance) { - this.viewContainerRef.createEmbeddedView(this.templateRef, { - $implicit: apiInstance, - [contextName]: apiInstance, - }) + effect( + (onCleanup) => { + if (!contextService.initialized()) { + return } + + const view = this.viewContainerRef.createEmbeddedView( + this.templateRef, + viewContext, + ) + onCleanup(() => view.destroy()) }, {injector: this.injector}, ) diff --git a/packages/frameworks/angular-core/src/machine/api-context.spec.ts b/packages/frameworks/angular-core/src/machine/api-context.spec.ts new file mode 100644 index 000000000..1335e4039 --- /dev/null +++ b/packages/frameworks/angular-core/src/machine/api-context.spec.ts @@ -0,0 +1,106 @@ +import { + Component, + computed, + Directive, + inject, + Injectable, + signal, +} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test} from "vitest" +import {page} from "vitest/browser" + +import {ApiContextDirective} from "./api-context.directive" +import {BaseApiContextService} from "./api-context.service" + +interface CounterApi { + count: number + increment: () => void +} + +@Injectable() +class CounterContextService extends BaseApiContextService {} + +@Directive({ + providers: [CounterContextService], + selector: "[counterRoot]", +}) +class CounterRootDirective { + private readonly contextService = inject(CounterContextService) + private readonly count = signal(0) + + constructor() { + this.contextService.init( + computed(() => ({ + count: this.count(), + increment: () => this.count.update((count) => count + 1), + })), + ) + } +} + +@Directive({selector: "[counterContext]"}) +class CounterContextDirective extends ApiContextDirective { + constructor() { + super(inject(CounterContextService), "counterContext") + } + + static ngTemplateContextGuard( + _dir: CounterContextDirective, + _ctx: unknown, + ): _ctx is {$implicit: CounterApi} { + return true + } +} + +@Component({ + imports: [CounterContextDirective, CounterRootDirective], + template: ` +
+ + + +
+ `, +}) +class CounterComponent {} + +const counter = () => page.getByRole("button") + +describe("ApiContextDirective", () => { + test("renders the api in the projected template", async () => { + await render(CounterComponent) + + await expect.element(counter()).toHaveTextContent("count: 0") + }) + + test("updates the projected template when the api changes", async () => { + await render(CounterComponent) + + await counter().click() + + await expect.element(counter()).toHaveTextContent("count: 1") + }) + + test("keeps the projected element when the api changes", async () => { + await render(CounterComponent) + const initial = counter().element() + + await counter().click() + await expect.element(counter()).toHaveTextContent("count: 1") + + expect(counter().element()).toBe(initial) + expect(initial.isConnected).toBe(true) + }) + + test("keeps focus on the projected element when the api changes", async () => { + await render(CounterComponent) + + await counter().click() + await expect.element(counter()).toHaveTextContent("count: 1") + + await expect.element(counter()).toHaveFocus() + }) +}) diff --git a/packages/frameworks/angular-core/src/machine/machine.spec.ts b/packages/frameworks/angular-core/src/machine/machine.spec.ts index aaa18a607..b31ab696d 100644 --- a/packages/frameworks/angular-core/src/machine/machine.spec.ts +++ b/packages/frameworks/angular-core/src/machine/machine.spec.ts @@ -699,4 +699,112 @@ describe("Machine", () => { collection.remove("item-1") expect(collection.get("item-1")).toBeUndefined() }) + + test("ids: outgoing element does not clear a newer registration", async () => { + interface Schema extends MachineSchema { + ids: { + trigger: string + } + state: "idle" + } + + const machine = createMachine({ + ids: ({bindableId}) => ({ + trigger: bindableId(), + }), + initialState() { + return "idle" + }, + states: { + idle: {}, + }, + }) + + const api = await renderMachine(machine) + + let destroyButton: (() => void) | undefined + api.scope.ids.register("trigger", "button-trigger", (callback) => { + destroyButton = callback + }) + expect(api.scope.ids.get("trigger")).toBe("button-trigger") + + // the replacement registers before the outgoing element is destroyed + api.scope.ids.register("trigger", "field-trigger", () => {}) + destroyButton?.() + + expect(api.scope.ids.get("trigger")).toBe("field-trigger") + }) + + test("ids: outgoing element does not clear a newer registration (object overload)", async () => { + interface Schema extends MachineSchema { + ids: { + trigger: string + } + state: "idle" + } + + const machine = createMachine({ + ids: ({bindableId}) => ({ + trigger: bindableId(), + }), + initialState() { + return "idle" + }, + states: { + idle: {}, + }, + }) + + const api = await renderMachine(machine) + + let destroyButton: (() => void) | undefined + api.scope.ids.register("trigger", { + id: "button-trigger", + onDestroy: (callback) => { + destroyButton = callback + }, + }) + expect(api.scope.ids.get("trigger")).toBe("button-trigger") + + api.scope.ids.register("trigger", { + id: "field-trigger", + onDestroy: () => {}, + }) + destroyButton?.() + + expect(api.scope.ids.get("trigger")).toBe("field-trigger") + }) + + test("ids: destroying the current element clears the registration", async () => { + interface Schema extends MachineSchema { + ids: { + trigger: string + } + state: "idle" + } + + const machine = createMachine({ + ids: ({bindableId}) => ({ + trigger: bindableId(), + }), + initialState() { + return "idle" + }, + states: { + idle: {}, + }, + }) + + const api = await renderMachine(machine) + + let destroyTrigger: (() => void) | undefined + api.scope.ids.register("trigger", "button-trigger", (callback) => { + destroyTrigger = callback + }) + expect(api.scope.ids.get("trigger")).toBe("button-trigger") + + destroyTrigger?.() + + expect(api.scope.ids.get("trigger")).toBeUndefined() + }) }) diff --git a/packages/frameworks/angular-core/src/machine/use-machine.ts b/packages/frameworks/angular-core/src/machine/use-machine.ts index 767988299..116a5b167 100644 --- a/packages/frameworks/angular-core/src/machine/use-machine.ts +++ b/packages/frameworks/angular-core/src/machine/use-machine.ts @@ -82,6 +82,17 @@ export function useMachine( return configIds?.[key]?.get?.() }, register(key, valueOrParams, onDestroy?: any) { + /** + * A replacement element registers before the outgoing element's cleanup + * runs. Clearing unconditionally would discard the newer registration. + */ + const clearIfCurrentId = (id: unknown) => () => { + if (configIds?.[key]?.get?.() !== id) { + return + } + configIds?.[key].set?.(undefined) + } + if ( typeof valueOrParams === "object" && valueOrParams !== null && @@ -89,10 +100,10 @@ export function useMachine( ) { const params = valueOrParams configIds?.[key].set?.(params.id) - params.onDestroy?.(() => configIds?.[key].set?.(undefined)) + params.onDestroy?.(clearIfCurrentId(params.id)) } else { configIds?.[key].set?.(valueOrParams) - onDestroy?.(() => configIds?.[key].set?.(undefined)) + onDestroy?.(clearIfCurrentId(valueOrParams)) } }, set(key, value) { diff --git a/packages/frameworks/angular-core/src/menu/core-menu-root.directive.ts b/packages/frameworks/angular-core/src/menu/core-menu-root.directive.ts index ccc3111d6..381e04a7a 100644 --- a/packages/frameworks/angular-core/src/menu/core-menu-root.directive.ts +++ b/packages/frameworks/angular-core/src/menu/core-menu-root.directive.ts @@ -26,6 +26,7 @@ import { type MenuApiProps, menuMachine, type MenuNavigateDetails, + type MenuPositioningOptions, } from "@qualcomm-ui/core/menu" import { createPresenceApi, @@ -33,7 +34,6 @@ import { presenceMachine, } from "@qualcomm-ui/core/presence" import type {LayerDismissEvent} from "@qualcomm-ui/dom/dismissable" -import type {PositioningOptions} from "@qualcomm-ui/dom/floating-ui" import type { FocusOutsideEvent, InteractOutsideEvent, @@ -137,7 +137,7 @@ export class CoreMenuRootDirective * The options used to dynamically position the menu * @inheritDoc */ - readonly positioning = input() + readonly positioning = input() /** * Whether the pressing printable characters should trigger typeahead navigation diff --git a/packages/frameworks/angular-core/src/popover/core-popover-root.directive.ts b/packages/frameworks/angular-core/src/popover/core-popover-root.directive.ts index 7b37ce883..c4cdc97dc 100644 --- a/packages/frameworks/angular-core/src/popover/core-popover-root.directive.ts +++ b/packages/frameworks/angular-core/src/popover/core-popover-root.directive.ts @@ -26,9 +26,9 @@ import { createPopoverApi, type PopoverApiProps, popoverMachine, + type PopoverPositioningOptions, } from "@qualcomm-ui/core/popover" import type {LayerDismissEvent} from "@qualcomm-ui/dom/dismissable" -import type {PositioningOptions} from "@qualcomm-ui/dom/floating-ui" import type { FocusOutsideEvent, InteractOutsideEvent, @@ -120,7 +120,7 @@ export class CorePopoverRootDirective * * @inheritDoc */ - readonly positioning = input() + readonly positioning = input() /** * On close, restore focus to the element that triggered the open event. diff --git a/packages/frameworks/angular-core/src/select/core-select-root.directive.ts b/packages/frameworks/angular-core/src/select/core-select-root.directive.ts index f893a4592..c87f67ae5 100644 --- a/packages/frameworks/angular-core/src/select/core-select-root.directive.ts +++ b/packages/frameworks/angular-core/src/select/core-select-root.directive.ts @@ -26,8 +26,8 @@ import { type ScrollToIndexDetails, type SelectApiProps, selectMachine, + type SelectPositioningOptions, } from "@qualcomm-ui/core/select" -import type {PositioningOptions} from "@qualcomm-ui/dom/floating-ui" import type { FocusOutsideEvent, InteractOutsideEvent, @@ -145,7 +145,7 @@ export class CoreSelectRootDirective * * @inheritDoc */ - readonly positioning = input() + readonly positioning = input() /** * Function to scroll to a specific index diff --git a/packages/frameworks/angular-core/src/signals/access-signal.ts b/packages/frameworks/angular-core/src/signals/access-signal.ts index b70b617d4..6bab3ceae 100644 --- a/packages/frameworks/angular-core/src/signals/access-signal.ts +++ b/packages/frameworks/angular-core/src/signals/access-signal.ts @@ -13,8 +13,5 @@ import type {MaybeSignal} from "./signals.types" * @return {T} The resolved value of the signal if the input is a signal, or the input value itself if it is not a signal. */ export function accessSignal(maybeSignal: MaybeSignal): T { - if (!maybeSignal) { - return maybeSignal - } return isSignal(maybeSignal) ? maybeSignal() : maybeSignal } diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-actions.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-actions.spec.ts new file mode 100644 index 000000000..0d84550ce --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-actions.spec.ts @@ -0,0 +1,284 @@ +import {Component, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" +import type {DatePickerValueChangeDetails} from "@qualcomm-ui/core/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +const seeded = parseDate("2024-06-15") +const prior = [parseDate("2024-06-10")] + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +function closeOnSelectDefaultTemplate() { + return `` +} + +function multipleSelectionTemplate() { + return ` + + ` +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent { + readonly valueChangedHandler = output() + protected readonly prior = prior + protected readonly seeded = seeded + } + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent { + readonly valueChangedHandler = output() + protected readonly prior = prior + protected readonly seeded = seeded + } + return SimpleComponent +} + +function closeOnSelectDefault() { + @Component({ + imports: [DatePickerModule], + template: closeOnSelectDefaultTemplate(), + }) + class DefaultComponent { + protected readonly seeded = seeded + } + return DefaultComponent +} + +function multipleSelection() { + @Component({ + imports: [DatePickerModule], + template: multipleSelectionTemplate(), + }) + class MultipleComponent { + protected readonly seeded = seeded + } + return MultipleComponent +} + +const grid = () => page.getByRole("grid") +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() +const okTrigger = () => page.getByRole("button", {name: "OK"}) +const cancelTrigger = () => page.getByRole("button", {name: "Cancel"}) + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`closeOnSelect=false keeps the calendar open after a selection — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(grid()).toBeVisible() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`OK closes the calendar and keeps the selection — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await okTrigger().click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => + composite( + `[defaultValue]="prior" (valueChanged)="valueChangedHandler.emit($event)"`, + ), + simple: () => + simple( + `[defaultValue]="prior" (valueChanged)="valueChangedHandler.emit($event)"`, + ), + testCase: (component) => { + test(`Cancel discards the pending selection and restores the value the calendar opened with — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: { + valueChangedHandler: (details) => { + valueChanged(details) + }, + }, + }) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + + await cancelTrigger().click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/10/2024") + await expect + .poll(() => valueChanged) + .toHaveBeenLastCalledWith( + expect.objectContaining({valueAsString: ["06/10/2024"]}), + ) + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`Cancel with no prior value leaves the field empty — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await cancelTrigger().click() + + await expect.element(page.getByRole("textbox")).toHaveValue("") + }) + }, + }, + { + composite: () => composite(`[defaultValue]="prior"`), + simple: () => simple(`[defaultValue]="prior"`), + testCase: (component) => { + test(`a reopened calendar takes a fresh snapshot, so Cancel restores the previously confirmed value — ${component.name}`, async () => { + await render(component) + + await openCalendar() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await okTrigger().click() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + + await openCalendar() + await page.getByRole("gridcell", {name: /June 25, 2024/}).click() + await cancelTrigger().click() + + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + }) + }, + }, +] + +describe("DatePicker - Actions", () => { + runTests(tests) + + test("the actions footer is omitted while closeOnSelect is left at its default", async () => { + await render(closeOnSelectDefault()) + await openCalendar() + + await expect.element(grid()).toBeVisible() + await expect.element(okTrigger()).not.toBeInTheDocument() + await expect.element(cancelTrigger()).not.toBeInTheDocument() + }) + + test("multiple mode renders the actions footer so the popover survives each toggle", async () => { + await render(multipleSelection()) + // in multiple mode the field itself is the trigger + await page.getByRole("combobox", {name: "Dates"}).click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(grid()).toBeVisible() + await okTrigger().click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(page.getByText("06/10/2024")).toBeVisible() + await expect.element(page.getByText("06/20/2024")).toBeVisible() + }) + + test("Escape behaves like Cancel, discarding the pending selection", async () => { + await render(simple(`[defaultValue]="prior"`)) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await userEvent.keyboard("{Escape}") + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(page.getByRole("textbox")).toHaveValue("06/10/2024") + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-api.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-api.spec.ts new file mode 100644 index 000000000..a378bce88 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-api.spec.ts @@ -0,0 +1,432 @@ +import {Component, signal} from "@angular/core" +import {render} from "@testing-library/angular" +import {afterEach, beforeEach, describe, expect, test, vi} from "vitest" +import {page} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" +import type {DatePickerApi} from "@qualcomm-ui/core/date-picker" + +const seeded = parseDate("2024-06-15") + +const valueChanged = vi.fn() + +const dayView = ` +
+ + + +
+
+` + +interface ApiHostConfig { + /** Above the control, so the downward popover cannot cover it. */ + above?: string + + /** Inside `Content`; outside it, the click would dismiss before it lands. */ + inContent?: string + rootAttrs?: string +} + +function withContext(content: string | undefined) { + return content + ? `${content}` + : "" +} + +function apiHostTemplate(config: ApiHostConfig) { + return ` +
+ ${withContext(config.above)} +
+
+
+ +
+
+ ${dayView} + ${withContext(config.inContent)} +
+
+
+
+ ` +} + +function inlineHostTemplate(above: string) { + return ` +
+ ${withContext(above)} +
+ ${dayView} +
+ + +
+
+
+ + +
+
+
+
+ ` +} + +function apiHost(config: ApiHostConfig) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: apiHostTemplate(config), + }) + class ApiHostComponent { + readonly d = parseDate + readonly max = parseDate("2024-06-20") + readonly maxOnly = parseDate("2020-01-31") + readonly min = parseDate("2024-06-10") + readonly output = signal("") + readonly seeded = seeded + readonly value = [seeded] + readonly valueChanged = valueChanged + + resolvePreset(api: DatePickerApi) { + this.output.set( + api + .getRangePresetValue("thisMonth") + .map((date) => date.toString()) + .join(","), + ) + } + } + return ApiHostComponent +} + +function inlineHost(above: string) { + @Component({ + imports: [DatePickerModule], + template: inlineHostTemplate(above), + }) + class InlineHostComponent { + readonly d = parseDate + readonly monthYear: Intl.DateTimeFormatOptions = { + month: "long", + year: "numeric", + } + readonly output = signal("") + readonly seeded = seeded + } + return InlineHostComponent +} + +const input = () => page.getByRole("textbox") +const grid = () => page.getByRole("grid") +const status = () => page.getByTestId("status") +const act = (name: string) => page.getByRole("button", {name}).click() + +describe("DatePicker - API", () => { + beforeEach(() => { + valueChanged.mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test("selectToday uses the configured time zone", async () => { + vi.setSystemTime(new Date("2024-06-01T00:30:00Z")) + await render( + apiHost({ + above: ``, + rootAttrs: `timeZone="America/Los_Angeles"`, + }), + ) + + await act("Today") + + await expect.element(input()).toHaveValue("05/31/2024") + }) + + test("selectToday clamps to max when today is out of range", async () => { + vi.setSystemTime(new Date("2024-06-01T12:00:00Z")) + await render( + apiHost({ + above: ``, + rootAttrs: `[max]="maxOnly"`, + }), + ) + + await act("Today") + + await expect.element(input()).toHaveValue("01/31/2020") + }) + + test("setValue constrains the given date to min and max", async () => { + await render( + apiHost({ + above: ``, + rootAttrs: `[max]="max" [min]="min"`, + }), + ) + + await act("Set") + + await expect.element(input()).toHaveValue("06/20/2024") + }) + + test("setValue orders a reversed range instead of storing it as given", async () => { + await render( + apiHost({ + above: ``, + rootAttrs: `selectionMode="range" (valueChanged)="valueChanged($event)"`, + }), + ) + + await act("Set") + + expect(valueChanged).toHaveBeenCalledWith( + expect.objectContaining({ + valueAsString: ["06/10/2024", "06/20/2024"], + }), + ) + }) + + test("setValue keeps only the first date in single mode", async () => { + await render( + apiHost({ + above: ``, + rootAttrs: `(valueChanged)="valueChanged($event)"`, + }), + ) + + await act("Set") + + expect(valueChanged).toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["06/10/2024"]}), + ) + }) + + test("clearValue empties the selection", async () => { + await render( + apiHost({ + above: ``, + rootAttrs: `[defaultValue]="value"`, + }), + ) + await expect.element(input()).toHaveValue("06/15/2024") + + await act("Wipe") + + await expect.element(input()).toHaveValue("") + }) + + test("setOpen drives the calendar without a trigger click", async () => { + await render( + apiHost({ + above: ``, + inContent: ``, + }), + ) + + await act("Show") + await expect.element(grid()).toBeVisible() + + await act("Hide") + await expect.element(grid()).not.toBeInTheDocument() + }) + + test("setFocusedValue moves the visible month", async () => { + await render( + inlineHost( + ``, + ), + ) + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .toBeVisible() + + await act("Jump") + + await expect + .element(page.getByRole("gridcell", {name: /September 5, 2024/})) + .toBeVisible() + await expect + .element(page.getByLabelText(/September 5, 2024/)) + .toHaveAttribute("data-focus") + }) + + test("toggleValue adds then removes a date in multiple mode", async () => { + await render( + apiHost({ + above: ``, + rootAttrs: `selectionMode="multiple" (valueChanged)="valueChanged($event)"`, + }), + ) + + await act("Toggle") + expect(valueChanged).toHaveBeenLastCalledWith( + expect.objectContaining({valueAsString: ["06/20/2024"]}), + ) + + await act("Toggle") + expect(valueChanged).toHaveBeenLastCalledWith( + expect.objectContaining({valueAsString: []}), + ) + }) + + test("toggleValue is ignored while readOnly", async () => { + await render( + apiHost({ + above: ``, + rootAttrs: `readOnly selectionMode="multiple" (valueChanged)="valueChanged($event)"`, + }), + ) + + await act("Toggle") + + expect(valueChanged).not.toHaveBeenCalled() + }) + + test("setTime attaches a time to the selected date and keeps it on reselection", async () => { + await render( + apiHost({ + above: ` + {{ api.value[0]?.toString() }} + + `, + rootAttrs: `[closeOnSelect]="false" [defaultValue]="value"`, + }), + ) + + await act("Set time") + await expect.element(input()).toHaveValue("06/15/2024") + await expect.element(status()).toHaveTextContent("2024-06-15T09:30") + + await act("Change date") + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(input()).toHaveValue("06/20/2024") + await expect.element(status()).toHaveTextContent("2024-06-20T09:30") + }) + + test("cancel reverts to the value the calendar opened with", async () => { + await render( + apiHost({ + inContent: ``, + rootAttrs: `[closeOnSelect]="false" [defaultValue]="[d('2024-06-10')]"`, + }), + ) + + await act("Change date") + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect.element(input()).toHaveValue("06/20/2024") + + await act("Discard") + + await expect.element(input()).toHaveValue("06/10/2024") + }) + + test("getRangePresetValue resolves a named preset to a pair of dates", async () => { + vi.setSystemTime(new Date("2024-06-20T12:00:00Z")) + await render( + apiHost({ + above: ` + {{ output() }} + + `, + rootAttrs: `selectionMode="range" timeZone="UTC"`, + }), + ) + + await act("Resolve") + + await expect.element(status()).toHaveTextContent("2024-06-01,2024-06-20") + }) + + test("setView jumps straight to the requested level", async () => { + await render( + inlineHost( + ``, + ), + ) + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", "calendar month") + + await act("Decade") + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", "calendar decade") + }) + + test("goToNext and goToPrev step the visible month", async () => { + await render( + inlineHost(` + + + `), + ) + + await act("Next") + await expect + .element(page.getByRole("gridcell", {name: /July 15, 2024/})) + .toBeVisible() + + await act("Prev") + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .toBeVisible() + }) + + test("focusMonth and focusYear move the visible range", async () => { + await render( + inlineHost(` + + + `), + ) + + await act("Month") + await expect + .element(page.getByRole("gridcell", {name: /September 15, 2024/})) + .toBeVisible() + + await act("Year") + await expect + .element(page.getByRole("gridcell", {name: /September 15, 2026/})) + .toBeVisible() + }) + + test("format renders a date with the given options", async () => { + await render( + inlineHost(` + {{ api.format(seeded, monthYear) }} + `), + ) + + await expect.element(status()).toHaveTextContent("June 2024") + }) + + test("the api exposes the readable state of the picker", async () => { + await render( + inlineHost(` + + {{ api.view }}|{{ api.selectionMode }}|{{ api.inline }}|{{ + api.focusedValueAsString + }}|{{ api.valueAsString.length }} + + `), + ) + + await expect + .element(status()) + .toHaveTextContent("day|single|true|06/15/2024|0") + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-clear.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-clear.spec.ts new file mode 100644 index 000000000..d21a8a26f --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-clear.spec.ts @@ -0,0 +1,227 @@ +import {Component, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {afterEach, describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import { + DatePickerModule, + type DateValue, + parseDate, +} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +class TestHost { + readonly valueChangedHandler = output() + readonly seeded = parseDate("2024-06-15") + readonly seededValue: (DateValue | null)[] = [parseDate("2024-06-15")] + readonly fullRange: (DateValue | null)[] = [ + parseDate("2024-06-10"), + parseDate("2024-06-20"), + ] +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent extends TestHost {} + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent extends TestHost {} + return SimpleComponent +} + +const clearTrigger = () => + page.getByRole("button", {name: /clear selected dates/i}) + +const watchValue = `(valueChanged)="valueChangedHandler.emit($event)"` + +const seededAttrs = `[defaultValue]="seededValue"` +const rangeAttrs = `[defaultValue]="fullRange" selectionMode="range"` + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`the clear trigger is hidden while nothing is selected — ${component.name}`, async () => { + await render(component) + await expect.element(clearTrigger()).not.toBeInTheDocument() + }) + }, + }, + { + composite: () => composite(`${seededAttrs} ${watchValue}`), + simple: () => simple(`${seededAttrs} ${watchValue}`), + testCase: (component) => { + test(`the clear trigger empties the field and reports the cleared value — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }) + + await expect.element(clearTrigger()).toBeVisible() + await clearTrigger().click() + + await expect.element(page.getByRole("textbox")).toHaveValue("") + await expect + .poll(() => valueChanged) + .toHaveBeenCalledWith( + expect.objectContaining({value: [], valueAsString: []}), + ) + await expect.element(clearTrigger()).not.toBeInTheDocument() + }) + }, + }, + { + composite: () => composite(seededAttrs), + simple: () => simple(seededAttrs), + testCase: (component) => { + test(`clearing moves focus to the input — ${component.name}`, async () => { + await render(component) + + await clearTrigger().click() + + await expect.element(page.getByRole("textbox")).toHaveFocus() + }) + }, + }, + { + composite: () => composite(`${seededAttrs} disabled ${watchValue}`), + simple: () => simple(`${seededAttrs} disabled ${watchValue}`), + testCase: (component) => { + test(`the clear trigger is disabled alongside the picker — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }) + + await expect.element(clearTrigger()).toBeDisabled() + expect(valueChanged).not.toHaveBeenCalled() + }) + }, + }, + { + composite: () => composite(`${rangeAttrs} ${watchValue}`), + simple: () => simple(`${rangeAttrs} ${watchValue}`), + testCase: (component) => { + test(`the clear trigger empties both ends of a range — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }) + + await clearTrigger().click() + + await expect + .element(page.getByRole("textbox", {name: /start date/i})) + .toHaveValue("") + await expect + .element(page.getByRole("textbox", {name: /end date/i})) + .toHaveValue("") + await expect + .poll(() => valueChanged) + .toHaveBeenCalledWith( + expect.objectContaining({value: [], valueAsString: []}), + ) + }) + }, + }, +] + +describe("DatePicker - Clear", () => { + afterEach(() => { + vi.useRealTimers() + }) + + runTests(tests) + + test("a readOnly picker keeps the clear trigger inert", async () => { + const valueChanged = vi.fn() + await render(simple(`${seededAttrs} readOnly ${watchValue}`), { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }) + + await clearTrigger().click() + + await expect.element(page.getByRole("textbox")).toHaveValue("06/15/2024") + expect(valueChanged).not.toHaveBeenCalled() + }) + + test("pressing Enter in a cleared input keeps it empty", async () => { + await render(simple(seededAttrs)) + + const input = page.getByRole("textbox") + await input.fill("06/20/2024") + await userEvent.keyboard("{Enter}") + await expect.element(input).toHaveValue("06/20/2024") + + await clearTrigger().click() + await expect.element(input).toHaveValue("") + + await userEvent.click(input.element()) + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("") + }) + + test("clearing while the calendar is open resets the focused day to today", async () => { + vi.setSystemTime(new Date("2026-07-29T12:00:00Z")) + await render( + simple(`${seededAttrs} [closeOnSelect]="false" timeZone="UTC"`), + ) + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await clearTrigger().click() + + await expect.element(page.getByRole("textbox")).toHaveValue("") + await expect + .element(page.getByLabelText(/July 29, 2026/)) + .toHaveAttribute("data-today") + await expect + .element(page.getByLabelText(/July 29, 2026/)) + .toHaveAttribute("data-focus") + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-constraints.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-constraints.spec.ts new file mode 100644 index 000000000..f10b4e729 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-constraints.spec.ts @@ -0,0 +1,420 @@ +import {Component, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import { + DatePickerModule, + type DateValue, + parseDate, +} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +const seeded = parseDate("2024-06-15") + +const weekends = (date: DateValue) => { + const weekday = new Date(date.year, date.month - 1, date.day).getDay() + return weekday === 0 || weekday === 6 +} + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + +
+ + + +
+
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +function inlineTemplate(rootAttrs = "") { + return `` +} + +function selectedUnavailableTemplate() { + return ` + + ` +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent { + readonly valueChangedHandler = output() + protected readonly beyondMax = [parseDate("2024-06-25")] + protected readonly max = parseDate("2024-06-20") + protected readonly min = parseDate("2024-06-10") + protected readonly seeded = seeded + protected readonly weekends = weekends + } + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent { + readonly valueChangedHandler = output() + protected readonly beyondMax = [parseDate("2024-06-25")] + protected readonly max = parseDate("2024-06-20") + protected readonly min = parseDate("2024-06-10") + protected readonly seeded = seeded + protected readonly weekends = weekends + } + return SimpleComponent +} + +function inlineSimple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: inlineTemplate(rootAttrs), + }) + class InlineComponent { + protected readonly february = parseDate("2021-02-01") + protected readonly seeded = seeded + } + return InlineComponent +} + +function selectedUnavailable() { + @Component({ + imports: [DatePickerModule], + template: selectedUnavailableTemplate(), + }) + class SelectedUnavailableComponent { + protected readonly seeded = seeded + protected readonly weekends = weekends + } + return SelectedUnavailableComponent +} + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +const dayCell = (day: RegExp) => page.getByRole("gridcell", {name: day}) + +/** Cell state and focus live on the trigger inside the `gridcell`. */ +const dayTrigger = (day: string) => page.getByLabelText(day) + +/** + * `getByLabelText` pierces the hidden closed calendar, so data attributes on + * cells must only be read once the grid is actually exposed. + */ +const expectGridVisible = () => + expect.element(page.getByRole("grid")).toBeVisible() + +const weekdayNames = () => + Array.from(page.getByRole("grid").element().querySelectorAll("thead th")).map( + (cell) => cell.getAttribute("aria-label"), + ) + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(`[max]="max" [min]="min"`), + simple: () => simple(`[max]="max" [min]="min"`), + testCase: (component) => { + test(`days outside min and max are marked unselectable — ${component.name}`, async () => { + await render(component) + await openCalendar() + await expectGridVisible() + + await expect + .element(dayCell(/June 9, 2024/)) + .toHaveAttribute("aria-disabled", "true") + await expect + .element(dayCell(/June 21, 2024/)) + .toHaveAttribute("aria-disabled", "true") + await expect + .element(dayCell(/June 10, 2024/)) + .toHaveAttribute("aria-disabled", "false") + await expect + .element(dayCell(/June 20, 2024/)) + .toHaveAttribute("aria-disabled", "false") + }) + }, + }, + { + composite: () => + composite( + `[max]="max" [min]="min" (valueChanged)="valueChangedHandler.emit()"`, + ), + simple: () => + simple( + `[max]="max" [min]="min" (valueChanged)="valueChangedHandler.emit()"`, + ), + testCase: (component) => { + test(`clicking a day outside min and max selects nothing — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: { + valueChangedHandler: () => { + valueChanged() + }, + }, + }) + await openCalendar() + await expectGridVisible() + + // Playwright refuses to click `aria-disabled` targets, so the + // component's own guard has to be exercised with a native click. + const cell = dayTrigger("June 25, 2024").element() as HTMLElement + cell.click() + + expect(valueChanged).not.toHaveBeenCalled() + await expect.element(page.getByRole("grid")).toBeVisible() + }) + }, + }, + { + composite: () => + composite( + `[isDateUnavailable]="weekends" (valueChanged)="valueChangedHandler.emit()"`, + ), + simple: () => + simple( + `[isDateUnavailable]="weekends" (valueChanged)="valueChangedHandler.emit()"`, + ), + testCase: (component) => { + test(`unavailable days announce themselves and reject clicks — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: { + valueChangedHandler: () => { + valueChanged() + }, + }, + }) + await openCalendar() + await expectGridVisible() + + // June 16 2024 is a Sunday + await expect + .element(dayTrigger("June 16, 2024")) + .toHaveAttribute( + "aria-label", + expect.stringContaining("Not available"), + ) + await expect + .element(dayTrigger("June 16, 2024")) + .toHaveAttribute("data-unavailable") + + const sunday = dayTrigger("June 16, 2024").element() as HTMLElement + sunday.click() + expect(valueChanged).not.toHaveBeenCalled() + + await dayCell(/June 17, 2024/).click() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/17/2024") + }) + }, + }, + { + composite: () => composite(`[isDateUnavailable]="weekends"`), + simple: () => simple(`[isDateUnavailable]="weekends"`), + testCase: (component) => { + test(`Space on an unavailable day does not commit it — ${component.name}`, async () => { + await render(component) + await openCalendar() + + // June 15 2024 is a Saturday, and is the day the calendar opens on + await expect.element(dayTrigger("June 15, 2024")).toHaveFocus() + await userEvent.keyboard("{ }") + + await expect.element(page.getByRole("textbox")).toHaveValue("") + await expect.element(page.getByRole("grid")).toBeVisible() + }) + }, + }, + { + composite: () => composite(`[isDateUnavailable]="weekends"`), + simple: () => simple(`[isDateUnavailable]="weekends"`), + testCase: (component) => { + test(`Enter on an unavailable day does not commit it — ${component.name}`, async () => { + await render(component) + await openCalendar() + + // June 15 2024 is a Saturday, and is the day the calendar opens on + await expect.element(dayTrigger("June 15, 2024")).toHaveFocus() + await userEvent.keyboard("{Enter}") + + await expect.element(page.getByRole("textbox")).toHaveValue("") + await expect.element(page.getByRole("grid")).toBeVisible() + }) + }, + }, + { + composite: () => composite(`[isDateUnavailable]="weekends"`), + simple: () => simple(`[isDateUnavailable]="weekends"`), + testCase: (component) => { + test(`typing an unavailable date reverts the input to the last committed value — ${component.name}`, async () => { + await render(component) + + const input = page.getByRole("textbox") + await input.fill("06/17/2024") + await userEvent.keyboard("{Enter}") + await expect.element(input).toHaveValue("06/17/2024") + + await input.fill("06/16/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("06/17/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`days spilling in from the neighbouring months are not selectable by default — ${component.name}`, async () => { + await render(component) + await openCalendar() + await expectGridVisible() + + // June 2024 starts on a Saturday, so late May fills the first row + await expect + .element(dayTrigger("May 26, 2024")) + .toHaveAttribute("data-outside-range") + await expect + .element(dayCell(/May 26, 2024/)) + .toHaveAttribute("aria-disabled", "true") + }) + }, + }, + { + composite: () => composite(`outsideDaySelectable`), + simple: () => simple(`outsideDaySelectable`), + testCase: (component) => { + test(`outsideDaySelectable lets a spilled-in day be picked — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await dayCell(/May 26, 2024/).click() + + await expect + .element(page.getByRole("textbox")) + .toHaveValue("05/26/2024") + }) + }, + }, + { + composite: () => composite(`[defaultValue]="beyondMax" [max]="max"`), + simple: () => simple(`[defaultValue]="beyondMax" [max]="max"`), + testCase: (component) => { + test(`a defaultValue beyond max is pulled back to max — ${component.name}`, async () => { + await render(component) + + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + }) + }, + }, +] + +describe("DatePicker - Constraints", () => { + runTests(tests) + + test("today's cell is flagged for assistive tech", async () => { + await render(inlineSimple()) + await expectGridVisible() + + const grid = page.getByRole("grid").element() + const today = grid.querySelectorAll('[aria-current="date"]') + + expect(today).toHaveLength(1) + expect(today[0].querySelector("[data-today]")).not.toBeNull() + }) + + test("an unavailable day that is also selected still reads as unavailable", async () => { + await render(selectedUnavailable()) + await openCalendar() + await expectGridVisible() + + await expect + .element(dayTrigger("June 15, 2024")) + .toHaveAttribute("aria-label", expect.stringContaining("Not available")) + await expect + .element(dayTrigger("June 15, 2024")) + .toHaveAttribute("data-selected") + }) + + test("fixedWeeks always renders six week rows", async () => { + // February 2021 fits in exactly four rows without padding + await render(inlineSimple(`fixedWeeks [defaultFocusedValue]="february"`)) + + await expectGridVisible() + expect( + page.getByRole("grid").element().querySelectorAll("tbody tr"), + ).toHaveLength(6) + }) + + test("the weekday header starts on Sunday by default", async () => { + await render(inlineSimple(`[defaultFocusedValue]="seeded"`)) + await expectGridVisible() + + expect(weekdayNames()).toEqual([ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]) + }) + + test("startOfWeek shifts the weekday header order", async () => { + await render(inlineSimple(`startOfWeek="1" [defaultFocusedValue]="seeded"`)) + await expectGridVisible() + + expect(weekdayNames()).toEqual([ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ]) + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-controlled.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-controlled.spec.ts new file mode 100644 index 000000000..581ffb5fd --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-controlled.spec.ts @@ -0,0 +1,333 @@ +import {Component, signal} from "@angular/core" +import {takeUntilDestroyed} from "@angular/core/rxjs-interop" +import {FormControl, ReactiveFormsModule} from "@angular/forms" +import {render} from "@testing-library/angular" +import {beforeEach, describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import { + type DatePickerPreset, + DatePickerModule, + type DateValue, + parseDate, +} from "@qualcomm-ui/angular/date-picker" + +const seeded = parseDate("2024-06-15") +const grid = () => page.getByRole("grid") +const input = () => page.getByRole("textbox") +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +async function openAndWait() { + await openCalendar() + await expect.element(grid()).toBeVisible() +} + +async function openAndWaitForFocus() { + await openAndWait() + await expect.element(page.getByLabelText(/June 15, 2024/)).toHaveFocus() +} + +const openChanged = vi.fn() +const focusChanged = vi.fn() + +function controlledValueTemplate() { + return ` + + ` +} + +function parentDrivenTemplate() { + return ` + + ${controlledValueTemplate()} + ` +} + +function frozenOpenTemplate() { + return ` + + ` +} + +function controlledOpenTemplate(rootAttrs: string) { + return ` + + ` +} + +function frozenViewTemplate() { + return ` + + ` +} + +function frozenFocusedValueTemplate() { + return ` + + ` +} + +/** + * Angular has no `value` input; the controlled value arrives through a form + * control. A control the parent writes the previous value back to is the closest + * analogue of React's frozen `value` prop. + */ +@Component({ + imports: [DatePickerModule, ReactiveFormsModule], + template: controlledValueTemplate(), +}) +class RejectingControlComponent { + readonly departure = new FormControl<(DateValue | null)[]>([seeded]) + + constructor() { + this.departure.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.departure.setValue([seeded], {emitEvent: false}) + }) + } +} + +@Component({ + imports: [DatePickerModule, ReactiveFormsModule], + template: parentDrivenTemplate(), +}) +class ParentDrivenControlComponent { + readonly departure = new FormControl<(DateValue | null)[]>([seeded]) + + jump() { + this.departure.setValue([parseDate("2024-06-20")]) + } +} + +@Component({ + imports: [DatePickerModule], + template: frozenOpenTemplate(), +}) +class FrozenOpenComponent { + readonly openChanged = openChanged +} + +function controlledOpen(rootAttrs = "") { + @Component({ + imports: [DatePickerModule], + template: controlledOpenTemplate(rootAttrs), + }) + class ControlledOpenComponent { + readonly open = signal(false) + readonly presets: DatePickerPreset[] = [ + {label: "Next 7 days", value: "next7Days"}, + ] + readonly seeded = seeded + } + return ControlledOpenComponent +} + +@Component({ + imports: [DatePickerModule], + template: frozenViewTemplate(), +}) +class FrozenViewComponent { + readonly seeded = seeded +} + +@Component({ + imports: [DatePickerModule], + template: frozenFocusedValueTemplate(), +}) +class FrozenFocusedValueComponent { + readonly focusChanged = focusChanged + readonly seeded = seeded +} + +describe("DatePicker - Controlled", () => { + beforeEach(() => { + openChanged.mockClear() + focusChanged.mockClear() + }) + + test("a form control that rejects the change keeps the previous value in the field", async () => { + await render(RejectingControlComponent) + await openAndWait() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(input()).toHaveValue("06/15/2024") + }) + + test("a form control driven by the parent updates the field and the selected cell", async () => { + await render(ParentDrivenControlComponent) + await expect.element(input()).toHaveValue("06/15/2024") + + await page.getByRole("button", {name: "Jump"}).click() + + await expect.element(input()).toHaveValue("06/20/2024") + + await openCalendar() + await expect.element(grid()).toBeVisible() + await expect + .element(page.getByLabelText(/June 20, 2024/)) + .toHaveAttribute("data-selected") + await expect + .element(page.getByLabelText(/June 15, 2024/)) + .not.toHaveAttribute("data-selected") + }) + + test("a frozen open input keeps the calendar closed while still reporting the intent", async () => { + await render(FrozenOpenComponent) + + await openCalendar() + + expect(openChanged).toHaveBeenCalledWith( + expect.objectContaining({open: true}), + ) + await expect.element(grid()).not.toBeInTheDocument() + }) + + test("an open input driven by the parent opens and closes the calendar", async () => { + await render(controlledOpen()) + await expect.element(grid()).not.toBeInTheDocument() + + await openAndWaitForFocus() + + await userEvent.keyboard("{Escape}") + await expect.element(grid()).not.toBeInTheDocument() + }) + + test("a controlled open picker still commits the selected date", async () => { + await render(controlledOpen()) + await openAndWait() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(input()).toHaveValue("06/20/2024") + await expect.element(grid()).not.toBeInTheDocument() + }) + + test("clicking the input keeps focus in the input, even when open is controlled", async () => { + await render(controlledOpen("openOnClick")) + + await input().click() + + await expect.element(grid()).toBeVisible() + await expect.element(input()).toHaveFocus() + }) + + test("a controlled open still moves focus into the grid when opened by the trigger", async () => { + await render(controlledOpen()) + + await openCalendar() + + await expect.element(grid()).toBeVisible() + await expect.element(page.getByLabelText(/June 15, 2024/)).toHaveFocus() + }) + + test("a controlled close after a selection keeps focus in the picker", async () => { + await render(controlledOpen()) + await openAndWait() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(input()).toHaveFocus() + }) + + test("a controlled close after Enter keeps focus in the picker", async () => { + await render(controlledOpen()) + await openAndWaitForFocus() + + await userEvent.keyboard("{Enter}") + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(input()).toHaveFocus() + }) + + test("a controlled close after Escape keeps focus in the picker", async () => { + await render(controlledOpen()) + await openAndWaitForFocus() + + await userEvent.keyboard("{Escape}") + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(input()).toHaveFocus() + }) + + test("a controlled selection does not leak focus restoration into the next close", async () => { + await render(controlledOpen()) + + await openAndWait() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect.element(grid()).not.toBeInTheDocument() + + await openAndWait() + await page.getByRole("button", {name: /close calendar/i}).click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toHaveFocus() + }) + + test("a controlled close after a preset keeps focus in the picker", async () => { + await render(controlledOpen(`selectionMode="range" [presets]="presets"`)) + await openAndWait() + + await page.getByRole("button", {name: /show presets/i}).click() + await page.getByText("Next 7 days").click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect + .element(page.getByRole("textbox", {name: /end date/i})) + .toHaveFocus() + }) + + test("a frozen view input pins the calendar to that level", async () => { + await render(FrozenViewComponent) + await openAndWait() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", "calendar year") + + await page.getByRole("button", {name: /return to calendar/i}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", "calendar year") + }) + + test("a frozen focusedValue input ignores keyboard navigation", async () => { + await render(FrozenFocusedValueComponent) + await openAndWait() + + await userEvent.keyboard("{ArrowRight}") + + expect(focusChanged).toHaveBeenCalledWith( + expect.objectContaining({ + focusedValue: expect.objectContaining({day: 16}), + }), + ) + await expect.element(page.getByLabelText(/June 15, 2024/)).toHaveFocus() + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-dismiss.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-dismiss.spec.ts new file mode 100644 index 000000000..5d6eab30d --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-dismiss.spec.ts @@ -0,0 +1,134 @@ +import {Component} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test} from "vitest" +import {page} from "vitest/browser" + +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" + +const seeded = parseDate("2024-06-15") + +/** + * The outside targets sit above the picker: the popover opens downwards and would + * otherwise cover them, timing out the click. + */ +function withOutsideTemplate(rootAttrs = "") { + return ` + +
Plain outside
+ + ` +} + +function withOutside(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: withOutsideTemplate(rootAttrs), + }) + class OutsideComponent { + protected readonly june10 = [parseDate("2024-06-10")] + protected readonly seeded = seeded + } + return OutsideComponent +} + +const grid = () => page.getByRole("grid") +const trigger = () => + page.getByRole("button", {name: /(?:choose|change) date/i}) +const openCalendar = () => trigger().click() +const outsideButton = () => + page.getByRole("button", {name: "Focusable outside"}) + +describe("DatePicker - Dismiss", () => { + test("clicking outside closes the calendar and keeps the committed value", async () => { + await render(withOutside(`[defaultValue]="june10"`)) + await openCalendar() + await expect.element(grid()).toBeVisible() + + await outsideButton().click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(page.getByRole("textbox")).toHaveValue("06/10/2024") + }) + + test("clicking outside discards a pending selection when closeOnSelect is false", async () => { + await render( + withOutside(` + [closeOnSelect]="false" + [defaultFocusedValue]="seeded" + [defaultValue]="june10" + `), + ) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect.element(page.getByRole("textbox")).toHaveValue("06/20/2024") + + await outsideButton().click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(page.getByRole("textbox")).toHaveValue("06/10/2024") + }) + + test("dismissing onto a non-focusable target returns focus to the trigger", async () => { + await render(withOutside(`[defaultFocusedValue]="seeded"`)) + await openCalendar() + + await page.getByText("Plain outside").click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(trigger()).toHaveFocus() + }) + + test("a focusable dismiss keeps focus even after an earlier non-focusable one", async () => { + await render(withOutside(`[defaultFocusedValue]="seeded"`)) + + await openCalendar() + await page.getByText("Plain outside").click() + await expect.element(trigger()).toHaveFocus() + + await openCalendar() + await outsideButton().click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(outsideButton()).toHaveFocus() + }) + + test("dismissing onto a focusable target leaves focus where the user put it", async () => { + await render(withOutside(`[defaultFocusedValue]="seeded"`)) + await openCalendar() + + await outsideButton().click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(outsideButton()).toHaveFocus() + }) + + test("clicking the input does not count as an outside interaction", async () => { + await render(withOutside(`[defaultFocusedValue]="seeded"`)) + await openCalendar() + + await page.getByRole("textbox").click() + + await expect.element(grid()).toBeVisible() + }) + + test("clicking inside the calendar does not close it", async () => { + await render( + withOutside(`[closeOnSelect]="false" [defaultFocusedValue]="seeded"`), + ) + await openCalendar() + + await page.getByRole("button", {name: /switch to next month/i}).click() + + await expect.element(grid()).toBeVisible() + }) + + test("the inline variant ignores outside interaction entirely", async () => { + await render(withOutside(`variant="inline" [defaultFocusedValue]="seeded"`)) + await expect.element(grid()).toBeVisible() + + await outsideButton().click() + + await expect.element(grid()).toBeVisible() + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-focus-trap.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-focus-trap.spec.ts new file mode 100644 index 000000000..bd71b7c86 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-focus-trap.spec.ts @@ -0,0 +1,195 @@ +import {Component} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +const seeded = parseDate("2024-06-15") + +const trailingLabel = "After the picker" + +const trailing = `` + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + + + +
+ + + +
+
+
+
+
+
+ ${trailing} + ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ${trailing} + ` +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent { + protected readonly seeded = seeded + } + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent { + protected readonly seeded = seeded + } + return SimpleComponent +} + +const grid = () => page.getByRole("grid") +const input = () => page.getByRole("textbox") +const trailingButton = () => page.getByRole("button", {name: trailingLabel}) +const focusedCell = () => + page.getByRole("button", {exact: false, name: /June 15, 2024/}) + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`Tab from the calendar returns to the input instead of leaving the picker — ${component.name}`, async () => { + await render(component) + + await openCalendar() + await expect.element(focusedCell()).toHaveFocus() + + await userEvent.tab() + + await expect.element(input()).toHaveFocus() + await expect.element(grid()).toBeVisible() + await expect.element(trailingButton()).not.toHaveFocus() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`Shift+Tab from the input returns to the calendar instead of dismissing it — ${component.name}`, async () => { + await render(component) + + await openCalendar() + await userEvent.tab() + await expect.element(input()).toHaveFocus() + + await userEvent.tab({shift: true}) + + await expect.element(focusedCell()).toHaveFocus() + await expect.element(grid()).toBeVisible() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`repeated Tab presses never reach content outside the picker — ${component.name}`, async () => { + await render(component) + + await openCalendar() + + for (let i = 0; i < 8; i++) { + await userEvent.tab() + await expect.element(trailingButton()).not.toHaveFocus() + } + + await expect.element(grid()).toBeVisible() + }) + }, + }, + { + composite: () => composite("openOnClick"), + simple: () => simple("openOnClick"), + testCase: (component) => { + test(`opening from the input keeps focus in the input and still allows typing — ${component.name}`, async () => { + await render(component) + + await input().click() + await expect.element(grid()).toBeVisible() + await expect.element(input()).toHaveFocus() + + await userEvent.keyboard("06/20/2024") + + await expect.element(input()).toHaveValue("06/20/2024") + await expect.element(input()).toHaveFocus() + }) + }, + }, +] + +describe("DatePicker - Focus trap", () => { + runTests(tests) + + test("an inline calendar does not trap focus", async () => { + @Component({ + imports: [DatePickerModule], + template: ` + + + `, + }) + class InlineComponent { + protected readonly seeded = seeded + } + + await render(InlineComponent) + + await focusedCell().click() + await expect.element(focusedCell()).toHaveFocus() + + await userEvent.tab() + + await expect.element(trailingButton()).toHaveFocus() + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-form.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-form.spec.ts new file mode 100644 index 000000000..a7b109433 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-form.spec.ts @@ -0,0 +1,596 @@ +import {Component, output} from "@angular/core" +import { + FormControl, + type FormControlOptions, + FormGroup, + ReactiveFormsModule, + Validators, +} from "@angular/forms" +import {render} from "@testing-library/angular" +import {beforeEach, describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import { + DatePickerModule, + type DateValue, + parseDate, +} from "@qualcomm-ui/angular/date-picker" +import type {DatePickerValueChangeDetails} from "@qualcomm-ui/core/date-picker" + +const seeded = parseDate("2024-06-15") +const submitted = vi.fn<(data: FormData) => void>() +const submit = () => page.getByRole("button", {name: "Submit"}).click() +const reset = () => page.getByRole("button", {name: "Reset"}).click() + +const doc = () => + page.getByRole("button", {name: "Submit"}).element().ownerDocument + +const isRootDisabled = () => + doc() + .querySelector('[data-date-picker-part="root"]') + ?.hasAttribute("data-disabled") + +function formTemplate(picker: string) { + return ` +
+ ${picker} + + +
+ ` +} + +function fieldsetTemplate(picker: string) { + return ` +
+
+ ${picker} +
+ +
+ ` +} + +function reactiveTemplate(pickerAttrs: string) { + return ` + {{ state }} + + ` +} + +function formHost(picker: string) { + @Component({ + imports: [DatePickerModule], + template: formTemplate(picker), + }) + class FormHostComponent { + readonly endOnlyRange: (DateValue | null)[] = [ + null, + parseDate("2024-06-20"), + ] + readonly range = [parseDate("2024-06-10"), parseDate("2024-06-20")] + readonly seeded = seeded + readonly submitted = submitted + readonly value = [seeded] + readonly valueChangedHandler = output() + + onSubmit(event: Event) { + event.preventDefault() + this.submitted(new FormData(event.target as HTMLFormElement)) + } + } + return FormHostComponent +} + +function fieldsetHost(picker: string) { + @Component({ + imports: [DatePickerModule], + template: fieldsetTemplate(picker), + }) + class FieldsetHostComponent { + fieldsetDisabled = true + readonly seeded = seeded + readonly submitted = submitted + readonly value = [seeded] + + onSubmit(event: Event) { + event.preventDefault() + this.submitted(new FormData(event.target as HTMLFormElement)) + } + } + return FieldsetHostComponent +} + +function reactiveHost( + pickerAttrs = "", + controlOptions: FormControlOptions = {validators: [Validators.required]}, +) { + @Component({ + imports: [DatePickerModule, ReactiveFormsModule], + template: reactiveTemplate(pickerAttrs), + }) + class ReactiveHostComponent { + readonly departure = new FormControl<(DateValue | null)[]>( + [], + controlOptions, + ) + readonly seeded = seeded + + get state() { + return [ + this.departure.value?.join(","), + this.departure.valid, + this.departure.dirty, + this.departure.touched, + ].join("|") + } + } + return ReactiveHostComponent +} + +const blurUpdateHost = () => + reactiveHost("", {updateOn: "blur", validators: [Validators.required]}) + +const optionalReactiveHost = (pickerAttrs = "") => reactiveHost(pickerAttrs, {}) + +function toggleableRangeHost() { + @Component({ + imports: [DatePickerModule, ReactiveFormsModule], + template: ` +
+ {{ state }} + @if (show) { + + } + + + + `, + }) + class ToggleableRangeHostComponent { + show = true + readonly trip = new FormGroup({ + departure: new FormControl<(DateValue | null)[]>([], { + validators: [Validators.required], + }), + }) + readonly seeded = seeded + + get state() { + return [ + this.trip.controls.departure.value?.join(","), + this.trip.valid, + ].join("|") + } + + revalidate() { + this.trip.controls.departure.updateValueAndValidity() + } + } + return ToggleableRangeHostComponent +} + +const status = () => page.getByTestId("status") +const startInput = () => page.getByRole("textbox", {name: /start date/i}) +const endInput = () => page.getByRole("textbox", {name: /end date/i}) + +describe("DatePicker - Form", () => { + beforeEach(() => { + submitted.mockClear() + }) + + test("a single picker submits its formatted value under the given name", async () => { + await render( + formHost(` + + `), + ) + + await submit() + + expect(submitted).toHaveBeenCalledTimes(1) + expect(submitted.mock.calls[0][0].getAll("departure")).toEqual([ + "06/15/2024", + ]) + }) + + test("a range picker submits both endpoints under the same name", async () => { + await render( + formHost(` + + `), + ) + + await submit() + + expect(submitted.mock.calls[0][0].getAll("trip")).toEqual([ + "06/10/2024", + "06/20/2024", + ]) + }) + + test("multiple mode submits one entry per selected date", async () => { + await render( + formHost(` + + `), + ) + + await submit() + + expect(submitted.mock.calls[0][0].getAll("dates")).toEqual([ + "06/10/2024", + "06/20/2024", + ]) + }) + + test("multiple mode submits an empty entry when nothing is selected", async () => { + await render( + formHost( + ``, + ), + ) + + await submit() + + expect(submitted.mock.calls[0][0].getAll("dates")).toEqual([""]) + }) + + test("required blocks submission of an empty single picker", async () => { + await render( + formHost(``), + ) + + await submit() + + expect(submitted).not.toHaveBeenCalled() + }) + + test("required blocks submission of an empty multiple picker", async () => { + await render( + formHost(` + + `), + ) + + await submit() + + expect(submitted).not.toHaveBeenCalled() + }) + + test("a picked date satisfies required", async () => { + await render( + formHost(` + + `), + ) + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await submit() + + expect(submitted.mock.calls[0][0].getAll("departure")).toEqual([ + "06/20/2024", + ]) + }) + + test("a disabled picker is left out of the submitted data", async () => { + await render( + formHost(` + + `), + ) + + await submit() + + expect(submitted.mock.calls[0][0].getAll("departure")).toEqual([]) + }) + + test("a required form control turns valid, dirty and touched once a date is selected", async () => { + await render(reactiveHost()) + await expect.element(status()).toHaveTextContent("|false|false|false") + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect + .element(status()) + .toHaveTextContent("2024-06-20|true|true|true") + await expect.element(page.getByRole("textbox")).toHaveValue("06/20/2024") + }) + + test("a required range control stays invalid until both endpoints are selected", async () => { + await render(reactiveHost(`selectionMode="range"`)) + await expect.element(status()).toHaveTextContent("|false|false|false") + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect.element(status()).toHaveTextContent("2024-06-10|false") + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect + .element(status()) + .toHaveTextContent("2024-06-10,2024-06-20|true") + }) + + test("a required range control rejects an end-only selection", async () => { + await render(reactiveHost(`selectionMode="range"`)) + + await endInput().fill("06/20/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(status()).toHaveTextContent(",2024-06-20|false") + }) + + test("destroying a picker releases the range constraint it put on the control", async () => { + await render(toggleableRangeHost()) + + await endInput().fill("06/20/2024") + await userEvent.keyboard("{Enter}") + await expect.element(status()).toHaveTextContent(",2024-06-20|false") + + await page.getByRole("button", {name: "Hide"}).click() + await page.getByRole("button", {name: "Revalidate"}).click() + + await expect.element(status()).toHaveTextContent(",2024-06-20|true") + }) + + test("an optional range control accepts a partial selection", async () => { + await render(optionalReactiveHost(`selectionMode="range"`)) + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect.element(status()).toHaveTextContent("2024-06-10|true") + }) + + test("closing the calendar without a selection only marks the control touched", async () => { + await render(reactiveHost()) + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await expect.element(page.getByRole("grid")).toBeVisible() + await userEvent.keyboard("{Escape}") + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + + await expect.element(status()).toHaveTextContent("|false|false|true") + }) + + test("blurring the input marks the control touched without opening the calendar", async () => { + await render(reactiveHost()) + + await page.getByRole("textbox").click() + await userEvent.keyboard("{Tab}") + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect.element(status()).toHaveTextContent("|false|false|true") + }) + + test("an updateOn blur control commits the typed date when the input loses focus", async () => { + await render(blurUpdateHost()) + + await page.getByRole("textbox").fill("06/20/2024") + await userEvent.keyboard("{Tab}") + + await expect + .element(status()) + .toHaveTextContent("2024-06-20|true|true|true") + }) + + test("a form control's value survives a round trip through the input", async () => { + await render(reactiveHost()) + + await page.getByRole("textbox").fill("06/20/2024") + await userEvent.keyboard("{Enter}") + + await expect + .element(status()) + .toHaveTextContent("2024-06-20|true|true|true") + }) + + test("resetting the form restores the initial value", async () => { + const valueChanged = vi.fn() + await render( + formHost(` + + `), + {on: {valueChangedHandler: (event: unknown) => valueChanged(event)}}, + ) + + const input = page.getByRole("textbox") + await input.fill("06/20/2024") + await userEvent.keyboard("{Enter}") + await expect.element(input).toHaveValue("06/20/2024") + + valueChanged.mockClear() + await reset() + + await expect.element(input).toHaveValue("06/15/2024") + await expect + .poll(() => valueChanged) + .toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["06/15/2024"]}), + ) + }) + + test("resetting the form clears a picker that had no initial value", async () => { + await render( + formHost(``), + ) + + const input = page.getByRole("textbox") + await input.fill("06/20/2024") + await userEvent.keyboard("{Enter}") + await expect.element(input).toHaveValue("06/20/2024") + + await reset() + + await expect.element(input).toHaveValue("") + }) + + test("resetting the form restores both endpoints of a range", async () => { + await render( + formHost(` + + `), + ) + + await startInput().fill("06/05/2024") + await userEvent.keyboard("{Enter}") + await expect.element(startInput()).toHaveValue("06/05/2024") + + await reset() + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + + test("resetting the form restores which range endpoint is next", async () => { + await render( + formHost(` + + `), + ) + + await startInput().fill("06/10/2024") + await userEvent.keyboard("{Enter}") + await expect.element(startInput()).toHaveValue("06/10/2024") + + await reset() + await expect.element(startInput()).toHaveValue("") + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 12, 2024/}).click() + + await expect.element(startInput()).toHaveValue("06/12/2024") + await expect.element(endInput()).toHaveValue("") + }) + + test("resetting an end-only range on an inline picker leaves the start slot next", async () => { + const valueChanged = vi.fn() + await render( + formHost(` + + `), + {on: {valueChangedHandler: (event: unknown) => valueChanged(event)}}, + ) + + await reset() + valueChanged.mockClear() + + await page.getByRole("gridcell", {name: /June 12, 2024/}).click() + + await expect + .poll(() => valueChanged.mock.calls.at(-1)?.[0].valueAsString) + .toEqual(["06/12/2024", "06/20/2024"]) + }) + + test("an ancestor disabled fieldset puts the picker in its disabled state", async () => { + await render( + fieldsetHost(` + + `), + ) + + await expect.poll(() => isRootDisabled()).toBe(true) + }) + + test("re-enabling the fieldset returns the picker to its enabled state", async () => { + await render( + fieldsetHost(` + + `), + ) + + await expect.poll(() => isRootDisabled()).toBe(true) + + doc().querySelector("fieldset")!.removeAttribute("disabled") + + await expect.poll(() => isRootDisabled()).toBe(false) + }) + + test("an ancestor disabled fieldset disables an inline picker", async () => { + await render( + fieldsetHost(` + + `), + ) + + await expect.poll(() => isRootDisabled()).toBe(true) + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-input.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-input.spec.ts new file mode 100644 index 000000000..4d6d94c1b --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-input.spec.ts @@ -0,0 +1,251 @@ +import {Component, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import { + DatePickerModule, + type DateValue, + parseDate, +} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +interface DatePickerLocaleDetails { + locale: string + timeZone: string +} + +let formatSpy: + | ((date: DateValue, details: DatePickerLocaleDetails) => string) + | undefined +let parseSpy: + | ((value: string, details: DatePickerLocaleDetails) => DateValue | undefined) + | undefined + +function compositeTemplate(rootAttrs = "", groupAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + +
+ + + +
+
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +class TestHost { + readonly valueChangedHandler = output() + readonly june10 = parseDate("2024-06-10") + readonly june15 = parseDate("2024-06-15") + readonly june20 = parseDate("2024-06-20") + readonly seededValue: (DateValue | null)[] = [parseDate("2024-06-15")] + + readonly format = (date: DateValue, details: DatePickerLocaleDetails) => + formatSpy!(date, details) + + readonly parse = (value: string, details: DatePickerLocaleDetails) => + parseSpy!(value, details) +} + +function composite(rootAttrs?: string, groupAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs, groupAttrs), + }) + class CompositeComponent extends TestHost {} + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent extends TestHost {} + return SimpleComponent +} + +const watchValue = `(valueChanged)="valueChangedHandler.emit($event)"` + +const seededAttrs = `[defaultValue]="seededValue"` +const boundedAttrs = `[max]="june20" [min]="june10"` +const roundTripAttrs = ` + [defaultValue]="seededValue" + [format]="format" + [parse]="parse" + locale="en-GB" + timeZone="Europe/Paris" +` + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(watchValue), + simple: () => simple(watchValue), + testCase: (component) => { + test(`typing a valid date and pressing Enter commits it — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }) + + const input = page.getByRole("textbox") + await input.fill("06/20/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("06/20/2024") + await expect + .poll(() => valueChanged) + .toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["06/20/2024"]}), + ) + }) + }, + }, + { + composite: () => composite(`${seededAttrs} ${watchValue}`), + simple: () => simple(`${seededAttrs} ${watchValue}`), + testCase: (component) => { + test(`an invalid date restores the last committed value — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }) + + const input = page.getByRole("textbox") + await input.fill("13/45/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("06/15/2024") + expect(valueChanged).not.toHaveBeenCalled() + }) + }, + }, + { + composite: () => composite(roundTripAttrs), + simple: () => simple(roundTripAttrs), + testCase: (component) => { + test(`custom parse and format functions drive the input round trip — ${component.name}`, async () => { + formatSpy = vi.fn((date: DateValue) => + date.toString().replaceAll("-", "/"), + ) + parseSpy = vi.fn((value: string) => + value === "2024/06/20" ? parseDate("2024-06-20") : undefined, + ) + await render(component) + + const input = page.getByRole("textbox") + await expect.element(input).toHaveValue("2024/06/15") + + await input.fill("2024/06/20") + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("2024/06/20") + expect(parseSpy).toHaveBeenCalledWith("2024/06/20", { + locale: "en-GB", + timeZone: "Europe/Paris", + }) + expect(formatSpy).toHaveBeenCalledWith( + expect.objectContaining({day: 20, month: 6, year: 2024}), + {locale: "en-GB", timeZone: "Europe/Paris"}, + ) + }) + }, + }, + { + composite: () => composite(`${seededAttrs} ${watchValue}`), + simple: () => simple(`${seededAttrs} ${watchValue}`), + testCase: (component) => { + test(`clearing the input clears the committed value — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }) + + const input = page.getByRole("textbox") + await expect.element(input).toHaveValue("06/15/2024") + + await input.fill("") + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("") + await expect + .poll(() => valueChanged) + .toHaveBeenCalledWith( + expect.objectContaining({value: [], valueAsString: []}), + ) + }) + }, + }, + { + composite: () => composite(boundedAttrs), + simple: () => simple(boundedAttrs), + testCase: (component) => { + test(`constrains an above-max date to the max on blur — ${component.name}`, async () => { + await render(component) + + const input = page.getByRole("textbox") + await input.fill("06/25/2024") + await userEvent.keyboard("{Tab}") + + await expect.element(input).toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => composite(boundedAttrs), + simple: () => simple(boundedAttrs), + testCase: (component) => { + test(`constrains a below-min date to the min on blur — ${component.name}`, async () => { + await render(component) + + const input = page.getByRole("textbox") + await input.fill("06/05/2024") + await userEvent.keyboard("{Tab}") + + await expect.element(input).toHaveValue("06/10/2024") + }) + }, + }, + { + composite: () => composite(boundedAttrs, `[fixOnBlur]="false"`), + simple: () => simple(`${boundedAttrs} [fixOnBlur]="false"`), + testCase: (component) => { + test(`fixOnBlur false leaves an out-of-range entry untouched on blur — ${component.name}`, async () => { + await render(component) + + const input = page.getByRole("textbox") + await input.fill("06/25/2024") + await userEvent.keyboard("{Tab}") + + await expect.element(input).toHaveValue("06/25/2024") + }) + }, + }, +] + +describe("DatePicker - Input", () => { + runTests(tests) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-locale.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-locale.spec.ts new file mode 100644 index 000000000..6912d6a51 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-locale.spec.ts @@ -0,0 +1,210 @@ +import {Component, signal} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" +import type {DatePickerIntlTranslations} from "@qualcomm-ui/core/date-picker" + +const seeded = parseDate("2024-06-15") +const input = () => page.getByRole("textbox") + +function germanTemplate(rootAttrs: string) { + return `` +} + +function switcherTemplate() { + return ` + + + ` +} + +function translationsTemplate() { + return `` +} + +function german(rootAttrs = "") { + @Component({ + imports: [DatePickerModule], + template: germanTemplate(rootAttrs), + }) + class GermanComponent { + readonly seeded = seeded + readonly value = [seeded] + } + return GermanComponent +} + +@Component({ + imports: [DatePickerModule], + template: switcherTemplate(), +}) +class LocaleSwitcherComponent { + readonly locale = signal("en-US") + readonly value = [seeded] +} + +@Component({ + imports: [DatePickerModule], + template: translationsTemplate(), +}) +class UndefinedTranslationsComponent { + readonly translations: DatePickerIntlTranslations = { + trigger: undefined, + viewCloseTrigger: undefined, + } +} + +@Component({ + imports: [DatePickerModule], + template: translationsTemplate(), +}) +class CustomTranslationsComponent { + readonly translations: DatePickerIntlTranslations = { + trigger: ({open}) => (open ? "Kalender schließen" : "Kalender öffnen"), + } +} + +@Component({ + imports: [DatePickerModule], + template: ` + + `, +}) +class GermanFormatTranslationComponent { + readonly translations: DatePickerIntlTranslations = { + inputDescription: (format) => `Datumsformat: ${format}`, + } +} + +describe("DatePicker - Locale", () => { + test("the placeholder follows the locale's field order and separators", async () => { + await render(german()) + + await expect.element(input()).toHaveAttribute("placeholder", "dd.mm.yyyy") + }) + + test("an explicit placeholder overrides the locale-derived one", async () => { + await render(german(`placeholder="Pick a day"`)) + + await expect.element(input()).toHaveAttribute("placeholder", "Pick a day") + }) + + test("the input describes the expected format, following the locale", async () => { + await render(german()) + + await expect + .element(input()) + .toHaveAttribute("aria-description", "Date format: dd.mm.yyyy") + }) + + test("an explicit placeholder does not change the described format", async () => { + await render(german(`placeholder="Pick a day"`)) + + await expect + .element(input()) + .toHaveAttribute("aria-description", "Date format: dd.mm.yyyy") + }) + + test("translations override the described format", async () => { + await render(GermanFormatTranslationComponent) + + await expect + .element(input()) + .toHaveAttribute("aria-description", "Datumsformat: dd.mm.yyyy") + }) + + test("the committed value is formatted for the locale", async () => { + await render(german(`[defaultValue]="value"`)) + + await expect.element(input()).toHaveValue("15.06.2024") + }) + + test("typing in the locale's own format commits the date", async () => { + await render(german()) + + await input().fill("20.06.2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input()).toHaveValue("20.06.2024") + }) + + test("a date typed in another locale's format is rejected, not reinterpreted", async () => { + await render(german(`[defaultValue]="value"`)) + await expect.element(input()).toHaveValue("15.06.2024") + + await input().fill("10/06/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input()).toHaveValue("15.06.2024") + }) + + test("a date whose month is out of range for the locale's field order is rejected", async () => { + await render(german(`[defaultValue]="value"`)) + + await input().fill("06.20.2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input()).toHaveValue("15.06.2024") + }) + + test("the weekday header starts on the locale's first day of the week", async () => { + await render(german(`variant="inline" [defaultFocusedValue]="seeded"`)) + await expect.element(page.getByRole("grid")).toBeVisible() + + const headers = Array.from( + page.getByRole("grid").element().querySelectorAll("thead th"), + ).map((cell) => cell.getAttribute("aria-label")) + + expect(headers[0]).toBe("Montag") + expect(headers.at(-1)).toBe("Sonntag") + }) + + test("the month heading is localized", async () => { + await render(german(`variant="inline" [defaultFocusedValue]="seeded"`)) + + await expect + .element(page.getByRole("button", {name: /switch to month view/i})) + .toHaveTextContent("Juni") + }) + + test("switching locale reformats the value already in the field", async () => { + await render(LocaleSwitcherComponent) + await expect.element(input()).toHaveValue("06/15/2024") + + await page.getByRole("button", {name: "German"}).click() + + await expect.element(input()).toHaveValue("15.06.2024") + }) + + test("an explicitly undefined translation falls back to the built-in label", async () => { + await render(UndefinedTranslationsComponent) + + await expect + .element(page.getByRole("button", {name: "Choose date"})) + .toBeVisible() + }) + + test("translations override the built-in accessible labels", async () => { + await render(CustomTranslationsComponent) + + await expect + .element(page.getByRole("button", {name: "Kalender öffnen"})) + .toBeVisible() + + await page.getByRole("button", {name: "Kalender öffnen"}).click() + + await expect + .element(page.getByRole("button", {name: "Kalender schließen"})) + .toBeVisible() + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-multiple.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-multiple.spec.ts new file mode 100644 index 000000000..a950f6574 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-multiple.spec.ts @@ -0,0 +1,418 @@ +import {Component, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import { + DatePickerModule, + type DateValue, + parseDate, +} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent { + readonly valueChangedHandler = output() + readonly seeded = parseDate("2024-06-15") + readonly twoDates: (DateValue | null)[] = [ + parseDate("2024-06-10"), + parseDate("2024-06-20"), + ] + readonly threeDates: (DateValue | null)[] = [ + parseDate("2024-06-10"), + parseDate("2024-06-20"), + parseDate("2024-06-25"), + ] + } + return SimpleComponent +} + +// the field is the calendar trigger, so the label names it +function getField() { + return page.getByRole("combobox", {name: "Dates"}) +} + +const watchValue = `(valueChanged)="valueChangedHandler.emit($event)"` + +const twoDatesAttrs = `[defaultValue]="twoDates"` + +const tests: MultiComponentTest[] = [ + { + simple: () => simple(), + testCase: (component) => { + test(`shows the placeholder when nothing is selected — ${component.name}`, async () => { + await render(component) + await expect.element(page.getByText("Select dates")).toBeVisible() + }) + }, + }, + { + simple: () => simple(twoDatesAttrs), + testCase: (component) => { + test(`renders one dismissible tag per selected date — ${component.name}`, async () => { + await render(component) + await expect.element(page.getByText("06/10/2024")).toBeVisible() + await expect.element(page.getByText("06/20/2024")).toBeVisible() + }) + }, + }, + { + simple: () => simple(twoDatesAttrs), + testCase: (component) => { + test(`dismissing a tag removes that date — ${component.name}`, async () => { + await render(component) + + await page.getByRole("button", {name: /Remove 06\/10\/2024/i}).click() + + await expect + .element(page.getByText("06/10/2024")) + .not.toBeInTheDocument() + await expect.element(page.getByText("06/20/2024")).toBeVisible() + }) + }, + }, + { + simple: () => simple(`${twoDatesAttrs} maxSelectedDates="2"`), + testCase: (component) => { + test(`disables only unselected days once maxSelectedDates is reached — ${component.name}`, async () => { + await render(component) + await getField().click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await expect + .element(page.getByRole("gridcell", {name: /June 25, 2024/})) + .toHaveAttribute("aria-disabled", "true") + + await expect + .element(page.getByRole("gridcell", {name: /June 10, 2024/})) + .toHaveAttribute("aria-disabled", "false") + }) + }, + }, +] + +describe("DatePicker - Multiple", () => { + runTests(tests) + + test("clicking a selected day removes it from the selection", async () => { + await render(simple(twoDatesAttrs)) + await getField().click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect.element(page.getByText("06/10/2024")).not.toBeInTheDocument() + await expect.element(page.getByText("06/20/2024")).toBeVisible() + }) + + test("Enter toggles the focused day without closing the calendar", async () => { + await render(simple()) + await getField().click() + + await userEvent.keyboard("{Enter}") + await expect.element(page.getByText("06/15/2024")).toBeVisible() + + await userEvent.keyboard("{Enter}") + await expect.element(page.getByText("06/15/2024")).not.toBeInTheDocument() + await expect.element(page.getByRole("grid")).toBeVisible() + }) + + test("selections are kept in chronological order regardless of pick order", async () => { + const valueChanged = vi.fn() + await render(simple(watchValue), { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }) + await getField().click() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect + .poll(() => valueChanged) + .toHaveBeenLastCalledWith( + expect.objectContaining({ + valueAsString: ["06/10/2024", "06/20/2024"], + }), + ) + }) + + test("maxSelectedDates rejects a further selection", async () => { + const valueChanged = vi.fn() + await render( + simple(`${twoDatesAttrs} maxSelectedDates="2" ${watchValue}`), + { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }, + ) + await getField().click() + await expect.element(page.getByRole("grid")).toBeVisible() + + /** + * Playwright refuses to click an `aria-disabled` element, so drive the + * component's own selectable guard with a native click. + */ + const cell = page.getByLabelText(/June 25, 2024/).element() as HTMLElement + cell.click() + + expect(valueChanged).not.toHaveBeenCalled() + await expect.element(page.getByText("06/25/2024")).not.toBeInTheDocument() + }) + + test("a date freed up by deselection can be re-spent under maxSelectedDates", async () => { + await render(simple(`${twoDatesAttrs} maxSelectedDates="2"`)) + await getField().click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await page.getByRole("gridcell", {name: /June 25, 2024/}).click() + + await expect.element(page.getByText("06/20/2024")).toBeVisible() + await expect.element(page.getByText("06/25/2024")).toBeVisible() + await expect.element(page.getByText("06/10/2024")).not.toBeInTheDocument() + }) + + test("the multiple field has no text input, so the label names the field", async () => { + await render(simple()) + + await expect.element(getField()).toBeVisible() + await expect.element(page.getByRole("textbox")).not.toBeInTheDocument() + }) + + test("a disabled picker renders its tags as disabled", async () => { + await render(simple(`disabled [defaultValue]="twoDates"`)) + + await expect + .element(page.getByRole("button", {name: /Remove 06\/10\/2024/i})) + .toBeDisabled() + }) + + test("the inline headline collapses more than two dates into a summary", async () => { + await render(simple(`variant="inline" [defaultValue]="threeDates"`)) + + await expect.element(page.getByText("Jun 10, 2024 +2 more")).toBeVisible() + }) + + test("the inline headline lists two dates in full", async () => { + await render(simple(`variant="inline" ${twoDatesAttrs}`)) + + await expect + .element(page.getByText("Jun 10, 2024, Jun 20, 2024")) + .toBeVisible() + }) + + describe("the field acts as the calendar trigger", () => { + test("clicking the field toggles the calendar", async () => { + await render(simple()) + + await getField().click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await getField().click() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test("clicking the placeholder opens the calendar", async () => { + await render(simple()) + + await page.getByText("Select dates").click() + + await expect.element(page.getByRole("grid")).toBeVisible() + }) + + test("clicking a tag does not open the calendar", async () => { + await render(simple(twoDatesAttrs)) + + await page.getByText("06/10/2024").click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test("dismissing a tag does not open the calendar", async () => { + await render(simple(twoDatesAttrs)) + + await page.getByRole("button", {name: /Remove 06\/10\/2024/i}).click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test("clearing the value does not open the calendar", async () => { + await render(simple(twoDatesAttrs)) + + await page.getByRole("button", {name: /clear/i}).click() + + await expect.element(page.getByText("Select dates")).toBeVisible() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test.for(["{Enter}", " "])("%s opens the calendar", async (key) => { + await render(simple()) + + // open then close leaves the field focused and closed + await getField().click() + await getField().click() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect.element(getField()).toHaveFocus() + + await userEvent.keyboard(key) + await expect.element(page.getByRole("grid")).toBeVisible() + }) + + test("ArrowDown opens the calendar", async () => { + await render(simple()) + + await getField().click() + await getField().click() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + + await userEvent.keyboard("{ArrowDown}") + await expect.element(page.getByRole("grid")).toBeVisible() + }) + + test("escape closes the calendar and restores focus to the field", async () => { + await render(simple()) + + await getField().click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Escape}") + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect.element(getField()).toHaveFocus() + }) + + test("a disabled field does not open the calendar", async () => { + await render(simple("disabled")) + + await expect.element(getField()).toHaveAttribute("aria-disabled", "true") + + const field = getField().element() as HTMLElement + field.click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test("a readonly field does not open the calendar", async () => { + await render(simple("readOnly")) + + await expect.element(getField()).toHaveAttribute("aria-readonly", "true") + + await getField().click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test("switching selectionMode at runtime hands the trigger over cleanly", async () => { + @Component({ + imports: [DatePickerModule], + template: ` + + + `, + }) + class ModeSwitcherComponent { + readonly seeded = parseDate("2024-06-15") + mode: "multiple" | "single" = "multiple" + + swap() { + this.mode = this.mode === "multiple" ? "single" : "multiple" + } + } + + await render(ModeSwitcherComponent) + await expect.element(getField()).toBeVisible() + + await page.getByRole("button", {name: "Swap mode"}).click() + + const trigger = page.getByRole("button", { + name: /(?:choose|change) date/i, + }) + await expect.element(trigger).toBeVisible() + await trigger.click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Escape}") + await expect.element(trigger).toHaveFocus() + + await page.getByRole("button", {name: "Swap mode"}).click() + + await expect.element(getField()).toBeVisible() + await getField().click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Escape}") + await expect.element(getField()).toHaveFocus() + }) + + test("switching to range restores the group role on the field", async () => { + @Component({ + imports: [DatePickerModule], + template: ` + + + `, + }) + class RangeSwitcherComponent { + readonly seeded = parseDate("2024-06-15") + mode: "multiple" | "range" = "multiple" + + swap() { + this.mode = this.mode === "multiple" ? "range" : "multiple" + } + } + + await render(RangeSwitcherComponent) + await expect.element(getField()).toBeVisible() + + await page.getByRole("button", {name: "Swap mode"}).click() + + // dir and role both change source with the mode, so a second binding + // system on this element used to blank them out here + const group = page.getByRole("group", {name: "Dates"}) + await expect.element(group).toBeVisible() + await expect.element(group).toHaveAttribute("dir", "ltr") + + await page.getByRole("button", {name: "Swap mode"}).click() + + await expect.element(getField()).toBeVisible() + await expect.element(getField()).toHaveAttribute("dir", "ltr") + }) + + test("clicking the label focuses the field", async () => { + await render(simple()) + + await page.getByText("Dates", {exact: true}).click() + + await expect.element(getField()).toHaveFocus() + }) + + test("the calendar icon is not a separate tab stop", async () => { + await render(simple()) + + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .not.toBeInTheDocument() + }) + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-navigation.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-navigation.spec.ts new file mode 100644 index 000000000..e1b025eab --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-navigation.spec.ts @@ -0,0 +1,361 @@ +import {Component, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" +import type { + DatePickerFocusChangeDetails, + DatePickerVisibleRangeChangeDetails, +} from "@qualcomm-ui/core/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +const seeded = parseDate("2024-06-15") + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + + + +
+ + + +
+
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent { + readonly focusChangedHandler = output() + readonly valueChangedHandler = output() + readonly visibleRangeChangedHandler = + output() + protected readonly max = parseDate("2024-06-30") + protected readonly maxNextMonth = parseDate("2024-07-31") + protected readonly min = parseDate("2024-06-01") + protected readonly seeded = seeded + } + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent { + readonly focusChangedHandler = output() + readonly valueChangedHandler = output() + readonly visibleRangeChangedHandler = + output() + protected readonly max = parseDate("2024-06-30") + protected readonly maxNextMonth = parseDate("2024-07-31") + protected readonly min = parseDate("2024-06-01") + protected readonly seeded = seeded + } + return SimpleComponent +} + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +const nextTrigger = () => + page.getByRole("button", {name: /switch to next month/i}) +const prevTrigger = () => + page.getByRole("button", {name: /switch to previous month/i}) + +async function openCalendarAndWaitForFocus() { + await openCalendar() + await expect.element(page.getByLabelText(/June 15, 2024/)).toHaveFocus() +} + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`the next trigger advances the visible month — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await nextTrigger().click() + + await expect + .element(page.getByRole("gridcell", {name: /July 1, 2024/})) + .toBeVisible() + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .not.toBeInTheDocument() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`the previous trigger steps back across the year boundary — ${component.name}`, async () => { + await render(component) + await openCalendar() + + for (let i = 0; i < 6; i++) { + await prevTrigger().click() + } + + await expect + .element(page.getByRole("gridcell", {name: /December 15, 2023/})) + .toBeVisible() + }) + }, + }, + { + composite: () => composite(`[max]="max" [min]="min"`), + simple: () => simple(`[max]="max" [min]="min"`), + testCase: (component) => { + test(`both paging triggers are disabled when min and max fence the visible month in — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await expect.element(prevTrigger()).toBeDisabled() + await expect.element(nextTrigger()).toBeDisabled() + }) + }, + }, + { + composite: () => composite(`[max]="maxNextMonth"`), + simple: () => simple(`[max]="maxNextMonth"`), + testCase: (component) => { + test(`the next trigger disables only once the last allowed month is reached — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await expect.element(nextTrigger()).not.toBeDisabled() + await nextTrigger().click() + + await expect.element(nextTrigger()).toBeDisabled() + await expect.element(prevTrigger()).not.toBeDisabled() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`arrow keys page the visible month when focus crosses its edge — ${component.name}`, async () => { + await render(component) + await openCalendarAndWaitForFocus() + + // June 15 -> June 1 -> May 25 + await userEvent.keyboard("{Home}{ArrowUp}") + + await expect + .element(page.getByRole("gridcell", {name: /May 25, 2024/})) + .toBeVisible() + await expect.element(page.getByLabelText(/May 25, 2024/)).toHaveFocus() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`PageDown and PageUp move focus a month at a time — ${component.name}`, async () => { + await render(component) + await openCalendarAndWaitForFocus() + + await userEvent.keyboard("{PageDown}") + await expect.element(page.getByLabelText(/July 15, 2024/)).toHaveFocus() + + await userEvent.keyboard("{PageUp}") + await expect.element(page.getByLabelText(/June 15, 2024/)).toHaveFocus() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`Shift+PageDown moves focus a year at a time — ${component.name}`, async () => { + await render(component) + await openCalendarAndWaitForFocus() + + await userEvent.keyboard("{Shift>}{PageDown}{/Shift}") + + await expect.element(page.getByLabelText(/June 15, 2025/)).toHaveFocus() + }) + }, + }, + { + composite: () => composite(`dir="rtl"`), + simple: () => simple(`dir="rtl"`), + testCase: (component) => { + test(`arrow keys are mirrored in RTL — ${component.name}`, async () => { + await render(component) + await openCalendarAndWaitForFocus() + + await userEvent.keyboard("{ArrowRight}") + await expect.element(page.getByLabelText(/June 14, 2024/)).toHaveFocus() + + await userEvent.keyboard("{ArrowLeft}") + await expect.element(page.getByLabelText(/June 15, 2024/)).toHaveFocus() + }) + }, + }, +] + +describe("DatePicker - Navigation", () => { + runTests(tests) + + test("the paging triggers step by year in the month view and by decade in the year view", async () => { + await render(simple()) + await openCalendar() + + await page.getByRole("button", {name: /switch to month view/i}).click() + await page.getByRole("button", {name: /switch to next year/i}).click() + await expect + .element(page.getByRole("button", {name: /switch to year view/i})) + .toHaveTextContent("2025") + + await page.getByRole("button", {name: /return to calendar/i}).click() + await page.getByRole("button", {name: /switch to year view/i}).click() + await page.getByRole("button", {name: /switch to previous decade/i}).click() + + await expect + .element(page.getByRole("gridcell", {name: "2010"})) + .toBeVisible() + await expect + .element(page.getByRole("gridcell", {name: "2019"})) + .toBeVisible() + }) + + test("a range picker announces one visible month without repeating it", async () => { + await render(simple(`selectionMode="range"`)) + await openCalendar() + + await nextTrigger().click() + + await expect + .element(page.getByRole("status")) + .toHaveTextContent(/^July 2024$/) + }) + + test("paging the day view announces the newly visible month", async () => { + await render(simple()) + await openCalendar() + + await nextTrigger().click() + + await expect + .element(page.getByRole("status")) + .toHaveTextContent("July 2024") + }) + + test("a visible range change is announced politely", async () => { + await render(simple()) + await openCalendar() + + await page.getByRole("button", {name: /switch to month view/i}).click() + await page.getByRole("button", {name: /switch to next year/i}).click() + + await expect.element(page.getByRole("status")).toHaveTextContent("2025") + }) + + test("visibleRangeChanged reports the newly visible month", async () => { + const visibleRangeChanged = vi.fn() + await render( + simple(`(visibleRangeChanged)="visibleRangeChangedHandler.emit($event)"`), + { + on: { + visibleRangeChangedHandler: (details) => { + visibleRangeChanged(details) + }, + }, + }, + ) + await openCalendar() + + await nextTrigger().click() + + await expect + .poll(() => visibleRangeChanged) + .toHaveBeenCalledWith( + expect.objectContaining({ + visibleRange: expect.objectContaining({ + start: expect.objectContaining({month: 7, year: 2024}), + }), + }), + ) + }) + + test("focusChanged reports roving focus without committing a value", async () => { + const focusChanged = vi.fn() + const valueChanged = vi.fn() + await render( + simple(` + (focusChanged)="focusChangedHandler.emit($event)" + (valueChanged)="valueChangedHandler.emit()" + `), + { + on: { + focusChangedHandler: (details) => { + focusChanged(details) + }, + valueChangedHandler: () => { + valueChanged() + }, + }, + }, + ) + await openCalendarAndWaitForFocus() + + await userEvent.keyboard("{ArrowRight}") + + await expect + .poll(() => focusChanged) + .toHaveBeenCalledWith( + expect.objectContaining({ + focusedValue: expect.objectContaining({ + day: 16, + month: 6, + year: 2024, + }), + }), + ) + expect(valueChanged).not.toHaveBeenCalled() + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-open-close.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-open-close.spec.ts new file mode 100644 index 000000000..76cfd9824 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-open-close.spec.ts @@ -0,0 +1,134 @@ +import {Component} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +/** + * `booleanAttribute` maps `undefined` to `false`, so an unset boolean input has + * to be left off the template rather than bound to `undefined`. + */ +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + + + +
+ + + +
+
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return `` +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent {} + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent {} + return SimpleComponent +} + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`opens on trigger click and closes on Escape, restoring focus to the trigger — ${component.name}`, async () => { + await render(component) + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Escape}") + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toHaveFocus() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`toggles closed when the trigger is clicked while open — ${component.name}`, async () => { + await render(component) + + const trigger = page.getByRole("button", { + name: /(?:choose|change) date|close calendar/i, + }) + await trigger.click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await trigger.click() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + }, + }, + { + composite: () => composite("defaultOpen"), + simple: () => simple("defaultOpen"), + testCase: (component) => { + test(`defaultOpen renders the calendar initially open — ${component.name}`, async () => { + await render(component) + await expect.element(page.getByRole("grid")).toBeVisible() + }) + }, + }, + { + composite: () => composite(`defaultOpen [open]="false"`), + simple: () => simple(`defaultOpen [open]="false"`), + testCase: (component) => { + test(`controlled open false wins over defaultOpen — ${component.name}`, async () => { + await render(component) + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + }, + }, +] + +describe("DatePicker - Open/Close", () => { + runTests(tests) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-parts.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-parts.spec.ts new file mode 100644 index 000000000..844a2ca7e --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-parts.spec.ts @@ -0,0 +1,463 @@ +import {Component} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test} from "vitest" +import {page} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {ButtonModule} from "@qualcomm-ui/angular/button" +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +const seeded = parseDate("2024-06-15") + +const weekdays = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] + +const dayView = ` +
+
+ + +
+ + + +
+
+` + +function popover(content: string) { + return ` + +
+
${content}
+
+
+ ` +} + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ ${popover(` + ${dayView} +
+
+ + +
+ + +
+
+
+
+ + +
+ + +
+
+ `)} +
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +function customTriggerTemplate() { + return ` +
+ +
+ +
+
+ ${popover(dayView)} +
+ ` +} + +function contextValueTemplate() { + return ` +
+ + {{ api.valueAsString[0] ?? "nothing selected" }} + +
+
+
+ ${popover(dayView)} +
+ ` +} + +function triggerOnlyTemplate() { + return ` +
+
+ +
+ ${popover(dayView)} +
+ ` +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent { + readonly seeded = seeded + } + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent { + readonly seeded = seeded + readonly selected = [parseDate("2024-06-10")] + readonly value = [seeded] + } + return SimpleComponent +} + +@Component({ + imports: [DatePickerModule], + template: ` + + `, +}) +class MultipleNoLabelComponent { + readonly selected = [parseDate("2024-06-10"), parseDate("2024-06-12")] +} + +@Component({ + imports: [ButtonModule, DatePickerModule, PortalDirective], + template: customTriggerTemplate(), +}) +class CustomTriggerComponent { + readonly seeded = seeded +} + +@Component({ + imports: [ButtonModule, DatePickerModule, PortalDirective], + styles: ` + :host { + display: block; + padding: 120px 0 0 200px; + } + `, + template: customTriggerTemplate(), +}) +class OffsetCustomTriggerComponent { + readonly seeded = seeded +} + +@Component({ + imports: [DatePickerModule, PortalDirective], + template: contextValueTemplate(), +}) +class ContextValueComponent { + readonly seeded = seeded +} + +@Component({ + imports: [ButtonModule, DatePickerModule, PortalDirective], + template: triggerOnlyTemplate(), +}) +class TriggerOnlyComponent { + readonly seeded = seeded +} + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`trigger accessible name reflects the open state — ${component.name}`, async () => { + await render(component) + + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + + await expect + .element(page.getByRole("button", {name: /close calendar/i})) + .toBeVisible() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`the popup is a dialog announced by name, wrapping an application view — ${component.name}`, async () => { + await render(component) + + const trigger = page.getByRole("button", { + name: /(?:choose|change) date/i, + }) + await expect.element(trigger).toHaveAttribute("aria-haspopup", "dialog") + await trigger.click() + + const dialog = page.getByRole("dialog") + await expect.element(dialog).toBeVisible() + await expect.element(dialog).toHaveAccessibleName("calendar") + await expect + .element(dialog) + .toHaveAttribute("aria-roledescription", "datepicker") + + await expect.element(page.getByRole("application")).toBeVisible() + }) + }, + }, + { + simple: () => simple(`variant="inline"`), + testCase: (component) => { + test(`the inline calendar is a group, not a dialog — ${component.name}`, async () => { + await render(component) + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument() + await expect + .element(page.getByRole("group", {name: "calendar"})) + .toBeVisible() + await expect.element(page.getByRole("application")).toBeVisible() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`weekday headers expose full day names to assistive technology — ${component.name}`, async () => { + await render(component) + + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + const headers = page.getByRole("columnheader") + expect(headers.elements()).toHaveLength(7) + + for (const [index, weekday] of weekdays.entries()) { + await expect.element(headers.nth(index)).toHaveAccessibleName(weekday) + } + }) + }, + }, + { + simple: () => simple(`variant="inline"`), + testCase: (component) => { + test(`inline variant renders an always-open calendar with no popover trigger — ${component.name}`, async () => { + await render(component) + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .not.toBeInTheDocument() + }) + }, + }, + { + composite: () => CustomTriggerComponent, + testCase: (component) => { + test(`a custom Trigger renders custom content and opens the calendar — ${component.name}`, async () => { + await render(component) + + await expect.element(page.getByText("Pick a date")).toBeVisible() + + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + }) + }, + }, + { + composite: () => composite(`minView="month"`), + testCase: (component) => { + test(`minView='month' opens directly at the month view — ${component.name}`, async () => { + await render(component) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + + await expect + .element(page.getByRole("grid")) + .toHaveAttribute("aria-roledescription", "calendar year") + }) + }, + }, +] + +describe("DatePicker - Parts", () => { + runTests(tests) + + test("the trigger confirms the selected date in its accessible name", async () => { + await render(simple(`[defaultValue]="selected"`)) + + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toHaveAccessibleName("Change date, Monday, June 10, 2024") + }) + + test("the trigger names both ends of a committed range, and a half-open one", async () => { + await render(simple(`selectionMode="range" [defaultValue]="selected"`)) + + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toHaveAccessibleName("Change date range, from Monday, June 10, 2024") + }) + + test("the field-as-trigger counts selections in multiple mode", async () => { + await render(MultipleNoLabelComponent) + + await expect + .element(page.getByLabelText(/change dates/i)) + .toHaveAccessibleName("Change dates, 2 selected") + }) + + test("the calendar grid is named by the range it shows, at every view level", async () => { + await render(simple(`variant="inline"`)) + + const grid = () => page.getByRole("grid") + await expect.element(grid()).toHaveAccessibleName("June 2024") + + await page.getByRole("button", {name: /switch to next month/i}).click() + await expect.element(grid()).toHaveAccessibleName("July 2024") + + await page.getByRole("button", {name: /switch to month view/i}).click() + await expect.element(grid()).toHaveAccessibleName("2024") + + await page.getByRole("button", {name: /return to calendar/i}).click() + await page.getByRole("button", {name: /switch to year view/i}).click() + await expect.element(grid()).toHaveAccessibleName("2020 - 2029") + }) + + test("a range picker's grid name does not repeat a single visible month", async () => { + await render(simple(`selectionMode="range" variant="inline"`)) + + await expect + .element(page.getByRole("grid")) + .toHaveAccessibleName("June 2024") + }) + + test("the inline headline captions the current selection", async () => { + await render(simple(`variant="inline"`)) + + await expect.element(page.getByText("Date", {exact: true})).toBeVisible() + await expect.element(page.getByText("Select date")).toBeVisible() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByText("Thu, June 20, 2024")).toBeVisible() + }) + + test("a range picker's headline caption names the range", async () => { + await render(simple(`selectionMode="range" variant="inline"`)) + + await expect.element(page.getByText("Date range")).toBeVisible() + }) + + test("headline=false drops the headline from the inline calendar", async () => { + await render(simple(`variant="inline" [headline]="false"`)) + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect.element(page.getByText("Select date")).not.toBeInTheDocument() + }) + + test("the inline variant has no field, so there is no input or clear trigger", async () => { + await render(simple(`variant="inline" [defaultValue]="value"`)) + + await expect.element(page.getByRole("textbox")).not.toBeInTheDocument() + await expect + .element(page.getByRole("button", {name: /clear selected dates/i})) + .not.toBeInTheDocument() + }) + + test("the context directive updates once a selection commits", async () => { + await render(ContextValueComponent) + await expect.element(page.getByText("nothing selected")).toBeVisible() + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByText("06/20/2024")).toBeVisible() + await expect + .element(page.getByText("nothing selected")) + .not.toBeInTheDocument() + }) + + test("a custom Trigger anchors the calendar to the control", async () => { + await render(OffsetCustomTriggerComponent) + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await expect.element(page.getByRole("grid")).toBeVisible() + + const positioner = page + .getByRole("grid") + .element() + .closest("[data-date-picker-part=positioner]") as HTMLElement + const control = positioner.ownerDocument.querySelector( + "[data-date-picker-part=control]", + ) as HTMLElement + + await expect + .poll(() => positioner.style.getPropertyValue("--x")) + .not.toBe("") + + const controlRect = control.getBoundingClientRect() + const positionerRect = positioner.getBoundingClientRect() + + expect(Math.abs(positionerRect.left - controlRect.left)).toBeLessThan(8) + expect(positionerRect.top).toBeGreaterThan(controlRect.top) + }) + + test("a trigger-only picker returns focus to the trigger after a selection", async () => { + await render(TriggerOnlyComponent) + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toHaveFocus() + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-presets.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-presets.spec.ts new file mode 100644 index 000000000..f07bf5cb8 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-presets.spec.ts @@ -0,0 +1,245 @@ +import {Component, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test, vi} from "vitest" +import {page} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import { + type DatePickerPreset, + DatePickerModule, + parseDate, +} from "@qualcomm-ui/angular/date-picker" + +const seeded = parseDate("2024-06-15") + +const presets: DatePickerPreset[] = [ + {label: "Next 7 days", value: "next7Days"}, + {label: "This month", value: "thisMonth"}, + { + label: "Fixed span", + value: [parseDate("2024-07-01"), parseDate("2024-07-10")], + }, +] + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + + + +
+ + + +
+
+
+ @for (preset of presets; track $index) { + + } +
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = `[presets]="presets"`) { + return ` + + ` +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent { + protected readonly presets = presets + protected readonly seeded = seeded + } + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent { + readonly valueChangedHandler = output() + protected readonly presets = presets + protected readonly seeded = seeded + } + return SimpleComponent +} + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() +const presetsTrigger = () => page.getByRole("button", {name: /show presets/i}) +const calendarTrigger = () => page.getByRole("button", {name: /show calendar/i}) +const startInput = () => page.getByRole("textbox", {name: /start date/i}) +const endInput = () => page.getByRole("textbox", {name: /end date/i}) + +describe("DatePicker - Presets", () => { + test("the presets trigger reveals the preset list and flips its own label", async () => { + await render(simple()) + await openCalendar() + + await expect.element(page.getByText("Next 7 days")).not.toBeVisible() + + await presetsTrigger().click() + + await expect.element(page.getByText("Next 7 days")).toBeVisible() + await expect.element(calendarTrigger()).toBeVisible() + + await calendarTrigger().click() + + await expect.element(page.getByText("Next 7 days")).not.toBeVisible() + await expect.element(presetsTrigger()).toBeVisible() + }) + + test("no presets trigger is rendered when no presets are given", async () => { + await render(simple("")) + await openCalendar() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect.element(presetsTrigger()).not.toBeInTheDocument() + }) + + test("a named preset commits a complete range and closes the panel", async () => { + const valueChanged = vi.fn() + await render( + simple(`[presets]="presets" (valueChanged)="valueChangedHandler.emit()"`), + { + on: { + valueChangedHandler: () => { + valueChanged() + }, + }, + }, + ) + await openCalendar() + + await presetsTrigger().click() + await page.getByText("Next 7 days").click() + + await expect.element(startInput()).not.toHaveValue("") + await expect.element(endInput()).not.toHaveValue("") + await expect.element(page.getByText("Next 7 days")).not.toBeVisible() + expect(valueChanged).toHaveBeenCalled() + }) + + test("a preset given as explicit dates commits exactly those dates", async () => { + await render(simple()) + await openCalendar() + + await presetsTrigger().click() + await page.getByText("Fixed span").click() + + await expect.element(startInput()).toHaveValue("07/01/2024") + await expect.element(endInput()).toHaveValue("07/10/2024") + }) + + test("choosing a preset moves the calendar onto the preset's range", async () => { + await render(simple()) + await openCalendar() + + await presetsTrigger().click() + await page.getByText("Fixed span").click() + + await expect + .element(page.getByRole("button", {name: /switch to month view/i})) + .toHaveTextContent("July") + }) + + test("the paging and view triggers are inert while the preset panel is open", async () => { + await render(composite()) + await openCalendar() + + await presetsTrigger().click() + + await expect + .element(page.getByRole("button", {name: /switch to next month/i})) + .toBeDisabled() + await expect + .element(page.getByRole("button", {name: /switch to previous month/i})) + .toBeDisabled() + await expect + .element(page.getByRole("button", {name: /switch to month view/i})) + .toBeDisabled() + }) + + test("reopening the calendar starts with the preset panel closed", async () => { + await render(simple()) + await openCalendar() + await presetsTrigger().click() + await expect.element(page.getByText("Next 7 days")).toBeVisible() + + await page.getByRole("button", {name: /close calendar/i}).click() + await openCalendar() + + await expect.element(page.getByText("Next 7 days")).not.toBeVisible() + }) + + test("a preset trigger describes the range it will apply", async () => { + await render(simple()) + await openCalendar() + await presetsTrigger().click() + + await expect + .element(page.getByText("Fixed span")) + .toHaveAttribute( + "aria-label", + "select Monday, July 1, 2024 to Wednesday, July 10, 2024", + ) + }) + + test("the preset label follows the picker's locale and time zone", async () => { + await render(simple(`locale="de-DE" [presets]="presets"`)) + await openCalendar() + await presetsTrigger().click() + + await expect + .element(page.getByText("Fixed span")) + .toHaveAttribute( + "aria-label", + "select Montag, 1. Juli 2024 to Mittwoch, 10. Juli 2024", + ) + }) + + test("a readOnly picker will not open the preset panel", async () => { + await render(simple(`readOnly variant="inline" [presets]="presets"`)) + + await presetsTrigger().click() + + await expect.element(page.getByText("Next 7 days")).not.toBeVisible() + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-range.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-range.spec.ts new file mode 100644 index 000000000..e93360919 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-range.spec.ts @@ -0,0 +1,401 @@ +import {Component, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import { + DatePickerModule, + type DateValue, + parseDate, +} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + +
+ + + +
+
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +class TestHost { + readonly valueChangedHandler = output() + readonly seeded = parseDate("2024-06-15") + readonly fullRange: (DateValue | null)[] = [ + parseDate("2024-06-10"), + parseDate("2024-06-20"), + ] +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent extends TestHost {} + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent extends TestHost {} + return SimpleComponent +} + +const startInput = () => page.getByRole("textbox", {name: /start date/i}) +const endInput = () => page.getByRole("textbox", {name: /end date/i}) + +/** + * Cell state (`data-*`) and focus live on the trigger inside the `gridcell`, + * not on the cell itself. + */ +const dayTrigger = (day: string) => page.getByLabelText(day) + +const watchValue = `(valueChanged)="valueChangedHandler.emit($event)"` + +const fullRangeAttrs = `[defaultValue]="fullRange"` + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`the range start keeps the calendar open and the end closes it — ${component.name}`, async () => { + await render(component) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("") + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`typing the end date first leaves the start slot empty, then accepts a start date — ${component.name}`, async () => { + await render(component) + + await endInput().fill("06/20/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(startInput()).toHaveValue("") + await expect.element(endInput()).toHaveValue("06/20/2024") + + await startInput().fill("06/10/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`normalizes a reversed range entered via input — ${component.name}`, async () => { + await render(component) + + await startInput().fill("06/20/2024") + await userEvent.keyboard("{Enter}") + await endInput().fill("06/10/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => composite(`${fullRangeAttrs} ${watchValue}`), + simple: () => simple(`${fullRangeAttrs} ${watchValue}`), + testCase: (component) => { + test(`clearing only the start input leaves the end date intact — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }) + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + + await startInput().fill("") + await userEvent.keyboard("{Enter}") + + await expect.element(startInput()).toHaveValue("") + await expect.element(endInput()).toHaveValue("06/20/2024") + await expect + .poll(() => valueChanged) + .toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["", "06/20/2024"]}), + ) + }) + }, + }, + { + composite: () => composite(fullRangeAttrs), + simple: () => simple(fullRangeAttrs), + testCase: (component) => { + test(`reverts an unparsable start date when tabbing to the end input, leaving the end intact — ${component.name}`, async () => { + await render(component) + + await startInput().fill("64564") + await userEvent.keyboard("{Tab}") + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`reverts trailing garbage typed into the start input after it committed — ${component.name}`, async () => { + await render(component) + + await startInput().fill("06/10/2024") + await userEvent.keyboard("{Enter}") + await expect.element(startInput()).toHaveValue("06/10/2024") + + await userEvent.keyboard("99") + await userEvent.keyboard("{Tab}") + + await expect.element(startInput()).toHaveValue("06/10/2024") + }) + }, + }, +] + +describe("DatePicker - Range", () => { + runTests(tests) + + test("previews the range band while hovering after picking a start date", async () => { + await render(simple()) + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await dayTrigger("June 20, 2024").hover() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect + .element(dayTrigger("June 10, 2024")) + .toHaveAttribute("data-hover-range-start") + await expect + .element(dayTrigger("June 15, 2024")) + .toHaveAttribute("data-in-hover-range") + await expect + .element(dayTrigger("June 20, 2024")) + .toHaveAttribute("data-hover-range-end") + + await expect + .element(dayTrigger("June 25, 2024")) + .not.toHaveAttribute("data-in-hover-range") + }) + + test("marks the committed range band once both endpoints are selected", async () => { + await render(simple(`[closeOnSelect]="false"`)) + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect + .element(dayTrigger("June 10, 2024")) + .toHaveAttribute("data-range-start") + await expect + .element(dayTrigger("June 15, 2024")) + .toHaveAttribute("data-in-range") + await expect + .element(dayTrigger("June 20, 2024")) + .toHaveAttribute("data-range-end") + + await expect + .element(dayTrigger("June 25, 2024")) + .not.toHaveAttribute("data-in-range") + }) + + test("leaving the grid clears the previewed band", async () => { + await render(simple()) + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await dayTrigger("June 20, 2024").hover() + await expect + .element(dayTrigger("June 15, 2024")) + .toHaveAttribute("data-in-hover-range") + + await page.getByRole("button", {name: /switch to next month/i}).hover() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect + .element(dayTrigger("June 15, 2024")) + .not.toHaveAttribute("data-in-hover-range") + }) + + test("keyboard navigation previews the band the same way hovering does", async () => { + await render(simple()) + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await userEvent.keyboard("{Enter}") + await userEvent.keyboard("{ArrowRight}{ArrowRight}") + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect + .element(dayTrigger("June 17, 2024")) + .toHaveAttribute("data-in-hover-range") + await expect + .element(dayTrigger("June 18, 2024")) + .toHaveAttribute("data-hover-range-end") + }) + + test("a third click starts a new range from the clicked day", async () => { + await render(simple(`[closeOnSelect]="false"`)) + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + + await page.getByRole("gridcell", {name: /June 25, 2024/}).click() + + await expect.element(startInput()).toHaveValue("06/25/2024") + await expect.element(endInput()).toHaveValue("") + }) + + test("a range half-entered by typing resumes at the end slot in the calendar", async () => { + await render(simple()) + + await startInput().fill("06/10/2024") + await userEvent.keyboard("{Enter}") + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + + test("a range left with only an end date resumes at the start slot in the calendar", async () => { + await render(simple(fullRangeAttrs)) + + await startInput().fill("") + await userEvent.keyboard("{Enter}") + await expect.element(startInput()).toHaveValue("") + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 5, 2024/}).click() + + await expect.element(startInput()).toHaveValue("06/05/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + + test("the second calendar pick completes the range regardless of click order", async () => { + await render(simple()) + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + + test("clearing only the end input leaves the start date intact", async () => { + const valueChanged = vi.fn() + await render(simple(`${fullRangeAttrs} ${watchValue}`), { + on: {valueChangedHandler: (event: unknown) => valueChanged(event)}, + }) + + await endInput().fill("") + await userEvent.keyboard("{Enter}") + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("") + await expect + .poll(() => valueChanged) + .toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["06/10/2024"]}), + ) + }) + + test("the range headline shows placeholders until both endpoints are set", async () => { + await render(simple(`variant="inline"`)) + + await expect.element(page.getByText("Start - End")).toBeVisible() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect.element(page.getByText("Jun 10, 2024 - End")).toBeVisible() + }) + + test("the default separator sits between the range inputs", async () => { + await render(simple()) + + await expect + .element(page.getByText("-", {exact: true})) + .toHaveAttribute("data-date-picker-part", "range-separator") + }) + + test("separator overrides the character between the range inputs", async () => { + await render(simple(`separator="to"`)) + + await expect.element(page.getByText("to", {exact: true})).toBeVisible() + await expect + .element(page.getByText("-", {exact: true})) + .not.toBeInTheDocument() + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-selection.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-selection.spec.ts new file mode 100644 index 000000000..d3c73c27b --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-selection.spec.ts @@ -0,0 +1,214 @@ +import {Component} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import { + DatePickerModule, + type DateValue, + parseDate, +} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +const seeded = parseDate("2024-06-15") + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + + + +
+ + + +
+
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent { + readonly seeded: DateValue = seeded + } + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent { + readonly seeded: DateValue = seeded + } + return SimpleComponent +} + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`selecting a day with the pointer commits it and closes — ${component.name}`, async () => { + await render(component) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`Space selects the focused day the calendar opened on — ${component.name}`, async () => { + await render(component) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{ }") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/15/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`Enter selects the focused day the calendar opened on — ${component.name}`, async () => { + await render(component) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Enter}") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/15/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`arrow keys move day focus before committing — ${component.name}`, async () => { + await render(component) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{ArrowRight}{Enter}") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/16/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`ArrowDown moves focus forward one week — ${component.name}`, async () => { + await render(component) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{ArrowDown}{Enter}") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/22/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`Home jumps to the first day of the month — ${component.name}`, async () => { + await render(component) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Home}{Enter}") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/01/2024") + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`End jumps to the last day of the month — ${component.name}`, async () => { + await render(component) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{End}{Enter}") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/30/2024") + }) + }, + }, +] + +describe("DatePicker - Selection", () => { + runTests(tests) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-slots.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-slots.spec.ts new file mode 100644 index 000000000..7bfae4d0d --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-slots.spec.ts @@ -0,0 +1,233 @@ +import {Component} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test} from "vitest" +import {page} from "vitest/browser" + +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" + +class TestHost { + readonly seeded = parseDate("2024-06-15") +} + +function host(template: string) { + @Component({ + imports: [DatePickerModule], + template, + }) + class SlotHost extends TestHost {} + return SlotHost +} + +const field = (slot: string, attrs = "") => ` + + ${slot} + +` + +const inline = (slot: string, attrs = "") => ` + + ${slot} + +` + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +describe("DatePicker - Slots", () => { + test("a projected hint replaces the default", async () => { + await render(host(field(`
Projected hint
`))) + + await expect.element(page.getByText("Projected hint")).toBeVisible() + }) + + test("a projected hint wins over the hint input", async () => { + await render( + host( + field(`
Projected hint
`, `hint="Ignored"`), + ), + ) + + await expect.element(page.getByText("Projected hint")).toBeVisible() + await expect.element(page.getByText("Ignored")).not.toBeInTheDocument() + }) + + test("a projected error text replaces the default", async () => { + await render( + host( + field( + `
Projected error
`, + `invalid errorText="Ignored"`, + ), + ), + ) + + await expect.element(page.getByText("Projected error")).toBeVisible() + await expect.element(page.getByText("Ignored")).not.toBeInTheDocument() + }) + + test("a projected control replaces the default field", async () => { + await render( + host( + field(` +
+
+
+ `), + ), + ) + + await expect + .element(page.getByRole("textbox", {name: /projected/i})) + .toBeVisible() + }) + + test("a projected input group carries its own separator", async () => { + await render( + host( + field( + `
`, + `selectionMode="range"`, + ), + ), + ) + + await expect.element(page.getByText("to", {exact: true})).toBeVisible() + }) + + test("a projected headline value carries its own placeholder", async () => { + await render( + host( + inline( + ``, + ), + ), + ) + + await expect.element(page.getByText("Nothing picked")).toBeVisible() + }) + + test("a projected headline replaces the whole headline", async () => { + await render( + host( + inline(` +
+ + +
+ `), + ), + ) + + await expect.element(page.getByText("Select date")).toBeVisible() + }) + + test("a projected headline label replaces the default", async () => { + await render( + host(inline(`Pick a day`)), + ) + + await expect.element(page.getByText("Pick a day")).toBeVisible() + }) + + test("a projected headline survives headline=false", async () => { + await render( + host( + inline( + ` +
+ Custom caption +
+ `, + `[headline]="false"`, + ), + ), + ) + + await expect.element(page.getByText("Custom caption")).toBeVisible() + }) + + test("headline=false still drops the default headline", async () => { + await render(host(inline("", `[headline]="false"`))) + + await expect.element(page.getByText("Select date")).not.toBeInTheDocument() + }) + + test("a projected presets block replaces the default", async () => { + await render( + host( + inline(` +
+ +
+ `), + ), + ) + await page.getByRole("button", {name: /presets/i}).click() + + await expect.element(page.getByText("Coming week")).toBeVisible() + }) + + test("a projected actions block replaces the default footer", async () => { + await render( + host( + field( + ` +
+ + +
+ `, + `[closeOnSelect]="false"`, + ), + ), + ) + await openCalendar() + + await expect.element(page.getByText("Confirm")).toBeVisible() + await expect.element(page.getByText("Nope")).toBeVisible() + }) + + test("projected actions render even without an actions-forcing input", async () => { + await render( + host( + field(` +
+ + +
+ `), + ), + ) + await openCalendar() + + await expect.element(page.getByText("Confirm")).toBeVisible() + }) + + test("projected actions are suppressed for the inline variant", async () => { + await render( + host( + inline(` +
+ + +
+ `), + ), + ) + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect.element(page.getByText("Confirm")).not.toBeInTheDocument() + }) + + test("the defaults still render when nothing is projected", async () => { + await render(host(field("", `hint="Default hint"`))) + + await expect.element(page.getByText("Default hint")).toBeVisible() + + await openCalendar() + + await expect.element(page.getByRole("grid")).toBeVisible() + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-states.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-states.spec.ts new file mode 100644 index 000000000..a6e392855 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-states.spec.ts @@ -0,0 +1,128 @@ +import {Component, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import { + DatePickerModule, + type DateValue, + parseDate, +} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + +
+ + + +
+
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +class TestHost { + readonly valueChangedHandler = output() + readonly seeded = parseDate("2024-06-15") + readonly seededValue: (DateValue | null)[] = [parseDate("2024-06-15")] +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent extends TestHost {} + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent extends TestHost {} + return SimpleComponent +} + +const tests: MultiComponentTest[] = [ + { + composite: () => composite("disabled"), + simple: () => simple("disabled"), + testCase: (component) => { + test(`disabled prevents opening the calendar — ${component.name}`, async () => { + await render(component) + + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toBeDisabled() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + }, + }, + { + composite: () => composite(`[defaultValue]="seededValue" readOnly`), + simple: () => simple(`[defaultValue]="seededValue" readOnly`), + testCase: (component) => { + test(`readOnly leaves the trigger enabled but does not open the popover — ${component.name}`, async () => { + await render(component) + + const trigger = page.getByRole("button", { + name: /(?:choose|change) date/i, + }) + await expect.element(trigger).not.toBeDisabled() + + await trigger.click() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + }, + }, +] + +describe("DatePicker - States", () => { + runTests(tests) + + test("readOnly inline calendar stays navigable but ignores selection", async () => { + const valueChanged = vi.fn() + await render( + simple( + `readOnly variant="inline" (valueChanged)="valueChangedHandler.emit($event)"`, + ), + {on: {valueChangedHandler: (event: unknown) => valueChanged(event)}}, + ) + + await page.getByRole("gridcell", {name: /June 15, 2024/}).click() + await userEvent.keyboard("{ArrowRight}") + await expect.element(page.getByLabelText(/June 16, 2024/)).toHaveFocus() + + await userEvent.keyboard("{Enter}") + expect(valueChanged).not.toHaveBeenCalled() + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-validation.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-validation.spec.ts new file mode 100644 index 000000000..da2a8d058 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-validation.spec.ts @@ -0,0 +1,230 @@ +import {Component} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test} from "vitest" +import {page} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {DatePickerModule} from "@qualcomm-ui/angular/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +const hint = "Arrival must be after this date" +const errorText = "Pick a date in the future" + +function compositeTemplate(rootAttrs: string) { + return ` +
+
+
+
+
${hint}
+
${errorText}
+ +
+
+
+ + + +
+
+
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs: string) { + return ` + + ` +} + +function invalidMultipleTemplate() { + return ` + + ` +} + +function composite(rootAttrs = "") { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent {} + return CompositeComponent +} + +function simple(rootAttrs = "") { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent {} + return SimpleComponent +} + +const input = () => page.getByRole("textbox") + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`a valid picker shows the hint and hides the error text — ${component.name}`, async () => { + await render(component) + + await expect.element(page.getByText(hint)).toBeVisible() + await expect.element(page.getByText(errorText)).not.toBeVisible() + await expect + .element(input()) + .not.toHaveAttribute("aria-invalid", "true") + }) + }, + }, + { + composite: () => composite("invalid"), + simple: () => simple("invalid"), + testCase: (component) => { + test(`an invalid picker swaps the hint for the error text — ${component.name}`, async () => { + await render(component) + + await expect.element(page.getByText(errorText)).toBeVisible() + await expect.element(page.getByText(hint)).not.toBeVisible() + }) + }, + }, + { + composite: () => composite("invalid"), + simple: () => simple("invalid"), + testCase: (component) => { + test(`an invalid picker marks the input and points it at the error text — ${component.name}`, async () => { + await render(component) + + await expect.element(input()).toHaveAttribute("aria-invalid", "true") + + const describedBy = input().element().getAttribute("aria-describedby") + const errorId = page.getByText(errorText).element().id + expect(describedBy?.split(" ")).toContain(errorId) + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`the hint is associated with the input — ${component.name}`, async () => { + await render(component) + + const describedBy = input().element().getAttribute("aria-describedby") + const hintId = page.getByText(hint).element().id + expect(describedBy?.split(" ")).toContain(hintId) + }) + }, + }, + { + composite: () => composite("invalid"), + simple: () => simple("invalid"), + testCase: (component) => { + test(`the error indicator only appears while invalid — ${component.name}`, async () => { + await render(component) + + await expect.element(page.getByLabelText("Error")).toBeVisible() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`the error indicator is absent while valid — ${component.name}`, async () => { + await render(component) + + await expect.element(page.getByLabelText("Error")).not.toBeVisible() + }) + }, + }, +] + +@Component({ + imports: [DatePickerModule], + template: ` + + `, +}) +class LabelledComponent {} + +@Component({ + imports: [DatePickerModule], + template: ` + + `, +}) +class RangeComponent {} + +@Component({ + imports: [DatePickerModule], + template: ` + + `, +}) +class InvalidRangeComponent {} + +@Component({ + imports: [DatePickerModule], + template: invalidMultipleTemplate(), +}) +class InvalidMultipleComponent {} + +describe("DatePicker - Validation", () => { + runTests(tests) + + test("the label is wired to the single input", async () => { + await render(LabelledComponent) + + await expect + .element(page.getByLabelText("Departure date")) + .toHaveAttribute("data-date-picker-part", "input") + }) + + test("a range picker groups its two inputs under the shared label", async () => { + await render(RangeComponent) + + const group = page.getByRole("group", {name: "Trip"}) + await expect.element(group).toBeVisible() + expect(group.element().querySelectorAll("input")).toHaveLength(2) + }) + + test("an invalid range picker marks both of its inputs", async () => { + await render(InvalidRangeComponent) + + await expect + .element(page.getByRole("textbox", {name: /start date/i})) + .toHaveAttribute("aria-invalid", "true") + await expect + .element(page.getByRole("textbox", {name: /end date/i})) + .toHaveAttribute("aria-invalid", "true") + }) + + test("multiple mode carries the invalid state on the field, since it has no input to mark", async () => { + await render(InvalidMultipleComponent) + + const field = page.getByRole("combobox", {name: "Dates"}) + await expect.element(field).toHaveAttribute("aria-invalid", "true") + + const describedBy = field.element().getAttribute("aria-describedby") + const errorId = page.getByText(errorText).element().id + expect(describedBy?.split(" ")).toContain(errorId) + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/__tests__/date-picker-views.spec.ts b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-views.spec.ts new file mode 100644 index 000000000..3eba00499 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/__tests__/date-picker-views.spec.ts @@ -0,0 +1,592 @@ +import {Component, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {describe, expect, test, vi} from "vitest" +import {page, userEvent} from "vitest/browser" + +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {DatePickerModule, parseDate} from "@qualcomm-ui/angular/date-picker" +import type {DatePickerViewChangeDetails} from "@qualcomm-ui/core/date-picker" + +import {type MultiComponentTest, runTests} from "~test-utils" + +const seeded = parseDate("2024-06-15") + +function subView(view: "month" | "year") { + return ` +
+
+ + + +
+ + +
+
+ ` +} + +function compositeTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ + + + +
+ + + +
+
+ ${subView("month")} + ${subView("year")} +
+
+
+
+ ` +} + +function simpleTemplate(rootAttrs = "") { + return ` + + ` +} + +/** + * A trigger with no `view` cycles instead of jumping; that is what `maxView` + * clamps. + */ +function cyclingTemplate(rootAttrs = "") { + return ` +
+
+
+
+ +
+
+
+
+ +
+ + + +
+
+
+
+ +
+ + +
+
+
+
+ +
+ + +
+
+
+
+
+
+ ` +} + +function minMonthViewTriggersTemplate() { + return ` +
+
+
+
+ + +
+ + +
+
+
+
+ ` +} + +function minMonthViewCloseTemplate() { + return ` +
+
+
+
+ + +
+ + +
+
+ ${subView("year")} +
+
+ ` +} + +function maxMonthWithApiTemplate() { + return ` +
+ + + +
+
+ + + +
+
+ ${subView("month")} + ${subView("year")} +
+
+ ` +} + +function composite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: compositeTemplate(rootAttrs), + }) + class CompositeComponent { + readonly valueChangedHandler = output() + readonly viewChangedHandler = output() + protected readonly seeded = seeded + } + return CompositeComponent +} + +function simple(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule], + template: simpleTemplate(rootAttrs), + }) + class SimpleComponent { + readonly valueChangedHandler = output() + readonly viewChangedHandler = output() + protected readonly seeded = seeded + } + return SimpleComponent +} + +function cyclingComposite(rootAttrs?: string) { + @Component({ + imports: [DatePickerModule, PortalDirective], + template: cyclingTemplate(rootAttrs), + }) + class CyclingComponent { + protected readonly seeded = seeded + } + return CyclingComponent +} + +function minMonthViewTriggers() { + @Component({ + imports: [DatePickerModule], + template: minMonthViewTriggersTemplate(), + }) + class MinMonthViewTriggersComponent { + protected readonly seeded = seeded + } + return MinMonthViewTriggersComponent +} + +function minMonthViewClose() { + @Component({ + imports: [DatePickerModule], + template: minMonthViewCloseTemplate(), + }) + class MinMonthViewCloseComponent { + protected readonly seeded = seeded + } + return MinMonthViewCloseComponent +} + +function maxMonthWithApi() { + @Component({ + imports: [DatePickerModule], + template: maxMonthWithApiTemplate(), + }) + class MaxMonthWithApiComponent { + protected readonly seeded = seeded + } + return MaxMonthWithApiComponent +} + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +const grid = () => page.getByRole("grid") + +const dayLevel = "calendar month" +const monthLevel = "calendar year" +const yearLevel = "calendar decade" + +const tests: MultiComponentTest[] = [ + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`the month view trigger switches from the day grid to the month grid — ${component.name}`, async () => { + await render(component) + await openCalendar() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + + await page.getByRole("button", {name: /switch to month view/i}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + await expect + .element(page.getByRole("gridcell", {name: "July"})) + .toBeVisible() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`the year view trigger switches from the day grid to the year grid — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", yearLevel) + await expect + .element(page.getByRole("gridcell", {name: "2020"})) + .toBeVisible() + await expect + .element(page.getByRole("gridcell", {name: "2029"})) + .toBeVisible() + }) + }, + }, + { + composite: () => composite(`(valueChanged)="valueChangedHandler.emit()"`), + simple: () => simple(`(valueChanged)="valueChangedHandler.emit()"`), + testCase: (component) => { + test(`picking a month returns to the day grid on that month — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: { + valueChangedHandler: () => { + valueChanged() + }, + }, + }) + await openCalendar() + + await page.getByRole("button", {name: /switch to month view/i}).click() + await page.getByRole("gridcell", {name: "September"}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + await expect + .element(page.getByRole("gridcell", {name: /September 15, 2024/})) + .toBeVisible() + expect(valueChanged).not.toHaveBeenCalled() + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`viewOnSelect defaults to jumping straight from the year grid to the day grid — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + await page.getByRole("gridcell", {name: "2026"}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2026/})) + .toBeVisible() + }) + }, + }, + { + composite: () => composite(`viewOnSelect="previous"`), + simple: () => simple(`viewOnSelect="previous"`), + testCase: (component) => { + test(`viewOnSelect='previous' steps the year grid back one level to the month grid — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + await page.getByRole("gridcell", {name: "2026"}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + }) + }, + }, + { + composite: () => composite(), + simple: () => simple(), + testCase: (component) => { + test(`the view close trigger abandons the month grid and returns to the day grid — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await page.getByRole("button", {name: /switch to month view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + + await page.getByRole("button", {name: /return to calendar/i}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .toBeVisible() + }) + }, + }, + { + composite: () => composite(`(valueChanged)="valueChangedHandler.emit()"`), + simple: () => simple(`(valueChanged)="valueChangedHandler.emit()"`), + testCase: (component) => { + test(`Enter drills down from the year grid without committing a value — ${component.name}`, async () => { + const valueChanged = vi.fn() + await render(component, { + on: { + valueChangedHandler: () => { + valueChanged() + }, + }, + }) + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + // Enter is handled by the focused year cell, so roving focus has to + // land there before the key is pressed. + await expect + .element(page.getByRole("button", {exact: true, name: "2024"})) + .toHaveFocus() + await userEvent.keyboard("{Enter}") + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + expect(valueChanged).not.toHaveBeenCalled() + }) + }, + }, + { + composite: () => composite(`defaultView="year"`), + simple: () => simple(`defaultView="year"`), + testCase: (component) => { + test(`defaultView opens at the requested level — ${component.name}`, async () => { + await render(component) + await openCalendar() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", yearLevel) + }) + }, + }, +] + +describe("DatePicker - Views", () => { + runTests(tests) + + test("a bare view trigger cycles day to month to year", async () => { + await render(cyclingComposite()) + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + + await page.getByRole("button", {name: /switch to day view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", yearLevel) + }) + + test("maxView='month' stops the cycling view trigger at the month grid", async () => { + await render(cyclingComposite(`maxView="month"`)) + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + + await page.getByRole("button", {name: /switch to day view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + }) + + test("minView='month' makes a month click the selection, normalized to the first of the month", async () => { + await render(cyclingComposite(`minView="month"`)) + await openCalendar() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + + await page.getByRole("gridcell", {name: "September"}).click() + + await expect.element(page.getByRole("textbox")).toHaveValue("09/01/2024") + await expect.element(grid()).not.toBeInTheDocument() + }) + + test("maxView='month' disables a view trigger that targets the year grid", async () => { + await render(composite(`maxView="month"`)) + await openCalendar() + + await expect + .element(page.getByRole("button", {name: /switch to year view/i})) + .toBeDisabled() + await expect + .element(page.getByRole("button", {name: /switch to month view/i})) + .not.toBeDisabled() + }) + + test("minView='month' disables a view trigger that targets the day grid", async () => { + await render(minMonthViewTriggers()) + + await expect + .element(page.getByRole("button", {name: /switch to day view/i})) + .toBeDisabled() + await expect + .element(page.getByRole("button", {name: /switch to year view/i})) + .not.toBeDisabled() + }) + + test("the view close trigger returns to minView rather than to the day grid", async () => { + await render(minMonthViewClose()) + + await page.getByRole("button", {name: /switch to year view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", yearLevel) + + await page.getByRole("button", {name: /return to calendar/i}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + }) + + test("setView clamps a request that falls outside minView and maxView", async () => { + await render(maxMonthWithApi()) + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + + await page.getByRole("button", {name: "Go year"}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + }) + + test("reopening discards uncommitted navigation, in both view and visible month", async () => { + await render(simple()) + + await openCalendar() + await page.getByRole("button", {name: /switch to month view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + + await page.getByRole("button", {name: /close calendar/i}).click() + await openCalendar() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .toBeVisible() + }) + + test("reopening returns to the month of the committed value, not the last browsed month", async () => { + await render(simple(`[defaultValue]="[seeded]"`)) + + const monthHeading = () => + page.getByRole("button", {name: /switch to month view/i}) + + await openCalendar() + await page.getByRole("button", {name: /switch to next month/i}).click() + await expect.element(monthHeading()).toHaveTextContent("July") + + await page.getByRole("button", {name: /close calendar/i}).click() + await openCalendar() + + await expect.element(monthHeading()).toHaveTextContent("June") + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .toBeVisible() + }) + + test("viewChanged reports each level change", async () => { + const viewChanged = vi.fn() + await render(simple(`(viewChanged)="viewChangedHandler.emit($event)"`), { + on: { + viewChangedHandler: (details) => { + viewChanged(details) + }, + }, + }) + await openCalendar() + + await page.getByRole("button", {name: /switch to month view/i}).click() + await expect.poll(() => viewChanged).toHaveBeenCalledWith({view: "month"}) + + await page.getByRole("button", {name: /return to calendar/i}).click() + await expect.poll(() => viewChanged).toHaveBeenCalledWith({view: "day"}) + }) +}) diff --git a/packages/frameworks/angular/src/date-picker/date-picker-actions.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-actions.directive.ts new file mode 100644 index 000000000..01bbfe5cd --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-actions.directive.ts @@ -0,0 +1,28 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Footer container for the confirm and cancel actions. Intended for use when + * `closeOnSelect` is `false`. + */ +@Directive({ + selector: "[q-date-picker-actions]", + standalone: false, +}) +export class DatePickerActionsDirective implements OnInit { + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly trackBindings = useTrackBindings(() => + this.qdsContext().getActionsBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-cancel-trigger.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-cancel-trigger.component.ts new file mode 100644 index 000000000..077ecf1e6 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-cancel-trigger.component.ts @@ -0,0 +1,41 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, type OnInit} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import {useButtonApi} from "@qualcomm-ui/angular/button" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +/** + * Discards selection, restores previous value, and closes the calendar. Styled + * as a ghost button with the label "Cancel" by default. + */ +@Component({ + selector: "[q-date-picker-cancel-trigger]", + standalone: false, + template: ` + Cancel + `, +}) +export class DatePickerCancelTriggerDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + + protected readonly buttonApi = useButtonApi({ + density: "compact", + emphasis: "primary", + variant: "ghost", + }) + + protected readonly trackBindings = useTrackBindings(() => + mergeProps( + {onclick: () => this.datePickerContext().cancel(), type: "button"}, + this.buttonApi().getRootBindings(), + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-clear-trigger.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-clear-trigger.directive.ts new file mode 100644 index 000000000..f317aac8c --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-clear-trigger.directive.ts @@ -0,0 +1,15 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive} from "@angular/core" + +import {CoreDatePickerClearTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" + +/** + * A button that clears the selection. + */ +@Directive({ + selector: "[q-date-picker-clear-trigger]", + standalone: false, +}) +export class DatePickerClearTriggerDirective extends CoreDatePickerClearTriggerDirective {} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-content.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-content.directive.ts new file mode 100644 index 000000000..f52fa2d2c --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-content.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerContentDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Container for the calendar. + */ +@Directive({ + selector: "[q-date-picker-content]", + standalone: false, +}) +export class DatePickerContentDirective extends CoreDatePickerContentDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getContentBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-context.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-context.directive.ts new file mode 100644 index 000000000..49d60174c --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-context.directive.ts @@ -0,0 +1,30 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, inject} from "@angular/core" + +import {DatePickerContextService} from "@qualcomm-ui/angular-core/date-picker" +import {ApiContextDirective} from "@qualcomm-ui/angular-core/machine" +import type {DatePickerApi} from "@qualcomm-ui/core/date-picker" + +@Directive({ + selector: "[datePickerContext]", + standalone: false, +}) +export class DatePickerContextDirective extends ApiContextDirective { + constructor() { + const contextService = inject(DatePickerContextService) + super(contextService, "datePickerContext") + } + + /** + * Type guard for the context, provides intellisense for the context in the + * template. + */ + static ngTemplateContextGuard( + _dir: DatePickerContextDirective, + _ctx: unknown, + ): _ctx is {$implicit: DatePickerApi} { + return true + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-control.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-control.directive.ts new file mode 100644 index 000000000..7e8abb523 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-control.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerControlDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Container for the input and triggers. + */ +@Directive({ + selector: "[q-date-picker-control]", + standalone: false, +}) +export class DatePickerControlDirective extends CoreDatePickerControlDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getControlBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-day-grid-header.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-day-grid-header.component.ts new file mode 100644 index 000000000..360740011 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-day-grid-header.component.ts @@ -0,0 +1,47 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, input} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" +import type {DatePickerWeekDay} from "@qualcomm-ui/core/date-picker" + +import {DatePickerTableHeadDirective} from "./date-picker-table-head.directive" + +export type DatePickerWeekDayFormat = Extract< + keyof DatePickerWeekDay, + "long" | "narrow" | "short" +> + +/** + * Renders the weekday column headers for the day view. Apply to a `thead` + * element. + */ +@Component({ + selector: "[q-date-picker-day-grid-header]", + standalone: false, + template: ` + + @for (weekDay of weekDays(); track $index) { + + {{ weekDay[format()] }} + + } + + `, +}) +export class DatePickerDayGridHeaderDirective extends DatePickerTableHeadDirective { + /** + * The format used to render the weekday labels. + * + * @default 'narrow' + */ + readonly format = input("narrow") + + protected readonly api = useDatePickerContext() + protected readonly weekDays = computed(() => this.api().weekDays) +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-day-grid.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-day-grid.component.ts new file mode 100644 index 000000000..4cf1a912f --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-day-grid.component.ts @@ -0,0 +1,37 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" + +import {DatePickerTableBodyDirective} from "./date-picker-table-body.directive" + +/** + * Renders the day cells for the visible month. Apply to a `tbody` element. + */ +@Component({ + selector: "[q-date-picker-day-grid]", + standalone: false, + template: ` + @for (week of weeks(); track $index) { + + @for (day of week; track day.toString()) { + +
{{ day.day }}
+ + } + + } + `, +}) +export class DatePickerDayGridDirective extends DatePickerTableBodyDirective { + protected readonly api = useDatePickerContext() + + protected readonly visibleRange = computed(() => this.api().visibleRange) + protected readonly weeks = computed(() => this.api().weeks) +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-error-indicator.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-error-indicator.component.ts new file mode 100644 index 000000000..7aa8f9ba1 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-error-indicator.component.ts @@ -0,0 +1,41 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, input} from "@angular/core" +import {LucideCircleAlert} from "@lucide/angular" + +import {CoreDatePickerErrorIndicatorDirective} from "@qualcomm-ui/angular-core/date-picker" +import type {LucideIconOrString} from "@qualcomm-ui/angular-core/lucide" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Visual indicator displayed inside the control when the date picker is + * invalid. + */ +@Component({ + selector: "[q-date-picker-error-indicator]", + standalone: false, + template: ` + + + + `, +}) +export class DatePickerErrorIndicatorDirective extends CoreDatePickerErrorIndicatorDirective { + /** + * lucide icon + * + * @default LucideCircleAlert + */ + readonly icon = input(LucideCircleAlert) + + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getErrorIndicatorBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-error-text.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-error-text.directive.ts new file mode 100644 index 000000000..96788a24f --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-error-text.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerErrorTextDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Error message displayed when the date picker is invalid. + */ +@Directive({ + selector: "[q-date-picker-error-text]", + standalone: false, +}) +export class DatePickerErrorTextDirective extends CoreDatePickerErrorTextDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getErrorTextBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-headline-label.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-headline-label.component.ts new file mode 100644 index 000000000..9644a3db6 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-headline-label.component.ts @@ -0,0 +1,36 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, type OnInit} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Caption above the headline value. Project content to override the default. + */ +@Component({ + selector: "[q-date-picker-headline-label]", + standalone: false, + template: ` + {{ fallback() }} + `, +}) +export class DatePickerHeadlineLabelDirective implements OnInit { + protected readonly api = useDatePickerContext() + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly fallback = computed(() => + this.api().selectionMode === "range" ? "Date range" : "Date", + ) + + protected readonly trackBindings = useTrackBindings(() => + this.qdsContext().getHeadlineLabelBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-headline-value.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-headline-value.component.ts new file mode 100644 index 000000000..3d6095d44 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-headline-value.component.ts @@ -0,0 +1,72 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, input, type OnInit} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import {getDatePickerHeadlineValueText} from "@qualcomm-ui/qds-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Renders the current selection as human readable text. + */ +@Component({ + selector: "[q-date-picker-headline-value]", + standalone: false, + template: ` + {{ text() }} + `, +}) +export class DatePickerHeadlineValueDirective implements OnInit { + /** + * Format used to render the selected date(s). + */ + readonly format = input() + + /** + * Suffix appended in `multiple` mode when more than two dates are selected. + * + * @default (count) => `+${count} more` + */ + readonly moreLabel = input<((count: number) => string) | undefined>() + + /** + * Text shown when no date is selected. + * + * @default 'Select date' + */ + readonly placeholder = input() + + /** + * Placeholders for the start and end of an incomplete range. + * + * @default ['Start', 'End'] + */ + readonly rangePlaceholder = input<[string, string] | undefined>() + + protected readonly api = useDatePickerContext() + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly text = computed(() => { + const api = this.api() + return getDatePickerHeadlineValueText({ + format: api.format, + formatOptions: this.format(), + moreLabel: this.moreLabel(), + placeholder: this.placeholder(), + rangePlaceholder: this.rangePlaceholder(), + selectionMode: api.selectionMode, + value: api.value, + }) + }) + + protected readonly trackBindings = useTrackBindings(() => + this.qdsContext().getHeadlineValueBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-headline.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-headline.directive.ts new file mode 100644 index 000000000..25135242e --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-headline.directive.ts @@ -0,0 +1,27 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Container for the calendar headline. + */ +@Directive({ + selector: "[q-date-picker-headline]", + standalone: false, +}) +export class DatePickerHeadlineDirective implements OnInit { + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly trackBindings = useTrackBindings(() => + this.qdsContext().getHeadlineBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-hint.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-hint.directive.ts new file mode 100644 index 000000000..87ed90220 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-hint.directive.ts @@ -0,0 +1,27 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerHintDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Helper text displayed below the field. Hidden while the date picker is + * invalid. + */ +@Directive({ + selector: "[q-date-picker-hint]", + standalone: false, +}) +export class DatePickerHintDirective extends CoreDatePickerHintDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getHintBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-input-clear-trigger.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-input-clear-trigger.component.ts new file mode 100644 index 000000000..b59168e7d --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-input-clear-trigger.component.ts @@ -0,0 +1,49 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" +import {LucideX} from "@lucide/angular" + +import {CoreDatePickerClearTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" +import {provideIcons} from "@qualcomm-ui/angular-core/lucide" +import {useIconButtonApi} from "@qualcomm-ui/angular/button" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Clears the selection, styled as a compact icon button, used inside the input + * group. + */ +@Component({ + providers: [provideIcons({LucideX})], + selector: "[q-date-picker-input-clear-trigger]", + standalone: false, + template: ` + + + + `, +}) +export class DatePickerInputClearTriggerDirective extends CoreDatePickerClearTriggerDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly iconButtonApi = useIconButtonApi({ + density: "compact", + disabled: computed(() => this.datePickerContext().disabled), + size: computed(() => this.qdsContext().triggerSize), + variant: "ghost", + }) + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => + mergeProps( + this.qdsContext().getClearTriggerBindings(), + this.iconButtonApi().getRootBindings(), + ), + ), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-input-group-trigger.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-input-group-trigger.directive.ts new file mode 100644 index 000000000..5b4cff4da --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-input-group-trigger.directive.ts @@ -0,0 +1,62 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Applies every binding on the bordered field, and in `multiple` mode turns it + * into the calendar trigger since no text input owns focus there. + * + * Do not add a second binding system to this element: `role`, `dir`, and + * `aria-invalid` change source with the mode, so two writers clobber each other + * on a runtime mode switch. Must stay in `useTrackBindings` — id registration + * writes a signal, which a `computed` forbids (NG0600). + */ +@Directive({ + selector: "div[q-date-picker-input-group-trigger]", + standalone: false, +}) +export class DatePickerInputGroupTriggerDirective implements OnInit { + /** + * Id of the label describing the field as a group. Omitted when the label + * describes a single input directly. + */ + readonly groupLabelId = input() + + protected readonly api = useDatePickerContext() + protected readonly qdsContext = useQdsDatePickerContext() + protected readonly hostId = computed(() => useId(this, undefined)) + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => { + const api = this.api() + const groupLabelId = this.groupLabelId() + const isMultiple = api.selectionMode === "multiple" + + return mergeProps( + api.getInputGroupBindings(), + isMultiple + ? api.getInputGroupTriggerBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }) + : {}, + this.qdsContext().getInputGroupBindings(), + { + "aria-labelledby": groupLabelId, + ...(groupLabelId && !isMultiple ? {role: "group"} : {}), + }, + ) + }) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-input-group.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-input-group.component.ts new file mode 100644 index 000000000..944a70939 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-input-group.component.ts @@ -0,0 +1,129 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + booleanAttribute, + Component, + computed, + input, + type OnInit, +} from "@angular/core" +import {LucideCalendar} from "@lucide/angular" + +import {useId} from "@qualcomm-ui/angular-core/common" +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" +import {provideIcons} from "@qualcomm-ui/angular-core/lucide" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Groups the label, input, clear, and calendar trigger. For a range picker the + * start and end inputs are grouped into a single bordered field separated by + * {@link separator}. + */ +@Component({ + providers: [provideIcons({LucideCalendar})], + selector: "div[q-date-picker-input-group]", + standalone: false, + template: ` + @if (label()) { + + } +
+ @if (isMultiple()) { + + } @else { + + @if (isRange()) { + + {{ separator() }} + + + } + } + + + + @if (isMultiple()) { + + } @else { + + } +
+ `, +}) +export class DatePickerInputGroupDirective implements OnInit { + /** + * Returns the accessible label for a tag's remove button. Only applicable in + * `multiple` mode. + * + * @default (dateText) => `Remove ${dateText}` + */ + readonly dismissLabel = input<(dateText: string) => string>( + (dateText) => `Remove ${dateText}`, + ) + + /** + * Whether to fix the input value on blur. + * + * @default true + */ + readonly fixOnBlur = input(true, { + transform: booleanAttribute, + }) + + /** + * Label text rendered above the field. + */ + readonly label = input() + + /** + * Text shown when no date is selected. Only applicable in `multiple` mode. + * + * @default 'Select dates' + */ + readonly placeholder = input("Select dates") + + /** + * Character shown between the start and end inputs of a range picker. + * + * @default '-' + */ + readonly separator = input("-") + + protected readonly labelId = useId(this, undefined) + + protected readonly api = useDatePickerContext() + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly isMultiple = computed( + () => this.api().selectionMode === "multiple", + ) + protected readonly isRange = computed( + () => this.api().selectionMode === "range", + ) + + protected readonly groupLabelId = computed(() => { + const hasGroupLabel = + (this.isRange() || this.isMultiple()) && !!this.label() + return hasGroupLabel ? this.labelId : undefined + }) + + protected readonly trackBindings = useTrackBindings(() => + this.qdsContext().getControlGroupBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-input-trigger.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-input-trigger.component.ts new file mode 100644 index 000000000..223530884 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-input-trigger.component.ts @@ -0,0 +1,51 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" +import {LucideCalendar} from "@lucide/angular" + +import {CoreDatePickerTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" +import {provideIcons} from "@qualcomm-ui/angular-core/lucide" +import {useIconButtonApi} from "@qualcomm-ui/angular/button" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Calendar toggle styled as a compact icon button, used inside the input group. + */ +@Component({ + providers: [provideIcons({LucideCalendar})], + selector: "[q-date-picker-input-trigger]", + standalone: false, + template: ` + + + + `, +}) +export class DatePickerInputTriggerDirective extends CoreDatePickerTriggerDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly iconButtonApi = useIconButtonApi({ + density: "compact", + disabled: computed(() => this.datePickerContext().disabled), + size: computed(() => this.qdsContext().triggerSize), + variant: "ghost", + }) + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => + mergeProps( + this.qdsContext().getTriggerBindings(), + this.iconButtonApi().getRootBindings(), + ), + ), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-input.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-input.directive.ts new file mode 100644 index 000000000..25e477a3f --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-input.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerInputDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * The editable date input. + */ +@Directive({ + selector: "[q-date-picker-input]", + standalone: false, +}) +export class DatePickerInputDirective extends CoreDatePickerInputDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getInputBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-label.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-label.directive.ts new file mode 100644 index 000000000..c3ec88b71 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-label.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerLabelDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Label for the date input. + */ +@Directive({ + selector: "[q-date-picker-label]", + standalone: false, +}) +export class DatePickerLabelDirective extends CoreDatePickerLabelDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getLabelBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-month-grid.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-month-grid.component.ts new file mode 100644 index 000000000..483ca2b51 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-month-grid.component.ts @@ -0,0 +1,47 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, input} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" +import type {DatePickerApiMonthGridProps} from "@qualcomm-ui/core/date-picker" +import {datePickerViewColumns} from "@qualcomm-ui/qds-core/date-picker" + +import {DatePickerTableBodyDirective} from "./date-picker-table-body.directive" + +/** + * Renders the month cells for the month view. Apply to a `tbody` element. + */ +@Component({ + selector: "[q-date-picker-month-grid]", + standalone: false, + template: ` + @for (months of monthsGrid(); track $index) { + + @for (month of months; track month.value) { + +
{{ month.label }}
+ + } + + } + `, +}) +export class DatePickerMonthGridDirective extends DatePickerTableBodyDirective { + /** + * The format used to render the month labels. + * + * @default 'short' + */ + readonly format = + input>("short") + + protected readonly api = useDatePickerContext() + + protected readonly monthsGrid = computed(() => + this.api().getMonthsGrid({ + columns: datePickerViewColumns.month, + format: this.format(), + }), + ) +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-month-text.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-month-text.component.ts new file mode 100644 index 000000000..44b5891c3 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-month-text.component.ts @@ -0,0 +1,47 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, input} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" + +export type DatePickerMonthTextFormat = + | "2-digit" + | "long" + | "narrow" + | "numeric" + | "short" + +/** + * Renders the name of the currently visible month. Intended as the label for a + * view trigger that jumps to the month view. + */ +@Component({ + selector: "q-date-picker-month-text", + standalone: false, + styles: [ + ` + :host { + display: contents; + } + `, + ], + template: ` + {{ text() }} + `, +}) +export class DatePickerMonthTextDirective { + /** + * The format used to render the visible month. + * + * @default 'long' + */ + readonly format = input("long") + + protected readonly api = useDatePickerContext() + + protected readonly text = computed(() => { + const api = this.api() + return api.format(api.visibleRange.start, {month: this.format()}) + }) +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-next-trigger.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-next-trigger.component.ts new file mode 100644 index 000000000..b1780f68b --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-next-trigger.component.ts @@ -0,0 +1,51 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" +import {LucideChevronRight} from "@lucide/angular" + +import {CoreDatePickerNextTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" +import {provideIcons} from "@qualcomm-ui/angular-core/lucide" +import {useIconButtonApi} from "@qualcomm-ui/angular/button" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Advances the calendar to the next page. Styled as a ghost icon button. + */ +@Component({ + providers: [provideIcons({LucideChevronRight})], + selector: "[q-date-picker-next-trigger]", + standalone: false, + template: ` + + + + `, +}) +export class DatePickerNextTriggerDirective extends CoreDatePickerNextTriggerDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly iconButtonApi = useIconButtonApi({ + density: "compact", + disabled: computed(() => !!this.coreBindings().disabled), + size: "md", + variant: "ghost", + }) + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => + mergeProps( + this.qdsContext().getNextTriggerBindings(), + this.iconButtonApi().getRootBindings(), + ), + ), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-ok-trigger.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-ok-trigger.component.ts new file mode 100644 index 000000000..eced4fed2 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-ok-trigger.component.ts @@ -0,0 +1,41 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, type OnInit} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import {useButtonApi} from "@qualcomm-ui/angular/button" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +/** + * Confirms selection and closes the calendar. Styled as a ghost button with the + * label "OK" by default. + */ +@Component({ + selector: "[q-date-picker-ok-trigger]", + standalone: false, + template: ` + OK + `, +}) +export class DatePickerOkTriggerDirective implements OnInit { + protected readonly datePickerContext = useDatePickerContext() + + protected readonly buttonApi = useButtonApi({ + density: "compact", + emphasis: "primary", + variant: "ghost", + }) + + protected readonly trackBindings = useTrackBindings(() => + mergeProps( + {onclick: () => this.datePickerContext().setOpen(false), type: "button"}, + this.buttonApi().getRootBindings(), + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-positioner.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-positioner.directive.ts new file mode 100644 index 000000000..e3ca57990 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-positioner.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerPositionerDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Positions the calendar relative to the control. + */ +@Directive({ + selector: "[q-date-picker-positioner]", + standalone: false, +}) +export class DatePickerPositionerDirective extends CoreDatePickerPositionerDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getPositionerBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-preset-trigger.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-preset-trigger.directive.ts new file mode 100644 index 000000000..da5f38ee2 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-preset-trigger.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerPresetTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Selects a preset date or range. + */ +@Directive({ + selector: "[q-date-picker-preset-trigger]", + standalone: false, +}) +export class DatePickerPresetTriggerDirective extends CoreDatePickerPresetTriggerDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getPresetTriggerBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-presets-trigger.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-presets-trigger.component.ts new file mode 100644 index 000000000..d44711922 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-presets-trigger.component.ts @@ -0,0 +1,62 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" +import {LucideEllipsisVertical, LucideX} from "@lucide/angular" + +import {CoreDatePickerPresetsTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" +import {provideIcons} from "@qualcomm-ui/angular-core/lucide" +import {useIconButtonApi} from "@qualcomm-ui/angular/button" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Toggles the presets panel. Styled as an outline icon button that becomes a + * close affordance while the panel is open. + */ +@Component({ + providers: [provideIcons({LucideEllipsisVertical, LucideX})], + selector: "[q-date-picker-presets-trigger]", + standalone: false, + template: ` + + @if (presetsOpen()) { + + } @else { + + } + + `, +}) +export class DatePickerPresetsTriggerDirective extends CoreDatePickerPresetsTriggerDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly presetsOpen = computed( + () => this.datePickerContext().presetsOpen, + ) + + protected readonly iconButtonApi = useIconButtonApi({ + density: "compact", + disabled: computed(() => !!this.coreBindings().disabled), + emphasis: computed(() => (this.presetsOpen() ? "neutral" : "primary")), + shape: computed(() => (this.presetsOpen() ? "square" : "rounded")), + size: "sm", + variant: "outline", + }) + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => + mergeProps( + this.qdsContext().getPresetsTriggerBindings(), + this.iconButtonApi().getRootBindings(), + ), + ), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-presets.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-presets.directive.ts new file mode 100644 index 000000000..e8f8f3eb3 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-presets.directive.ts @@ -0,0 +1,28 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerPresetsDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Panel that lists {@link DatePickerPresetTriggerDirective} options in place of + * the calendar while the {@link DatePickerPresetsTriggerDirective} is toggled + * on. + */ +@Directive({ + selector: "[q-date-picker-presets]", + standalone: false, +}) +export class DatePickerPresetsDirective extends CoreDatePickerPresetsDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getPresetsBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-prev-trigger.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-prev-trigger.component.ts new file mode 100644 index 000000000..811633c10 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-prev-trigger.component.ts @@ -0,0 +1,51 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" +import {LucideChevronLeft} from "@lucide/angular" + +import {CoreDatePickerPrevTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" +import {provideIcons} from "@qualcomm-ui/angular-core/lucide" +import {useIconButtonApi} from "@qualcomm-ui/angular/button" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Moves the calendar to the previous page. Styled as a ghost icon button. + */ +@Component({ + providers: [provideIcons({LucideChevronLeft})], + selector: "[q-date-picker-prev-trigger]", + standalone: false, + template: ` + + + + `, +}) +export class DatePickerPrevTriggerDirective extends CoreDatePickerPrevTriggerDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly iconButtonApi = useIconButtonApi({ + density: "compact", + disabled: computed(() => !!this.coreBindings().disabled), + size: "md", + variant: "ghost", + }) + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => + mergeProps( + this.qdsContext().getPrevTriggerBindings(), + this.iconButtonApi().getRootBindings(), + ), + ), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-range-text.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-range-text.component.ts new file mode 100644 index 000000000..0567b69ad --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-range-text.component.ts @@ -0,0 +1,31 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" + +import {CoreDatePickerRangeTextDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Human readable text for the visible range. The text is view-aware (month and + * year for the day view, the year for the month view, and the decade for the + * year view). + */ +@Component({ + selector: "[q-date-picker-range-text]", + standalone: false, + template: ` + {{ datePickerContext().visibleRangeText.formatted }} + `, +}) +export class DatePickerRangeTextDirective extends CoreDatePickerRangeTextDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getRangeTextBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-root.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-root.directive.ts new file mode 100644 index 000000000..c2d9dcd8b --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-root.directive.ts @@ -0,0 +1,92 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + booleanAttribute, + computed, + Directive, + inject, + input, +} from "@angular/core" + +import { + CoreDatePickerRootDirective, + provideDatePickerContext, + provideDatePickerViewContext, +} from "@qualcomm-ui/angular-core/date-picker" +import {INPUT_FORM_CONTROL_CONTEXT} from "@qualcomm-ui/angular-core/input" +import {normalizeProps} from "@qualcomm-ui/angular-core/machine" +import {providePresenceContext} from "@qualcomm-ui/angular-core/presence" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import { + createQdsDatePickerApi, + type QdsDatePickerApiProps, +} from "@qualcomm-ui/qds-core/date-picker" +import type {QdsInputSize} from "@qualcomm-ui/qds-core/input" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" +import type {Explicit} from "@qualcomm-ui/utils/guard" + +import { + provideQdsDatePickerContext, + QdsDatePickerContextService, +} from "./qds-date-picker-context.service" + +@Directive({ + providers: [ + { + provide: INPUT_FORM_CONTROL_CONTEXT, + useFactory: () => + inject(DatePickerRootDirective, {self: true}).formControlContext, + }, + provideDatePickerContext(), + provideDatePickerViewContext(), + provideQdsDatePickerContext(), + providePresenceContext(), + ], + selector: "[q-date-picker-root]", + standalone: false, +}) +export class DatePickerRootDirective + extends CoreDatePickerRootDirective + implements SignalifyInput +{ + /** + * Whether to hide days from the previous and next months in the current month + * view. By default those days are shown. + * + * @default false + */ + readonly hideOutsideDays = input(undefined, { + transform: booleanAttribute, + }) + + /** + * The size of the input field and its elements. Does not affect the calendar + * pane. + * + * @default 'md' + */ + readonly size = input() + + protected readonly qdsDatePickerService = inject(QdsDatePickerContextService) + + override ngOnInit() { + this.trackBindings.extendWith( + computed(() => this.qdsDatePickerService.context().getRootBindings()), + ) + + this.qdsDatePickerService.init( + computed(() => + createQdsDatePickerApi( + { + hideOutsideDays: this.hideOutsideDays(), + size: this.size(), + } satisfies Explicit, + normalizeProps, + ), + ), + ) + + super.ngOnInit() + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-table-body.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-table-body.directive.ts new file mode 100644 index 000000000..af03addec --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-table-body.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerTableBodyDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * The calendar grid body. + */ +@Directive({ + selector: "[q-date-picker-table-body]", + standalone: false, +}) +export class DatePickerTableBodyDirective extends CoreDatePickerTableBodyDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getTableBodyBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-table-cell-trigger.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-table-cell-trigger.directive.ts new file mode 100644 index 000000000..8dc2a5934 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-table-cell-trigger.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerTableCellTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * The selectable trigger inside a calendar cell. + */ +@Directive({ + selector: "[q-date-picker-table-cell-trigger]", + standalone: false, +}) +export class DatePickerTableCellTriggerDirective extends CoreDatePickerTableCellTriggerDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getTableCellTriggerBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-table-cell.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-table-cell.directive.ts new file mode 100644 index 000000000..05cf69b40 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-table-cell.directive.ts @@ -0,0 +1,30 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import { + CoreDatePickerTableCellDirective, + provideDatePickerTableCellContext, +} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * A single cell in the calendar grid. + */ +@Directive({ + providers: [provideDatePickerTableCellContext()], + selector: "[q-date-picker-table-cell]", + standalone: false, +}) +export class DatePickerTableCellDirective extends CoreDatePickerTableCellDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getTableCellBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-table-head.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-table-head.directive.ts new file mode 100644 index 000000000..c6a43d06f --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-table-head.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerTableHeadDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * The calendar grid header. + */ +@Directive({ + selector: "[q-date-picker-table-head]", + standalone: false, +}) +export class DatePickerTableHeadDirective extends CoreDatePickerTableHeadDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getTableHeadBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-table-header.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-table-header.directive.ts new file mode 100644 index 000000000..0645a4934 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-table-header.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerTableHeaderDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * A column header in the calendar grid. + */ +@Directive({ + selector: "[q-date-picker-table-header]", + standalone: false, +}) +export class DatePickerTableHeaderDirective extends CoreDatePickerTableHeaderDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getTableHeaderBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-table-row.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-table-row.directive.ts new file mode 100644 index 000000000..5f6ee5ba9 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-table-row.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerTableRowDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * A row in the calendar grid. + */ +@Directive({ + selector: "[q-date-picker-table-row]", + standalone: false, +}) +export class DatePickerTableRowDirective extends CoreDatePickerTableRowDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getTableRowBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-table.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-table.directive.ts new file mode 100644 index 000000000..8b4e15392 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-table.directive.ts @@ -0,0 +1,44 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import { + CoreDatePickerTableDirective, + provideDatePickerTableContext, +} from "@qualcomm-ui/angular-core/date-picker" +import {datePickerViewColumns} from "@qualcomm-ui/qds-core/date-picker" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * The calendar grid. The column count is fixed per view, so the `columns` input + * of the core directive is ignored. + */ +@Directive({ + providers: [provideDatePickerTableContext()], + selector: "[q-date-picker-table]", + standalone: false, +}) +export class DatePickerTableDirective extends CoreDatePickerTableDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + protected override readonly resolvedColumns = computed(() => { + const view = this.resolvedView() + return view ? datePickerViewColumns[view] : undefined + }) + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => { + const columns = this.resolvedColumns() + return mergeProps( + this.qdsContext().getTableBindings(), + columns ? {style: {"--columns": columns}} : {}, + ) + }), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-trigger.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-trigger.directive.ts new file mode 100644 index 000000000..ebb6216cb --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-trigger.directive.ts @@ -0,0 +1,16 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive} from "@angular/core" + +import {CoreDatePickerTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" + +/** + * A button that opens and closes the calendar. Registers the trigger with the + * date picker so accessibility and focus restoration work. + */ +@Directive({ + selector: "[q-date-picker-trigger]", + standalone: false, +}) +export class DatePickerTriggerDirective extends CoreDatePickerTriggerDirective {} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-value-tags.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-value-tags.component.ts new file mode 100644 index 000000000..dc6070ab5 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-value-tags.component.ts @@ -0,0 +1,103 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, input, type OnInit} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {DateValue} from "@qualcomm-ui/core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +interface ValueTag { + date: DateValue + label: string +} + +/** + * Displays the dates selected in `multiple` mode as dismissible tags. Renders a + * visually hidden input per selected date so the selection participates in form + * submission under the picker's `name`. + */ +@Component({ + host: { + "[attr.data-empty]": "tags().length ? null : ''", + }, + selector: "span[q-date-picker-value-tags]", + standalone: false, + template: ` + @if (tags().length) { + @for (tag of tags(); track tag.date.toString()) { + + {{ tag.label }} + + } + } @else { + {{ placeholder() }} + } + @for (formValue of formValues(); track $index) { + + } + `, +}) +export class DatePickerValueTagsDirective implements OnInit { + /** + * Returns the accessible label for a tag's remove button. + * + * @default (dateText) => `Remove ${dateText}` + */ + readonly dismissLabel = input<(dateText: string) => string>( + (dateText) => `Remove ${dateText}`, + ) + + /** + * Text shown when no date is selected. + * + * @default 'Select dates' + */ + readonly placeholder = input("Select dates") + + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly trackBindings = useTrackBindings(() => + this.qdsContext().getValueTagsBindings(), + ) + + protected readonly api = useDatePickerContext() + + protected readonly tags = computed(() => { + const api = this.api() + return api.value.flatMap((date, index) => + date == null ? [] : [{date, label: api.valueAsString[index]}], + ) + }) + + /** + * An empty selection still needs one input so `required` blocks submission. + */ + protected readonly formValues = computed(() => { + const tags = this.tags() + return tags.length ? tags.map((tag) => tag.label) : [""] + }) + + protected readonly isDisabled = computed(() => { + const api = this.api() + return api.disabled || api.readOnly + }) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-view-close-trigger.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-view-close-trigger.component.ts new file mode 100644 index 000000000..86d61a9d2 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-view-close-trigger.component.ts @@ -0,0 +1,50 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" +import {LucideX} from "@lucide/angular" + +import {CoreDatePickerViewCloseTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" +import {provideIcons} from "@qualcomm-ui/angular-core/lucide" +import {useIconButtonApi} from "@qualcomm-ui/angular/button" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Returns from the month or year view to the day calendar. Styled as an outline + * icon button. + */ +@Component({ + providers: [provideIcons({LucideX})], + selector: "[q-date-picker-view-close-trigger]", + standalone: false, + template: ` + + + + `, +}) +export class DatePickerViewCloseTriggerDirective extends CoreDatePickerViewCloseTriggerDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly iconButtonApi = useIconButtonApi({ + density: "compact", + disabled: computed(() => !!this.coreBindings().disabled), + shape: "square", + size: "sm", + variant: "outline", + }) + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => + mergeProps( + this.qdsContext().getViewCloseTriggerBindings(), + this.iconButtonApi().getRootBindings(), + ), + ), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-view-control.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-view-control.directive.ts new file mode 100644 index 000000000..f4ff303c1 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-view-control.directive.ts @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreDatePickerViewControlDirective} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Groups the navigation controls of a view. + */ +@Directive({ + selector: "[q-date-picker-view-control]", + standalone: false, +}) +export class DatePickerViewControlDirective extends CoreDatePickerViewControlDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getViewControlBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-view-trigger.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-view-trigger.component.ts new file mode 100644 index 000000000..eb6100689 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-view-trigger.component.ts @@ -0,0 +1,53 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {booleanAttribute, Component, computed, input} from "@angular/core" + +import {CoreDatePickerViewTriggerDirective} from "@qualcomm-ui/angular-core/date-picker" +import {useButtonApi} from "@qualcomm-ui/angular/button" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Switches to the next view level (day to month to year). Styled as a ghost + * button. + */ +@Component({ + selector: "[q-date-picker-view-trigger]", + standalone: false, + template: ` + + `, +}) +export class DatePickerViewTriggerDirective extends CoreDatePickerViewTriggerDirective { + /** + * Renders the trigger as a non-interactive label. Use in the month and year + * views, where the label describes the visible range rather than a target. + */ + readonly disabled = input(undefined, { + transform: booleanAttribute, + }) + + protected readonly qdsContext = useQdsDatePickerContext() + + protected readonly buttonApi = useButtonApi({ + density: "compact", + disabled: computed(() => this.disabled() || !!this.coreBindings().disabled), + size: "md", + variant: "ghost", + }) + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => + mergeProps( + this.qdsContext().getViewTriggerBindings(), + this.buttonApi().getRootBindings(), + ), + ), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-view.directive.ts b/packages/frameworks/angular/src/date-picker/date-picker-view.directive.ts new file mode 100644 index 000000000..da94827e6 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-view.directive.ts @@ -0,0 +1,30 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import { + CoreDatePickerViewDirective, + provideDatePickerViewContext, +} from "@qualcomm-ui/angular-core/date-picker" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.service" + +/** + * Groups a single calendar view (day, month, or year). + */ +@Directive({ + providers: [provideDatePickerViewContext()], + selector: "[q-date-picker-view]", + standalone: false, +}) +export class DatePickerViewDirective extends CoreDatePickerViewDirective { + protected readonly qdsContext = useQdsDatePickerContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsContext().getViewBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-year-grid.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-year-grid.component.ts new file mode 100644 index 000000000..3a90e3f8a --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-year-grid.component.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" +import {datePickerViewColumns} from "@qualcomm-ui/qds-core/date-picker" + +import {DatePickerTableBodyDirective} from "./date-picker-table-body.directive" + +/** + * Renders the year cells for the year view. Apply to a `tbody` element. + */ +@Component({ + selector: "[q-date-picker-year-grid]", + standalone: false, + template: ` + @for (years of yearsGrid(); track $index) { + + @for (year of years; track year.value) { + +
{{ year.label }}
+ + } + + } + `, +}) +export class DatePickerYearGridDirective extends DatePickerTableBodyDirective { + protected readonly api = useDatePickerContext() + + protected readonly yearsGrid = computed(() => + this.api().getYearsGrid({columns: datePickerViewColumns.year}), + ) +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker-year-text.component.ts b/packages/frameworks/angular/src/date-picker/date-picker-year-text.component.ts new file mode 100644 index 000000000..df7154b76 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker-year-text.component.ts @@ -0,0 +1,42 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, input} from "@angular/core" + +import {useDatePickerContext} from "@qualcomm-ui/angular-core/date-picker" + +export type DatePickerYearTextFormat = "2-digit" | "numeric" + +/** + * Renders the currently visible year. Intended as the label for a view trigger + * that jumps to the year view. + */ +@Component({ + selector: "q-date-picker-year-text", + standalone: false, + styles: [ + ` + :host { + display: contents; + } + `, + ], + template: ` + {{ text() }} + `, +}) +export class DatePickerYearTextDirective { + /** + * The format used to render the visible year. + * + * @default 'numeric' + */ + readonly format = input("numeric") + + protected readonly api = useDatePickerContext() + + protected readonly text = computed(() => { + const api = this.api() + return api.format(api.visibleRange.start, {year: this.format()}) + }) +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker.component.ts b/packages/frameworks/angular/src/date-picker/date-picker.component.ts new file mode 100644 index 000000000..0da95f077 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker.component.ts @@ -0,0 +1,305 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + contentChild, + inject, + input, +} from "@angular/core" + +import { + provideDatePickerContext, + provideDatePickerViewContext, +} from "@qualcomm-ui/angular-core/date-picker" +import {INPUT_FORM_CONTROL_CONTEXT} from "@qualcomm-ui/angular-core/input" +import {providePresenceContext} from "@qualcomm-ui/angular-core/presence" +import type {DatePickerPresetTriggerValue} from "@qualcomm-ui/core/date-picker" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" + +import {DatePickerActionsDirective} from "./date-picker-actions.directive" +import {DatePickerPresetsDirective} from "./date-picker-presets.directive" +import {DatePickerRootDirective} from "./date-picker-root.directive" +import {provideQdsDatePickerContext} from "./qds-date-picker-context.service" + +export interface DatePickerPreset { + /** + * Text rendered inside the preset trigger. + */ + label: string + + /** + * The value applied when the preset is selected. Either a named range preset + * (e.g. `"next7Days"`) or an explicit array of dates. + */ + value: DatePickerPresetTriggerValue +} + +export type DatePickerVariant = "input" | "inline" + +/** + * A date picker with the full calendar composed for you. Defaults to a labelled + * field that opens a calendar popover; use {@link variant} for an always-on + * `inline` calendar. For finer control, compose the parts yourself with + * `q-date-picker-root` and friends. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: INPUT_FORM_CONTROL_CONTEXT, + useFactory: () => + inject(DatePickerComponent, {self: true}).formControlContext, + }, + provideDatePickerContext(), + provideDatePickerViewContext(), + provideQdsDatePickerContext(), + providePresenceContext(), + ], + selector: "q-date-picker", + standalone: false, + template: ` + +
+
+ + + + + @if (hasPresets()) { + + } +
+ + + +
+
+ +
+
+ + + + + +
+ + +
+
+ +
+
+ + + + + +
+ + +
+
+ + + @if (presets()?.length) { +
+ @for (preset of presets(); track $index) { + + } +
+ } +
+ + @if (!isInline()) { + + @if (showActions()) { +
+ + +
+ } +
+ } +
+ + @if (isInline()) { +
+ + @if (headline()) { +
+ + + + + + +
+ } +
+ +
+ } @else { + +
+ +
+
+
+
+ + + @if (hint()) { +
{{ hint() }}
+ } +
+ + @if (errorText()) { +
{{ errorText() }}
+ } +
+ + +
+
+ +
+
+
+ } + `, +}) +export class DatePickerComponent extends DatePickerRootDirective { + /** + * Set to true to disable portalling behavior for the calendar popover. + */ + readonly disablePortal = input(undefined, { + transform: booleanAttribute, + }) + + /** + * Optional error that describes the element when `invalid` is true. + * + * @remarks + * To customize the element, provide it using the directive instead: + * + * ```angular-html + *
...
+ * ``` + */ + readonly errorText = input() + + /** + * Whether to fix the input value on blur. + * + * @default true + */ + readonly fixOnBlur = input(true, { + transform: booleanAttribute, + }) + + /** + * Whether to render the headline (label and selected value) above the + * calendar. Only applies to the `inline` variant. + * + * @default true + */ + readonly headline = input(true, { + transform: booleanAttribute, + }) + + /** + * Optional hint describing the element. This element is automatically + * associated with the component's input element for accessibility. + * + * @remarks + * To customize the element, provide it using the directive instead: + * + * ```angular-html + *
...
+ * ``` + */ + readonly hint = input() + + /** + * Label text rendered above the field. + */ + readonly label = input() + + /** + * Quick-select presets shown alongside the calendar. When provided, a toggle + * is rendered in the day view to reveal the preset list. + */ + readonly presets = input() + + /** + * Character shown between the start and end inputs of a range picker. + * + * @default '-' + */ + readonly separator = input("-") + + /** + * The presentation of the date picker. + * - `input` - a labelled field that opens the calendar in a popover. + * - `inline` - an always-visible, flat calendar that commits on selection. + * + * @default 'input' + */ + readonly variant = input("input") + + protected readonly projectedPresets = contentChild(DatePickerPresetsDirective) + protected readonly projectedActions = contentChild(DatePickerActionsDirective) + + /** + * Whether the presets trigger should be displayed. + */ + protected readonly hasPresets = computed( + () => !!this.presets()?.length || !!this.projectedPresets(), + ) + + /** + * The inherited `inline` input must drive the rendered branch too, or it + * configures an inline machine behind the input and popover layout. + */ + protected readonly isInline = computed( + () => this.variant() === "inline" || !!this.inline(), + ) + + protected override readonly resolvedInline = this.isInline + + protected readonly showActions = computed( + () => + !this.isInline() && + (this.closeOnSelect() === false || + this.selectionMode() === "multiple" || + !!this.projectedActions()), + ) + + protected override readonly resolvedCloseOnSelect = computed(() => + this.showActions() ? false : this.closeOnSelect(), + ) +} diff --git a/packages/frameworks/angular/src/date-picker/date-picker.module.ts b/packages/frameworks/angular/src/date-picker/date-picker.module.ts new file mode 100644 index 000000000..b3e114d8c --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/date-picker.module.ts @@ -0,0 +1,121 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {NgTemplateOutlet} from "@angular/common" +import {NgModule} from "@angular/core" + +import {QBindDirective} from "@qualcomm-ui/angular-core/machine" +import {PortalDirective} from "@qualcomm-ui/angular-core/portal" +import {IconDirective} from "@qualcomm-ui/angular/icon" +import {TagDirective} from "@qualcomm-ui/angular/tag" + +import {DatePickerActionsDirective} from "./date-picker-actions.directive" +import {DatePickerCancelTriggerDirective} from "./date-picker-cancel-trigger.component" +import {DatePickerClearTriggerDirective} from "./date-picker-clear-trigger.directive" +import {DatePickerContentDirective} from "./date-picker-content.directive" +import {DatePickerContextDirective} from "./date-picker-context.directive" +import {DatePickerControlDirective} from "./date-picker-control.directive" +import {DatePickerDayGridHeaderDirective} from "./date-picker-day-grid-header.component" +import {DatePickerDayGridDirective} from "./date-picker-day-grid.component" +import {DatePickerErrorIndicatorDirective} from "./date-picker-error-indicator.component" +import {DatePickerErrorTextDirective} from "./date-picker-error-text.directive" +import {DatePickerHeadlineLabelDirective} from "./date-picker-headline-label.component" +import {DatePickerHeadlineValueDirective} from "./date-picker-headline-value.component" +import {DatePickerHeadlineDirective} from "./date-picker-headline.directive" +import {DatePickerHintDirective} from "./date-picker-hint.directive" +import {DatePickerInputClearTriggerDirective} from "./date-picker-input-clear-trigger.component" +import {DatePickerInputGroupTriggerDirective} from "./date-picker-input-group-trigger.directive" +import {DatePickerInputGroupDirective} from "./date-picker-input-group.component" +import {DatePickerInputTriggerDirective} from "./date-picker-input-trigger.component" +import {DatePickerInputDirective} from "./date-picker-input.directive" +import {DatePickerLabelDirective} from "./date-picker-label.directive" +import {DatePickerMonthGridDirective} from "./date-picker-month-grid.component" +import {DatePickerMonthTextDirective} from "./date-picker-month-text.component" +import {DatePickerNextTriggerDirective} from "./date-picker-next-trigger.component" +import {DatePickerOkTriggerDirective} from "./date-picker-ok-trigger.component" +import {DatePickerPositionerDirective} from "./date-picker-positioner.directive" +import {DatePickerPresetTriggerDirective} from "./date-picker-preset-trigger.directive" +import {DatePickerPresetsTriggerDirective} from "./date-picker-presets-trigger.component" +import {DatePickerPresetsDirective} from "./date-picker-presets.directive" +import {DatePickerPrevTriggerDirective} from "./date-picker-prev-trigger.component" +import {DatePickerRangeTextDirective} from "./date-picker-range-text.component" +import {DatePickerRootDirective} from "./date-picker-root.directive" +import {DatePickerTableBodyDirective} from "./date-picker-table-body.directive" +import {DatePickerTableCellTriggerDirective} from "./date-picker-table-cell-trigger.directive" +import {DatePickerTableCellDirective} from "./date-picker-table-cell.directive" +import {DatePickerTableHeadDirective} from "./date-picker-table-head.directive" +import {DatePickerTableHeaderDirective} from "./date-picker-table-header.directive" +import {DatePickerTableRowDirective} from "./date-picker-table-row.directive" +import {DatePickerTableDirective} from "./date-picker-table.directive" +import {DatePickerTriggerDirective} from "./date-picker-trigger.directive" +import {DatePickerValueTagsDirective} from "./date-picker-value-tags.component" +import {DatePickerViewCloseTriggerDirective} from "./date-picker-view-close-trigger.component" +import {DatePickerViewControlDirective} from "./date-picker-view-control.directive" +import {DatePickerViewTriggerDirective} from "./date-picker-view-trigger.component" +import {DatePickerViewDirective} from "./date-picker-view.directive" +import {DatePickerYearGridDirective} from "./date-picker-year-grid.component" +import {DatePickerYearTextDirective} from "./date-picker-year-text.component" +import {DatePickerComponent} from "./date-picker.component" + +const parts = [ + DatePickerComponent, + DatePickerRootDirective, + DatePickerActionsDirective, + DatePickerCancelTriggerDirective, + DatePickerClearTriggerDirective, + DatePickerContentDirective, + DatePickerContextDirective, + DatePickerControlDirective, + DatePickerDayGridDirective, + DatePickerDayGridHeaderDirective, + DatePickerErrorIndicatorDirective, + DatePickerErrorTextDirective, + DatePickerHeadlineDirective, + DatePickerHeadlineLabelDirective, + DatePickerHeadlineValueDirective, + DatePickerHintDirective, + DatePickerInputDirective, + DatePickerInputClearTriggerDirective, + DatePickerInputGroupDirective, + DatePickerInputGroupTriggerDirective, + DatePickerInputTriggerDirective, + DatePickerLabelDirective, + DatePickerMonthGridDirective, + DatePickerMonthTextDirective, + DatePickerNextTriggerDirective, + DatePickerOkTriggerDirective, + DatePickerPositionerDirective, + DatePickerPresetsDirective, + DatePickerPresetsTriggerDirective, + DatePickerPresetTriggerDirective, + DatePickerPrevTriggerDirective, + DatePickerRangeTextDirective, + DatePickerTableDirective, + DatePickerTableBodyDirective, + DatePickerTableCellDirective, + DatePickerTableCellTriggerDirective, + DatePickerTableHeadDirective, + DatePickerTableHeaderDirective, + DatePickerTableRowDirective, + DatePickerTriggerDirective, + DatePickerValueTagsDirective, + DatePickerViewDirective, + DatePickerViewCloseTriggerDirective, + DatePickerViewControlDirective, + DatePickerViewTriggerDirective, + DatePickerYearGridDirective, + DatePickerYearTextDirective, +] + +@NgModule({ + declarations: parts, + exports: parts, + imports: [ + IconDirective, + NgTemplateOutlet, + PortalDirective, + QBindDirective, + TagDirective, + ], +}) +export class DatePickerModule {} diff --git a/packages/frameworks/angular/src/date-picker/index.ts b/packages/frameworks/angular/src/date-picker/index.ts new file mode 100644 index 000000000..94800db38 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/index.ts @@ -0,0 +1,58 @@ +export { + CalendarDate, + getLocalTimeZone, + isWeekend, + parseDate, + today, + type DateValue, +} from "@qualcomm-ui/utils/date-utils" + +export * from "./date-picker-actions.directive" +export * from "./date-picker-cancel-trigger.component" +export * from "./date-picker-clear-trigger.directive" +export * from "./date-picker-content.directive" +export * from "./date-picker-context.directive" +export * from "./date-picker-control.directive" +export * from "./date-picker-day-grid-header.component" +export * from "./date-picker-day-grid.component" +export * from "./date-picker-error-indicator.component" +export * from "./date-picker-error-text.directive" +export * from "./date-picker-headline-label.component" +export * from "./date-picker-headline-value.component" +export * from "./date-picker-headline.directive" +export * from "./date-picker-hint.directive" +export * from "./date-picker-input-clear-trigger.component" +export * from "./date-picker-input-group-trigger.directive" +export * from "./date-picker-input-group.component" +export * from "./date-picker-input-trigger.component" +export * from "./date-picker-input.directive" +export * from "./date-picker-label.directive" +export * from "./date-picker-month-grid.component" +export * from "./date-picker-month-text.component" +export * from "./date-picker-next-trigger.component" +export * from "./date-picker-ok-trigger.component" +export * from "./date-picker-positioner.directive" +export * from "./date-picker-preset-trigger.directive" +export * from "./date-picker-presets-trigger.component" +export * from "./date-picker-presets.directive" +export * from "./date-picker-prev-trigger.component" +export * from "./date-picker-range-text.component" +export * from "./date-picker-root.directive" +export * from "./date-picker-table-body.directive" +export * from "./date-picker-table-cell-trigger.directive" +export * from "./date-picker-table-cell.directive" +export * from "./date-picker-table-head.directive" +export * from "./date-picker-table-header.directive" +export * from "./date-picker-table-row.directive" +export * from "./date-picker-table.directive" +export * from "./date-picker-trigger.directive" +export * from "./date-picker-value-tags.component" +export * from "./date-picker-view-close-trigger.component" +export * from "./date-picker-view-control.directive" +export * from "./date-picker-view-trigger.component" +export * from "./date-picker-view.directive" +export * from "./date-picker-year-grid.component" +export * from "./date-picker-year-text.component" +export * from "./date-picker.component" +export * from "./date-picker.module" +export * from "./qds-date-picker-context.service" diff --git a/packages/frameworks/angular/src/date-picker/ng-package.json b/packages/frameworks/angular/src/date-picker/ng-package.json new file mode 100644 index 000000000..7e82164ba --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/frameworks/angular/src/date-picker/qds-date-picker-context.service.ts b/packages/frameworks/angular/src/date-picker/qds-date-picker-context.service.ts new file mode 100644 index 000000000..cff7afc96 --- /dev/null +++ b/packages/frameworks/angular/src/date-picker/qds-date-picker-context.service.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Injectable} from "@angular/core" + +import { + type ApiContext, + BaseApiContextService, + createApiContext, +} from "@qualcomm-ui/angular-core/machine" +import type {QdsDatePickerApi} from "@qualcomm-ui/qds-core/date-picker" + +@Injectable() +export class QdsDatePickerContextService extends BaseApiContextService {} + +export const [ + QDS_DATE_PICKER_CONTEXT, + useQdsDatePickerContext, + provideQdsDatePickerContext, +]: ApiContext = createApiContext( + "QdsDatePickerContext", + QdsDatePickerContextService, +) diff --git a/packages/frameworks/angular/src/select/select-value-text.directive.ts b/packages/frameworks/angular/src/select/select-value-text.directive.ts index 50f6f4c5c..47de520f6 100644 --- a/packages/frameworks/angular/src/select/select-value-text.directive.ts +++ b/packages/frameworks/angular/src/select/select-value-text.directive.ts @@ -1,7 +1,7 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -import {Component, computed} from "@angular/core" +import {Component, computed, input} from "@angular/core" import {CoreSelectValueTextDirective} from "@qualcomm-ui/angular-core/select" @@ -17,19 +17,30 @@ import {useQdsSelectContext} from "./qds-select-context.service" {{ valueAsString() }} } @else { @for (itemId of value(); track itemId) { + @let label = selectContext().collection.stringifyItem(itemId) ?? ""; } } `, }) export class SelectValueTextDirective extends CoreSelectValueTextDirective { + /** + * Returns the accessible label for a selected item's remove button. + * + * @default (itemText) => `Remove ${itemText}` + */ + readonly dismissLabel = input<(itemText: string) => string>( + (itemText) => `Remove ${itemText}`, + ) + protected readonly qdsSelectContext = useQdsSelectContext() constructor() { diff --git a/packages/frameworks/angular/src/tag/tag.directive.ts b/packages/frameworks/angular/src/tag/tag.directive.ts index 62051107c..a5a6709d2 100644 --- a/packages/frameworks/angular/src/tag/tag.directive.ts +++ b/packages/frameworks/angular/src/tag/tag.directive.ts @@ -159,6 +159,14 @@ export class TagDirective implements SignalifyInput, OnInit { transform: booleanAttribute, }) + /** + * Accessible label for the dismiss button. + * Only applicable when {@link variant} is `dismissable`. + * + * @default 'Dismiss' + */ + readonly dismissLabel = input() + /** * {@link https://lucide.dev/icons lucide-angular} icon, positioned after the label. * Ignored when {@link variant} is `dismissable`, as it is reserved for the @@ -256,6 +264,7 @@ export class TagDirective implements SignalifyInput, OnInit { { active: this.active(), disabled: this.disabled(), + dismissLabel: this.dismissLabel(), emphasis: this.emphasis(), radius: this.radius(), selected: this.selectedState().value(), diff --git a/packages/frameworks/react-core/src/date-picker/core-date-picker.tsx b/packages/frameworks/react-core/src/date-picker/core-date-picker.tsx new file mode 100644 index 000000000..aa7889277 --- /dev/null +++ b/packages/frameworks/react-core/src/date-picker/core-date-picker.tsx @@ -0,0 +1,622 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement, ReactNode} from "react" + +import { + createDatePickerApi, + type DatePickerApi, + type DatePickerApiProps, + datePickerMachine, + type DateValue, + type DatePickerDateView, + type DatePickerApiDayTableCellProps, + type DatePickerApiInputProps, + type DatePickerApiLabelProps, + type DatePickerApiPresetTriggerProps, + splitDatePickerInputProps, + splitDatePickerLabelProps, + splitDatePickerPresetTriggerProps, + splitDatePickerProps, + type DatePickerApiTableCellProps, + type DatePickerApiTableProps, + type DatePickerApiViewProps, + type DatePickerVisibleRange, +} from "@qualcomm-ui/core/date-picker" +import { + type PresenceApiProps, + splitPresenceProps, +} from "@qualcomm-ui/core/presence" +import {useOnDestroy, useOnDestroyWhen} from "@qualcomm-ui/react-core/effects" +import {normalizeProps, useMachine} from "@qualcomm-ui/react-core/machine" +import { + PresenceContextProvider, + usePresence, + usePresenceContext, +} from "@qualcomm-ui/react-core/presence" +import {useControlledId} from "@qualcomm-ui/react-core/state" +import { + type ElementRenderProp, + type IdProp, + PolymorphicElement, + type RenderProp, + renderProp, +} from "@qualcomm-ui/react-core/system" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import { + DatePickerContextProvider, + DatePickerTableCellContextProvider, + DatePickerTableContextProvider, + DatePickerViewContextProvider, + useDatePickerContext, + useDatePickerTableCellContext, + useDatePickerTableContext, + useDatePickerViewContext, +} from "./date-picker-context.js" + +export interface CoreDatePickerRootProps + extends + Omit, + PresenceApiProps, + Omit, "defaultValue" | "dir"> {} + +export function CoreDatePickerRoot({ + children, + ...props +}: CoreDatePickerRootProps): ReactElement { + const [presenceProps, otherProps] = splitPresenceProps(props) + const [datePickerProps, localProps] = splitDatePickerProps(otherProps) + + const config = useMachine(datePickerMachine, datePickerProps) + const api = createDatePickerApi(config, normalizeProps) + + const presence = usePresence(mergeProps({present: api.open}, presenceProps)) + + const mergedProps = mergeProps(api.getRootBindings(), localProps) + + return ( + + + + {children} + + + + ) +} + +export interface CoreDatePickerClearTriggerProps + extends ElementRenderProp<"button">, IdProp {} + +export function CoreDatePickerClearTrigger({ + id, + ...props +}: CoreDatePickerClearTriggerProps): ReactElement { + const context = useDatePickerContext() + const mergedProps = mergeProps( + context.getClearTriggerBindings({ + id: useControlledId(id), + onDestroy: useOnDestroy(), + }), + props, + ) + + return +} + +export interface CoreDatePickerContentProps + extends ElementRenderProp<"div">, IdProp {} + +export function CoreDatePickerContent({ + id, + ...props +}: CoreDatePickerContentProps): ReactElement | null { + const context = useDatePickerContext() + const presence = usePresenceContext() + const mergedProps = mergeProps( + context.getContentBindings({ + id: useControlledId(id), + onDestroy: useOnDestroyWhen(presence.unmounted), + }), + presence.getPresenceBindings(), + props, + ) + + if (presence.unmounted) { + return null + } + + return +} + +export interface CoreDatePickerControlProps + extends ElementRenderProp<"div">, IdProp {} + +export function CoreDatePickerControl({ + id, + ...props +}: CoreDatePickerControlProps): ReactElement { + const context = useDatePickerContext() + const mergedProps = mergeProps( + context.getControlBindings({ + id: useControlledId(id), + onDestroy: useOnDestroy(), + }), + props, + ) + + return +} + +export interface CoreDatePickerErrorIndicatorProps extends ElementRenderProp<"span"> {} + +export function CoreDatePickerErrorIndicator( + props: CoreDatePickerErrorIndicatorProps, +): ReactElement { + const context = useDatePickerContext() + const mergedProps = mergeProps(context.getErrorIndicatorBindings(), props) + + return +} + +export interface CoreDatePickerErrorTextProps + extends ElementRenderProp<"div">, IdProp {} + +export function CoreDatePickerErrorText({ + id, + ...props +}: CoreDatePickerErrorTextProps): ReactElement { + const context = useDatePickerContext() + const mergedProps = mergeProps( + context.getErrorTextBindings({ + id: useControlledId(id), + onDestroy: useOnDestroy(), + }), + props, + ) + + return +} + +export interface CoreDatePickerHintProps + extends ElementRenderProp<"div">, IdProp {} + +export function CoreDatePickerHint({ + id, + ...props +}: CoreDatePickerHintProps): ReactElement { + const context = useDatePickerContext() + const mergedProps = mergeProps( + context.getHintBindings({ + id: useControlledId(id), + onDestroy: useOnDestroy(), + }), + props, + ) + + return +} + +export interface CoreDatePickerInputProps + extends ElementRenderProp<"input">, DatePickerApiInputProps, IdProp {} + +export function CoreDatePickerInput({ + id, + ...props +}: CoreDatePickerInputProps): ReactElement { + const context = useDatePickerContext() + const [inputProps, localProps] = splitDatePickerInputProps(props) + const mergedProps = mergeProps( + context.getInputBindings({ + id: useControlledId(id), + onDestroy: useOnDestroy(), + ...inputProps, + }), + localProps, + ) + + return +} + +export interface CoreDatePickerLabelProps + extends ElementRenderProp<"label">, DatePickerApiLabelProps {} + +export function CoreDatePickerLabel( + props: CoreDatePickerLabelProps, +): ReactElement { + const context = useDatePickerContext() + const [labelProps, localProps] = splitDatePickerLabelProps(props) + const mergedProps = mergeProps( + context.getLabelBindings(labelProps), + localProps, + ) + + return +} + +export interface CoreDatePickerNextTriggerProps + extends ElementRenderProp<"button">, DatePickerApiViewProps {} + +export function CoreDatePickerNextTrigger({ + view, + ...props +}: CoreDatePickerNextTriggerProps): ReactElement { + const context = useDatePickerContext() + const viewContext = useDatePickerViewContext() + const mergedProps = mergeProps( + context.getNextTriggerBindings(view ? {...viewContext, view} : viewContext), + props, + ) + + return +} + +export interface CoreDatePickerPositionerProps + extends ElementRenderProp<"div">, IdProp {} + +export function CoreDatePickerPositioner({ + id, + ...props +}: CoreDatePickerPositionerProps): ReactElement | null { + const context = useDatePickerContext() + const presence = usePresenceContext() + const mergedProps = mergeProps( + context.getPositionerBindings({ + id: useControlledId(id), + onDestroy: useOnDestroyWhen(presence.unmounted), + }), + props, + ) + + if (presence.unmounted) { + return null + } + + return +} + +export interface CoreDatePickerPresetsProps extends ElementRenderProp<"div"> {} + +export function CoreDatePickerPresets( + props: CoreDatePickerPresetsProps, +): ReactElement { + const context = useDatePickerContext() + const mergedProps = mergeProps(context.getPresetsBindings(), props) + + return +} + +export interface CoreDatePickerPresetsTriggerProps extends ElementRenderProp<"button"> {} + +export function CoreDatePickerPresetsTrigger( + props: CoreDatePickerPresetsTriggerProps, +): ReactElement { + const context = useDatePickerContext() + const mergedProps = mergeProps(context.getPresetsTriggerBindings(), props) + + return +} + +export interface CoreDatePickerPresetTriggerProps + extends + Omit, "value">, + DatePickerApiPresetTriggerProps {} + +export function CoreDatePickerPresetTrigger( + props: CoreDatePickerPresetTriggerProps, +): ReactElement { + const context = useDatePickerContext() + const [presetProps, localProps] = splitDatePickerPresetTriggerProps(props) + const mergedProps = mergeProps( + context.getPresetTriggerBindings(presetProps), + localProps, + ) + + return +} + +export interface CoreDatePickerPrevTriggerProps + extends ElementRenderProp<"button">, DatePickerApiViewProps {} + +export function CoreDatePickerPrevTrigger({ + view, + ...props +}: CoreDatePickerPrevTriggerProps): ReactElement { + const context = useDatePickerContext() + const viewContext = useDatePickerViewContext() + const mergedProps = mergeProps( + context.getPrevTriggerBindings(view ? {...viewContext, view} : viewContext), + props, + ) + + return +} + +export interface CoreDatePickerRangeTextProps extends ElementRenderProp<"div"> {} + +export function CoreDatePickerRangeText( + props: CoreDatePickerRangeTextProps, +): ReactElement { + const context = useDatePickerContext() + const mergedProps = mergeProps(context.getRangeTextBindings(), props) + + return +} + +export interface CoreDatePickerTableProps + extends ElementRenderProp<"table">, DatePickerApiTableProps {} + +export function CoreDatePickerTable({ + children, + columns, + view, + ...props +}: CoreDatePickerTableProps): ReactElement { + const context = useDatePickerContext() + const viewContext = useDatePickerViewContext() + const tableProps: DatePickerApiTableProps = { + ...viewContext, + columns, + ...(view ? {view} : {}), + } + const mergedProps = mergeProps(context.getTableBindings(tableProps), props) + + return ( + + + {children} + + + ) +} + +export interface CoreDatePickerTableBodyProps extends ElementRenderProp<"tbody"> {} + +export function CoreDatePickerTableBody( + props: CoreDatePickerTableBodyProps, +): ReactElement { + const context = useDatePickerContext() + const tableContext = useDatePickerTableContext() + const mergedProps = mergeProps( + context.getTableBodyBindings(tableContext), + props, + ) + + return +} + +export interface CoreDatePickerTableCellProps extends Omit< + ElementRenderProp<"td">, + "value" +> { + columns?: number + disabled?: boolean + value: DateValue | number + visibleRange?: DatePickerVisibleRange +} + +export function CoreDatePickerTableCell({ + children, + columns, + disabled, + value, + visibleRange, + ...props +}: CoreDatePickerTableCellProps): ReactElement { + const context = useDatePickerContext() + const {view} = useDatePickerTableContext() + const cellProps = {columns, disabled, value, visibleRange} as + | DatePickerApiDayTableCellProps + | DatePickerApiTableCellProps + + let bindings + switch (view) { + case "month": + bindings = context.getMonthTableCellBindings( + cellProps as DatePickerApiTableCellProps, + ) + break + case "year": + bindings = context.getYearTableCellBindings( + cellProps as DatePickerApiTableCellProps, + ) + break + default: + bindings = context.getDayTableCellBindings( + cellProps as DatePickerApiDayTableCellProps, + ) + } + + const mergedProps = mergeProps(bindings, props) + + return ( + + + {children} + + + ) +} + +export interface CoreDatePickerTableCellTriggerProps extends ElementRenderProp<"div"> {} + +export function CoreDatePickerTableCellTrigger( + props: CoreDatePickerTableCellTriggerProps, +): ReactElement { + const context = useDatePickerContext() + const cellContext = useDatePickerTableCellContext() + const {view} = useDatePickerTableContext() + + let bindings + switch (view) { + case "month": + bindings = context.getMonthTableCellTriggerBindings( + cellContext as DatePickerApiTableCellProps, + ) + break + case "year": + bindings = context.getYearTableCellTriggerBindings( + cellContext as DatePickerApiTableCellProps, + ) + break + default: + bindings = context.getDayTableCellTriggerBindings( + cellContext as DatePickerApiDayTableCellProps, + ) + } + + const mergedProps = mergeProps(bindings, props) + + return +} + +export interface CoreDatePickerTableHeadProps extends ElementRenderProp<"thead"> {} + +export function CoreDatePickerTableHead( + props: CoreDatePickerTableHeadProps, +): ReactElement { + const context = useDatePickerContext() + const tableContext = useDatePickerTableContext() + const mergedProps = mergeProps( + context.getTableHeadBindings(tableContext), + props, + ) + + return +} + +export interface CoreDatePickerTableHeaderProps extends ElementRenderProp<"th"> {} + +export function CoreDatePickerTableHeader( + props: CoreDatePickerTableHeaderProps, +): ReactElement { + const context = useDatePickerContext() + const tableContext = useDatePickerTableContext() + const mergedProps = mergeProps( + context.getTableHeaderBindings(tableContext), + props, + ) + + return +} + +export interface CoreDatePickerTableRowProps extends ElementRenderProp<"tr"> {} + +export function CoreDatePickerTableRow( + props: CoreDatePickerTableRowProps, +): ReactElement { + const context = useDatePickerContext() + const tableContext = useDatePickerTableContext() + const mergedProps = mergeProps( + context.getTableRowBindings(tableContext), + props, + ) + + return +} + +export interface CoreDatePickerTriggerProps + extends ElementRenderProp<"button">, IdProp {} + +export function CoreDatePickerTrigger({ + id, + ...props +}: CoreDatePickerTriggerProps): ReactElement { + const context = useDatePickerContext() + const mergedProps = mergeProps( + context.getTriggerBindings({ + id: useControlledId(id), + onDestroy: useOnDestroy(), + }), + props, + ) + + return +} + +export interface CoreDatePickerViewProps + extends ElementRenderProp<"div">, DatePickerApiViewProps {} + +export function CoreDatePickerView({ + children, + view, + ...props +}: CoreDatePickerViewProps): ReactElement { + const context = useDatePickerContext() + const viewProps: DatePickerApiViewProps = {view} + const mergedProps = mergeProps(context.getViewBindings(viewProps), props) + + return ( + + + {children} + + + ) +} + +export interface CoreDatePickerViewControlProps extends ElementRenderProp<"div"> {} + +export function CoreDatePickerViewControl( + props: CoreDatePickerViewControlProps, +): ReactElement { + const context = useDatePickerContext() + const viewContext = useDatePickerViewContext() + const mergedProps = mergeProps( + context.getViewControlBindings(viewContext), + props, + ) + + return +} + +export interface CoreDatePickerViewCloseTriggerProps extends ElementRenderProp<"button"> {} + +export function CoreDatePickerViewCloseTrigger( + props: CoreDatePickerViewCloseTriggerProps, +): ReactElement { + const context = useDatePickerContext() + const mergedProps = mergeProps(context.getViewCloseTriggerBindings(), props) + + return +} + +export interface CoreDatePickerViewTriggerProps extends ElementRenderProp<"button"> { + /** + * Switch directly to this view when activated. When omitted, the trigger + * toggles to the next view. + */ + view?: DatePickerDateView +} + +export function CoreDatePickerViewTrigger({ + view, + ...props +}: CoreDatePickerViewTriggerProps): ReactElement { + const context = useDatePickerContext() + const viewContext = useDatePickerViewContext() + const mergedProps = mergeProps( + context.getViewTriggerBindings({goToView: view, view: viewContext.view}), + props, + ) + + return +} + +export interface CoreDatePickerContextProps { + /** + * {@link https://react-next.qui.qualcomm.com/render-props Render Prop} + * that provides the current {@link DatePickerApi} context. + * + * @inheritDoc + */ + children: RenderProp +} + +export function CoreDatePickerContext({ + children, +}: CoreDatePickerContextProps): ReactNode { + const context = useDatePickerContext() + return renderProp(children, context) +} diff --git a/packages/frameworks/react-core/src/date-picker/date-picker-context.ts b/packages/frameworks/react-core/src/date-picker/date-picker-context.ts new file mode 100644 index 000000000..162a796ee --- /dev/null +++ b/packages/frameworks/react-core/src/date-picker/date-picker-context.ts @@ -0,0 +1,44 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type { + DatePickerApi, + DatePickerApiDayTableCellProps, + DatePickerApiTableCellProps, + DatePickerApiTableProps, + DatePickerApiViewProps, +} from "@qualcomm-ui/core/date-picker" +import {createGuardedContext} from "@qualcomm-ui/react-core/context" + +export const [DatePickerContextProvider, useDatePickerContext] = + createGuardedContext({ + hookName: "useDatePickerContext", + providerName: "", + strict: true, + }) + +export const [DatePickerViewContextProvider, useDatePickerViewContext] = + createGuardedContext({ + defaultValue: {view: "day"}, + hookName: "useDatePickerViewContext", + providerName: "", + strict: true, + }) + +export const [DatePickerTableContextProvider, useDatePickerTableContext] = + createGuardedContext({ + hookName: "useDatePickerTableContext", + providerName: "", + strict: true, + }) + +export const [ + DatePickerTableCellContextProvider, + useDatePickerTableCellContext, +] = createGuardedContext< + DatePickerApiDayTableCellProps | DatePickerApiTableCellProps +>({ + hookName: "useDatePickerTableCellContext", + providerName: "", + strict: true, +}) diff --git a/packages/frameworks/react-core/src/date-picker/index.ts b/packages/frameworks/react-core/src/date-picker/index.ts new file mode 100644 index 000000000..6f7255ed8 --- /dev/null +++ b/packages/frameworks/react-core/src/date-picker/index.ts @@ -0,0 +1,160 @@ +import type {FunctionComponent} from "react" + +import { + CoreDatePickerClearTrigger, + type CoreDatePickerClearTriggerProps, + CoreDatePickerContent, + type CoreDatePickerContentProps, + CoreDatePickerContext, + type CoreDatePickerContextProps, + CoreDatePickerControl, + type CoreDatePickerControlProps, + CoreDatePickerErrorIndicator, + type CoreDatePickerErrorIndicatorProps, + CoreDatePickerErrorText, + type CoreDatePickerErrorTextProps, + CoreDatePickerHint, + type CoreDatePickerHintProps, + CoreDatePickerInput, + type CoreDatePickerInputProps, + CoreDatePickerLabel, + type CoreDatePickerLabelProps, + CoreDatePickerNextTrigger, + type CoreDatePickerNextTriggerProps, + CoreDatePickerPositioner, + type CoreDatePickerPositionerProps, + CoreDatePickerPresets, + type CoreDatePickerPresetsProps, + CoreDatePickerPresetsTrigger, + type CoreDatePickerPresetsTriggerProps, + CoreDatePickerPresetTrigger, + type CoreDatePickerPresetTriggerProps, + CoreDatePickerPrevTrigger, + type CoreDatePickerPrevTriggerProps, + CoreDatePickerRangeText, + type CoreDatePickerRangeTextProps, + CoreDatePickerRoot, + type CoreDatePickerRootProps, + CoreDatePickerTable, + CoreDatePickerTableBody, + type CoreDatePickerTableBodyProps, + CoreDatePickerTableCell, + type CoreDatePickerTableCellProps, + CoreDatePickerTableCellTrigger, + type CoreDatePickerTableCellTriggerProps, + CoreDatePickerTableHead, + type CoreDatePickerTableHeadProps, + CoreDatePickerTableHeader, + type CoreDatePickerTableHeaderProps, + type CoreDatePickerTableProps, + CoreDatePickerTableRow, + type CoreDatePickerTableRowProps, + CoreDatePickerTrigger, + type CoreDatePickerTriggerProps, + CoreDatePickerView, + CoreDatePickerViewCloseTrigger, + type CoreDatePickerViewCloseTriggerProps, + CoreDatePickerViewControl, + type CoreDatePickerViewControlProps, + type CoreDatePickerViewProps, + CoreDatePickerViewTrigger, + type CoreDatePickerViewTriggerProps, +} from "./core-date-picker.js" + +export * from "./date-picker-context.js" + +export type { + CoreDatePickerClearTriggerProps, + CoreDatePickerContentProps, + CoreDatePickerContextProps, + CoreDatePickerControlProps, + CoreDatePickerErrorIndicatorProps, + CoreDatePickerErrorTextProps, + CoreDatePickerHintProps, + CoreDatePickerInputProps, + CoreDatePickerLabelProps, + CoreDatePickerNextTriggerProps, + CoreDatePickerPositionerProps, + CoreDatePickerPresetsProps, + CoreDatePickerPresetsTriggerProps, + CoreDatePickerPresetTriggerProps, + CoreDatePickerPrevTriggerProps, + CoreDatePickerRangeTextProps, + CoreDatePickerRootProps, + CoreDatePickerTableBodyProps, + CoreDatePickerTableCellProps, + CoreDatePickerTableCellTriggerProps, + CoreDatePickerTableHeadProps, + CoreDatePickerTableHeaderProps, + CoreDatePickerTableProps, + CoreDatePickerTableRowProps, + CoreDatePickerTriggerProps, + CoreDatePickerViewCloseTriggerProps, + CoreDatePickerViewControlProps, + CoreDatePickerViewProps, + CoreDatePickerViewTriggerProps, +} + +interface CoreDatePickerComponent { + ClearTrigger: FunctionComponent + Content: FunctionComponent + Context: FunctionComponent + Control: FunctionComponent + ErrorIndicator: FunctionComponent + ErrorText: FunctionComponent + Hint: FunctionComponent + Input: FunctionComponent + Label: FunctionComponent + NextTrigger: FunctionComponent + Positioner: FunctionComponent + Presets: FunctionComponent + PresetsTrigger: FunctionComponent + PresetTrigger: FunctionComponent + PrevTrigger: FunctionComponent + RangeText: FunctionComponent + Root: FunctionComponent + Table: FunctionComponent + TableBody: FunctionComponent + TableCell: FunctionComponent + TableCellTrigger: FunctionComponent + TableHead: FunctionComponent + TableHeader: FunctionComponent + TableRow: FunctionComponent + Trigger: FunctionComponent + View: FunctionComponent + ViewCloseTrigger: FunctionComponent + ViewControl: FunctionComponent + ViewTrigger: FunctionComponent +} + +export const CoreDatePicker: CoreDatePickerComponent = { + ClearTrigger: CoreDatePickerClearTrigger, + Content: CoreDatePickerContent, + Context: CoreDatePickerContext, + Control: CoreDatePickerControl, + ErrorIndicator: CoreDatePickerErrorIndicator, + ErrorText: CoreDatePickerErrorText, + Hint: CoreDatePickerHint, + Input: CoreDatePickerInput, + Label: CoreDatePickerLabel, + NextTrigger: CoreDatePickerNextTrigger, + Positioner: CoreDatePickerPositioner, + Presets: CoreDatePickerPresets, + PresetsTrigger: CoreDatePickerPresetsTrigger, + PresetTrigger: CoreDatePickerPresetTrigger, + PrevTrigger: CoreDatePickerPrevTrigger, + RangeText: CoreDatePickerRangeText, + Root: CoreDatePickerRoot, + Table: CoreDatePickerTable, + TableBody: CoreDatePickerTableBody, + TableCell: CoreDatePickerTableCell, + TableCellTrigger: CoreDatePickerTableCellTrigger, + TableHead: CoreDatePickerTableHead, + TableHeader: CoreDatePickerTableHeader, + TableRow: CoreDatePickerTableRow, + Trigger: CoreDatePickerTrigger, + View: CoreDatePickerView, + ViewCloseTrigger: CoreDatePickerViewCloseTrigger, + ViewControl: CoreDatePickerViewControl, + ViewTrigger: CoreDatePickerViewTrigger, +} diff --git a/packages/frameworks/react-core/src/machine/machine.spec.tsx b/packages/frameworks/react-core/src/machine/machine.spec.tsx index 7bd710abe..0d9e94493 100644 --- a/packages/frameworks/react-core/src/machine/machine.spec.tsx +++ b/packages/frameworks/react-core/src/machine/machine.spec.tsx @@ -686,6 +686,120 @@ describe("basic", () => { .toHaveAttribute("aria-labelledby", "4") }) + test.for([ + ["positional overload", "positional"], + ["object overload", "object"], + ] as const)( + "ids: handing a part over to a different element keeps the new id (%s)", + async ([, overload]) => { + interface Schema extends MachineSchema { + ids: { + trigger: string + } + state: "idle" + } + + const machineConfig = createMachine({ + ids: ({bindableId}) => ({ + trigger: bindableId(), + }), + initialState() { + return "idle" + }, + states: { + idle: {}, + }, + }) + + const observedIds: (string | undefined)[] = [] + + function createApi(machine: Machine) { + const {scope} = machine + return { + getRegisteredId: () => scope.ids.get("trigger"), + getTriggerProps: ( + id: string, + onDestroy: (callback: () => void) => void, + ) => { + if (overload === "object") { + scope.ids.register("trigger", {id, onDestroy}) + } else { + scope.ids.register("trigger", id, onDestroy) + } + return {id} + }, + } + } + + const ApiContext = createContext | null>( + null, + ) + + const text = { + button: "ButtonTrigger", + field: "FieldTrigger", + swap: "Swap", + } + + function ButtonTrigger(): ReactElement { + const api = useContext(ApiContext) + const onDestroy = useOnDestroy() + + return ( + + ) + } + + function FieldTrigger(): ReactElement { + const api = useContext(ApiContext) + const onDestroy = useOnDestroy() + + return ( +
+ {text.field} +
+ ) + } + + function TestComponent() { + const [asField, setAsField] = useState(false) + const machine = useMachine(machineConfig) + const api = createApi(machine) + + observedIds.push(api.getRegisteredId()) + + return ( + + {asField ? : } + + + + ) + } + + await render() + + observedIds.length = 0 + await page.getByText(text.swap).click() + await expect.element(page.getByText(text.field)).toBeInTheDocument() + + expect(observedIds.at(-1)).toBe("field-trigger") + // the outgoing element must never blank out the incoming registration + expect(observedIds).not.toContain(undefined) + + observedIds.length = 0 + await page.getByText(text.swap).click() + await expect.element(page.getByText(text.button)).toBeInTheDocument() + + expect(observedIds.at(-1)).toBe("button-trigger") + expect(observedIds).not.toContain(undefined) + }, + ) + test("ids: collection", async () => { interface Schema extends MachineSchema { ids: { diff --git a/packages/frameworks/react-core/src/machine/use-machine.ts b/packages/frameworks/react-core/src/machine/use-machine.ts index cb36d32c6..37a6d20cc 100644 --- a/packages/frameworks/react-core/src/machine/use-machine.ts +++ b/packages/frameworks/react-core/src/machine/use-machine.ts @@ -86,6 +86,21 @@ export function useMachine( return idsRef.current?.[key].get() }, register(key, valueOrParams, onDestroy?: any) { + /** + * A replacement element registers during render, before the outgoing + * element's cleanup runs at commit. Clearing unconditionally would + * discard the newer registration. + */ + const clearIfCurrentId = (id: unknown) => () => { + if (idsRef.current?.[key].get() !== id) { + return + } + idsRef.current?.[key].set(undefined, { + cleanup: true, + immediate: true, + }) + } + if ( typeof valueOrParams === "object" && valueOrParams !== null && @@ -93,20 +108,10 @@ export function useMachine( ) { const params = valueOrParams idsRef.current?.[key].set(params.id) - params.onDestroy?.(() => - idsRef.current?.[key].set(undefined, { - cleanup: true, - immediate: true, - }), - ) + params.onDestroy?.(clearIfCurrentId(params.id)) } else { idsRef.current?.[key].set(valueOrParams) - onDestroy?.(() => - idsRef.current?.[key].set(undefined, { - cleanup: true, - immediate: true, - }), - ) + onDestroy?.(clearIfCurrentId(valueOrParams)) } }, set(key, value) { diff --git a/packages/frameworks/react-mdx/src/component-explorer/component-explorer-base.tsx b/packages/frameworks/react-mdx/src/component-explorer/component-explorer-base.tsx index 4910f5097..1b746af33 100644 --- a/packages/frameworks/react-mdx/src/component-explorer/component-explorer-base.tsx +++ b/packages/frameworks/react-mdx/src/component-explorer/component-explorer-base.tsx @@ -124,22 +124,17 @@ export function ComponentExplorerBase({ ), ) - if (targetElements.length === 0) { - setHighlightRects([]) - return - } - const previewRect = previewElement.getBoundingClientRect() const highlightOffset = 4 - const rects = targetElements.map((targetElement) => { - const elementRect = targetElement.getBoundingClientRect() - return { + const rects = targetElements + .map((targetElement) => targetElement.getBoundingClientRect()) + .filter((elementRect) => elementRect.width > 0 && elementRect.height > 0) + .map((elementRect) => ({ height: elementRect.height + highlightOffset * 2, left: elementRect.left - previewRect.left - highlightOffset, top: elementRect.top - previewRect.top - highlightOffset, width: elementRect.width + highlightOffset * 2, - } - }) + })) setHighlightRects(rects) }, [hoveredPart, scope]) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-actions.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-actions.spec.tsx new file mode 100644 index 000000000..1266b6ae4 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-actions.spec.tsx @@ -0,0 +1,225 @@ +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const seeded = parseDate("2024-06-15") + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const grid = () => page.getByRole("grid") +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() +const okTrigger = () => page.getByRole("button", {name: "OK"}) +const cancelTrigger = () => page.getByRole("button", {name: "Cancel"}) + +const tests: MultiComponentTestCase>[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("closeOnSelect=false keeps the calendar open after a selection", async () => { + await render(getComponent()) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(grid()).toBeVisible() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("OK closes the calendar and keeps the selection", async () => { + await render(getComponent()) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await okTrigger().click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + }) + }, + }, + { + composite: (props) => ( + + ), + simple: (props) => ( + + ), + testCase: (getComponent) => { + test("Cancel discards the pending selection and restores the value the calendar opened with", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + + await cancelTrigger().click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/10/2024") + expect(onValueChange).toHaveBeenLastCalledWith( + expect.objectContaining({valueAsString: ["06/10/2024"]}), + ) + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("Cancel with no prior value leaves the field empty", async () => { + await render(getComponent()) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await cancelTrigger().click() + + await expect.element(page.getByRole("textbox")).toHaveValue("") + }) + }, + }, + { + composite: (props) => ( + + ), + simple: (props) => ( + + ), + testCase: (getComponent) => { + test("a reopened calendar takes a fresh snapshot, so Cancel restores the previously confirmed value", async () => { + await render(getComponent()) + + await openCalendar() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await okTrigger().click() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + + await openCalendar() + await page.getByRole("gridcell", {name: /June 25, 2024/}).click() + await cancelTrigger().click() + + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + }) + }, + }, +] + +describe("DatePicker - Actions", () => { + runTests(tests) + + test("the actions footer is omitted while closeOnSelect is left at its default", async () => { + await render( + , + ) + await openCalendar() + + await expect.element(grid()).toBeVisible() + await expect.element(okTrigger()).not.toBeInTheDocument() + await expect.element(cancelTrigger()).not.toBeInTheDocument() + }) + + test("multiple mode renders the actions footer so the popover survives each toggle", async () => { + await render( + , + ) + // in multiple mode the field itself is the trigger + await page.getByRole("combobox", {name: "Dates"}).click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(grid()).toBeVisible() + await okTrigger().click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(page.getByText("06/10/2024")).toBeVisible() + await expect.element(page.getByText("06/20/2024")).toBeVisible() + }) + + test("Escape behaves like Cancel, discarding the pending selection", async () => { + await render() + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await userEvent.keyboard("{Escape}") + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(page.getByRole("textbox")).toHaveValue("06/10/2024") + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-api.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-api.spec.tsx new file mode 100644 index 000000000..f3cf5910d --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-api.spec.tsx @@ -0,0 +1,334 @@ +import type {ReactNode} from "react" + +import {afterEach, describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page} from "vitest/browser" + +import type {DatePickerApi} from "@qualcomm-ui/core/date-picker" +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +const seeded = parseDate("2024-06-15") + +/** + * The imperative api is only reachable through the context render prop, so these + * cases cover the methods that have no direct UI affordance. + */ +function WithApi({ + children, + inContent, + ...props +}: Omit, "children"> & { + children?: (api: DatePickerApi) => ReactNode + /** + * Rendered inside `Content`. A button placed outside it counts as an outside + * interaction and would dismiss the popover before its own `onClick` runs. + */ + inContent?: (api: DatePickerApi) => ReactNode +}) { + return ( + + {/* above the control so the downward popover cannot cover the buttons */} + {children ? {children} : null} + + + + + + + + + + + + + {inContent ? ( + {inContent} + ) : null} + + + + + ) +} + +const input = () => page.getByRole("textbox") +const grid = () => page.getByRole("grid") +const act = (name: string) => page.getByRole("button", {name}).click() + +describe("DatePicker - API", () => { + afterEach(() => { + vi.useRealTimers() + }) + + test("selectToday uses the configured time zone", async () => { + vi.setSystemTime(new Date("2024-06-01T00:30:00Z")) + await render( + + {(api) => } + , + ) + + await act("Today") + + await expect.element(input()).toHaveValue("05/31/2024") + }) + + test("selectToday clamps to max when today is out of range", async () => { + vi.setSystemTime(new Date("2024-06-01T12:00:00Z")) + await render( + + {(api) => } + , + ) + + await act("Today") + + await expect.element(input()).toHaveValue("01/31/2020") + }) + + test("setValue constrains the given date to min and max", async () => { + await render( + + {(api) => ( + + )} + , + ) + + await act("Set") + + await expect.element(input()).toHaveValue("06/20/2024") + }) + + test("setValue orders a reversed range instead of storing it as given", async () => { + const onValueChange = vi.fn() + await render( + + {(api) => ( + + )} + , + ) + + await act("Set") + + expect(onValueChange).toHaveBeenCalledWith( + expect.objectContaining({ + valueAsString: ["06/10/2024", "06/20/2024"], + }), + ) + }) + + test("setValue keeps only the first date in single mode", async () => { + const onValueChange = vi.fn() + await render( + + {(api) => ( + + )} + , + ) + + await act("Set") + + expect(onValueChange).toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["06/10/2024"]}), + ) + }) + + test("clearValue empties the selection", async () => { + await render( + + {(api) => } + , + ) + await expect.element(input()).toHaveValue("06/15/2024") + + await act("Wipe") + + await expect.element(input()).toHaveValue("") + }) + + test("setOpen drives the calendar without a trigger click", async () => { + await render( + ( + + )} + > + {(api) => } + , + ) + + await act("Show") + await expect.element(grid()).toBeVisible() + + await act("Hide") + await expect.element(grid()).not.toBeInTheDocument() + }) + + test("setFocusedValue moves the visible month", async () => { + await render( + + + {(api) => ( + + )} + + + + + + + + + + , + ) + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .toBeVisible() + + await act("Jump") + + await expect + .element(page.getByRole("gridcell", {name: /September 5, 2024/})) + .toBeVisible() + await expect + .element(page.getByLabelText(/September 5, 2024/)) + .toHaveAttribute("data-focus") + }) + + test("toggleValue adds then removes a date in multiple mode", async () => { + const onValueChange = vi.fn() + await render( + + {(api) => ( + + )} + , + ) + + await act("Toggle") + expect(onValueChange).toHaveBeenLastCalledWith( + expect.objectContaining({valueAsString: ["06/20/2024"]}), + ) + + await act("Toggle") + expect(onValueChange).toHaveBeenLastCalledWith( + expect.objectContaining({valueAsString: []}), + ) + }) + + test("toggleValue is ignored while readOnly", async () => { + const onValueChange = vi.fn() + await render( + + {(api) => ( + + )} + , + ) + + await act("Toggle") + + expect(onValueChange).not.toHaveBeenCalled() + }) + + test("setTime attaches a time to the selected date and keeps it on reselection", async () => { + let latest: DatePickerApi | undefined + await render( + + {(api) => { + latest = api + return ( + + ) + }} + , + ) + + await act("Set time") + await expect.element(input()).toHaveValue("06/15/2024") + expect(latest?.value[0]).toMatchObject({hour: 9, minute: 30}) + + await act("Change date") + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(input()).toHaveValue("06/20/2024") + expect(latest?.value[0]).toMatchObject({day: 20, hour: 9, minute: 30}) + }) + + test("cancel reverts to the value the calendar opened with", async () => { + await render( + ( + + )} + />, + ) + + await act("Change date") + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect.element(input()).toHaveValue("06/20/2024") + + await act("Discard") + + await expect.element(input()).toHaveValue("06/10/2024") + }) + + test("getRangePresetValue resolves a named preset to a pair of dates", async () => { + vi.setSystemTime(new Date("2024-06-20T12:00:00Z")) + let resolved: string[] = [] + await render( + + {(api) => ( + + )} + , + ) + + await act("Resolve") + + expect(resolved).toEqual(["2024-06-01", "2024-06-20"]) + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-clear.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-clear.spec.tsx new file mode 100644 index 000000000..a55960e8f --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-clear.spec.tsx @@ -0,0 +1,199 @@ +import {afterEach, describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const seeded = parseDate("2024-06-15") + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const clearTrigger = () => + page.getByRole("button", {name: /clear selected dates/i}) + +const tests: MultiComponentTestCase>[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the clear trigger is hidden while nothing is selected", async () => { + await render(getComponent()) + await expect.element(clearTrigger()).not.toBeInTheDocument() + }) + }, + }, + { + composite: (props) => , + simple: (props) => , + testCase: (getComponent) => { + test("the clear trigger empties the field and reports the cleared value", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + + await expect.element(clearTrigger()).toBeVisible() + await clearTrigger().click() + + await expect.element(page.getByRole("textbox")).toHaveValue("") + expect(onValueChange).toHaveBeenCalledWith( + expect.objectContaining({value: [], valueAsString: []}), + ) + await expect.element(clearTrigger()).not.toBeInTheDocument() + }) + }, + }, + { + composite: (props) => , + simple: (props) => , + testCase: (getComponent) => { + test("clearing moves focus to the input", async () => { + await render(getComponent()) + + await clearTrigger().click() + + await expect.element(page.getByRole("textbox")).toHaveFocus() + }) + }, + }, + { + composite: (props) => ( + + ), + simple: (props) => , + testCase: (getComponent) => { + test("the clear trigger is disabled alongside the picker", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + + await expect.element(clearTrigger()).toBeDisabled() + expect(onValueChange).not.toHaveBeenCalled() + }) + }, + }, + { + composite: (props) => ( + + ), + simple: (props) => ( + + ), + testCase: (getComponent) => { + test("the clear trigger empties both ends of a range", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + + await clearTrigger().click() + + await expect + .element(page.getByRole("textbox", {name: /start date/i})) + .toHaveValue("") + await expect + .element(page.getByRole("textbox", {name: /end date/i})) + .toHaveValue("") + expect(onValueChange).toHaveBeenCalledWith( + expect.objectContaining({value: [], valueAsString: []}), + ) + }) + }, + }, +] + +describe("DatePicker - Clear", () => { + afterEach(() => { + vi.useRealTimers() + }) + + runTests(tests) + + test("a readOnly picker keeps the clear trigger inert", async () => { + const onValueChange = vi.fn() + await render( + , + ) + + await clearTrigger().click() + + await expect.element(page.getByRole("textbox")).toHaveValue("06/15/2024") + expect(onValueChange).not.toHaveBeenCalled() + }) + + test("pressing Enter in a cleared input keeps it empty", async () => { + await render() + + const input = page.getByRole("textbox") + await input.fill("06/20/2024") + await userEvent.keyboard("{Enter}") + await expect.element(input).toHaveValue("06/20/2024") + + await clearTrigger().click() + await expect.element(input).toHaveValue("") + + await userEvent.click(input.element()) + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("") + }) + + test("clearing while the calendar is open resets the focused day to today", async () => { + vi.setSystemTime(new Date("2026-07-29T12:00:00Z")) + await render( + , + ) + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await clearTrigger().click() + + await expect.element(page.getByRole("textbox")).toHaveValue("") + await expect + .element(page.getByLabelText(/July 29, 2026/)) + .toHaveAttribute("data-today") + await expect + .element(page.getByLabelText(/July 29, 2026/)) + .toHaveAttribute("data-focus") + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-constraints.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-constraints.spec.tsx new file mode 100644 index 000000000..188b94b80 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-constraints.spec.tsx @@ -0,0 +1,359 @@ +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const seeded = parseDate("2024-06-15") + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +const dayCell = (day: RegExp) => page.getByRole("gridcell", {name: day}) + +/** Cell state and focus live on the trigger inside the `gridcell`. */ +const dayTrigger = (day: string) => page.getByLabelText(day) + +const weekends = (date: {day: number; month: number; year: number}) => { + const weekday = new Date(date.year, date.month - 1, date.day).getDay() + return weekday === 0 || weekday === 6 +} + +const tests: MultiComponentTestCase>[] = [ + { + composite: () => ( + + ), + simple: () => ( + + ), + testCase: (getComponent) => { + test("days outside min and max are marked unselectable", async () => { + await render(getComponent()) + await openCalendar() + + await expect + .element(dayCell(/June 9, 2024/)) + .toHaveAttribute("aria-disabled", "true") + await expect + .element(dayCell(/June 21, 2024/)) + .toHaveAttribute("aria-disabled", "true") + await expect + .element(dayCell(/June 10, 2024/)) + .toHaveAttribute("aria-disabled", "false") + await expect + .element(dayCell(/June 20, 2024/)) + .toHaveAttribute("aria-disabled", "false") + }) + }, + }, + { + composite: (props) => ( + + ), + simple: (props) => ( + + ), + testCase: (getComponent) => { + test("clicking a day outside min and max selects nothing", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + await openCalendar() + + const cell = dayTrigger("June 25, 2024").element() as HTMLElement + cell.click() + + expect(onValueChange).not.toHaveBeenCalled() + await expect.element(page.getByRole("grid")).toBeVisible() + }) + }, + }, + { + composite: (props) => , + simple: (props) => , + testCase: (getComponent) => { + test("unavailable days announce themselves and reject clicks", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + await openCalendar() + + // June 16 2024 is a Sunday + await expect + .element(dayTrigger("June 16, 2024")) + .toHaveAttribute( + "aria-label", + expect.stringContaining("Not available"), + ) + await expect + .element(dayTrigger("June 16, 2024")) + .toHaveAttribute("data-unavailable") + + const sunday = dayTrigger("June 16, 2024").element() as HTMLElement + sunday.click() + expect(onValueChange).not.toHaveBeenCalled() + + await dayCell(/June 17, 2024/).click() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/17/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("Space on an unavailable day does not commit it", async () => { + await render(getComponent()) + await openCalendar() + + // June 15 2024 is a Saturday, and is the day the calendar opens on + await userEvent.keyboard("{ }") + + await expect.element(page.getByRole("textbox")).toHaveValue("") + await expect.element(page.getByRole("grid")).toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("Enter on an unavailable day does not commit it", async () => { + await render(getComponent()) + await openCalendar() + + // June 15 2024 is a Saturday, and is the day the calendar opens on + await userEvent.keyboard("{Enter}") + + await expect.element(page.getByRole("textbox")).toHaveValue("") + await expect.element(page.getByRole("grid")).toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("typing an unavailable date reverts the input to the last committed value", async () => { + await render(getComponent()) + + const input = page.getByRole("textbox") + await input.fill("06/17/2024") + await userEvent.keyboard("{Enter}") + await expect.element(input).toHaveValue("06/17/2024") + + await input.fill("06/16/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("06/17/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("days spilling in from the neighbouring months are not selectable by default", async () => { + await render(getComponent()) + await openCalendar() + + // June 2024 starts on a Saturday, so late May fills the first row + await expect + .element(dayTrigger("May 26, 2024")) + .toHaveAttribute("data-outside-range") + await expect + .element(dayCell(/May 26, 2024/)) + .toHaveAttribute("aria-disabled", "true") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("outsideDaySelectable lets a spilled-in day be picked", async () => { + await render(getComponent()) + await openCalendar() + + await dayCell(/May 26, 2024/).click() + + await expect + .element(page.getByRole("textbox")) + .toHaveValue("05/26/2024") + }) + }, + }, + { + composite: () => ( + + ), + simple: () => ( + + ), + testCase: (getComponent) => { + test("a defaultValue beyond max is pulled back to max", async () => { + await render(getComponent()) + + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + }) + }, + }, +] + +describe("DatePicker - Constraints", () => { + runTests(tests) + + test("today's cell is flagged for assistive tech", async () => { + await render() + await expect.element(page.getByRole("grid")).toBeVisible() + + const grid = page.getByRole("grid").element() + const today = grid.querySelectorAll('[aria-current="date"]') + + expect(today).toHaveLength(1) + expect(today[0].querySelector("[data-today]")).not.toBeNull() + }) + + test("an unavailable day that is also selected still reads as unavailable", async () => { + await render( + , + ) + await openCalendar() + + await expect + .element(dayTrigger("June 15, 2024")) + .toHaveAttribute("aria-label", expect.stringContaining("Not available")) + await expect + .element(dayTrigger("June 15, 2024")) + .toHaveAttribute("data-selected") + }) + + test("fixedWeeks always renders six week rows", async () => { + // February 2021 fits in exactly four rows without padding + await render( + , + ) + + await expect.element(page.getByRole("grid")).toBeVisible() + expect( + page.getByRole("grid").element().querySelectorAll("tbody tr"), + ).toHaveLength(6) + }) + + test("startOfWeek shifts the weekday header order", async () => { + const weekdayNames = () => + Array.from( + page.getByRole("grid").element().querySelectorAll("thead th"), + ).map((cell) => cell.getAttribute("aria-label")) + + const {unmount} = await render( + , + ) + await expect.element(page.getByRole("grid")).toBeVisible() + expect(weekdayNames()).toEqual([ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]) + await unmount() + + await render( + , + ) + await expect.element(page.getByRole("grid")).toBeVisible() + expect(weekdayNames()).toEqual([ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ]) + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-controlled.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-controlled.spec.tsx new file mode 100644 index 000000000..b2cc58619 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-controlled.spec.tsx @@ -0,0 +1,313 @@ +import {useState} from "react" + +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import type {DateValue} from "@qualcomm-ui/core/date-picker" +import { + DatePicker, + type DatePickerProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +const seeded = parseDate("2024-06-15") +const grid = () => page.getByRole("grid") +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +describe("DatePicker - Controlled", () => { + test("a frozen value prop ignores a selection made in the calendar", async () => { + const onValueChange = vi.fn() + await render( + , + ) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + expect(onValueChange).toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["06/20/2024"]}), + ) + await expect.element(page.getByRole("textbox")).toHaveValue("06/15/2024") + }) + + test("a value prop driven by the parent updates the field and the selected cell", async () => { + function Controlled() { + const [value, setValue] = useState<(DateValue | null)[]>([seeded]) + return ( + <> + + + + ) + } + + await render() + await expect.element(page.getByRole("textbox")).toHaveValue("06/15/2024") + + await page.getByRole("button", {name: "Jump"}).click() + + await expect.element(page.getByRole("textbox")).toHaveValue("06/20/2024") + + await openCalendar() + await expect.element(grid()).toBeVisible() + await expect + .element(page.getByLabelText(/June 20, 2024/)) + .toHaveAttribute("data-selected") + await expect + .element(page.getByLabelText(/June 15, 2024/)) + .not.toHaveAttribute("data-selected") + }) + + test("a frozen open prop keeps the calendar closed while still reporting the intent", async () => { + const onOpenChange = vi.fn() + await render( + , + ) + + await openCalendar() + + expect(onOpenChange).toHaveBeenCalledWith( + expect.objectContaining({open: true}), + ) + await expect.element(grid()).not.toBeInTheDocument() + }) + + test("an open prop driven by the parent opens and closes the calendar", async () => { + function Controlled() { + const [open, setOpen] = useState(false) + return ( + setOpen(details.open)} + open={open} + /> + ) + } + + await render() + await expect.element(grid()).not.toBeInTheDocument() + + await openCalendar() + await expect.element(grid()).toBeVisible() + + await userEvent.keyboard("{Escape}") + await expect.element(grid()).not.toBeInTheDocument() + }) + + test("a controlled open picker still commits the selected date", async () => { + function Controlled() { + const [open, setOpen] = useState(false) + return ( + setOpen(details.open)} + open={open} + /> + ) + } + + await render() + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByRole("textbox")).toHaveValue("06/20/2024") + await expect.element(grid()).not.toBeInTheDocument() + }) + + test("clicking the input keeps focus in the input, even when open is controlled", async () => { + function Controlled() { + const [open, setOpen] = useState(false) + return ( + setOpen(details.open)} + open={open} + openOnClick + /> + ) + } + + await render() + + await page.getByRole("textbox").click() + + await expect.element(grid()).toBeVisible() + await expect.element(page.getByRole("textbox")).toHaveFocus() + }) + + test("a controlled open still moves focus into the grid when opened by the trigger", async () => { + function Controlled() { + const [open, setOpen] = useState(false) + return ( + setOpen(details.open)} + open={open} + /> + ) + } + + await render() + + await openCalendar() + + await expect.element(grid()).toBeVisible() + await expect.element(page.getByLabelText(/June 15, 2024/)).toHaveFocus() + }) + + test("a controlled close keeps focus in the picker on every closing path", async () => { + function Controlled({...props}: Partial) { + const [open, setOpen] = useState(false) + return ( + setOpen(details.open)} + open={open} + {...props} + /> + ) + } + + const input = page.getByRole("textbox") + + const {unmount} = await render() + await openCalendar() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(input).toHaveFocus() + await unmount() + + const second = await render() + await openCalendar() + await userEvent.keyboard("{Enter}") + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(input).toHaveFocus() + await second.unmount() + + await render() + await openCalendar() + await userEvent.keyboard("{Escape}") + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(input).toHaveFocus() + }) + + test("a controlled selection does not leak focus restoration into the next close", async () => { + function Controlled() { + const [open, setOpen] = useState(false) + return ( + setOpen(details.open)} + open={open} + /> + ) + } + + await render() + + await openCalendar() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect.element(grid()).not.toBeInTheDocument() + + await openCalendar() + await page.getByRole("button", {name: /close calendar/i}).click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toHaveFocus() + }) + + test("a controlled close after a preset keeps focus in the picker", async () => { + function Controlled() { + const [open, setOpen] = useState(false) + return ( + setOpen(details.open)} + open={open} + presets={[{label: "Next 7 days", value: "next7Days"}]} + selectionMode="range" + /> + ) + } + + await render() + await openCalendar() + + await page.getByRole("button", {name: /show presets/i}).click() + await page.getByText("Next 7 days").click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect + .element(page.getByRole("textbox", {name: /end date/i})) + .toHaveFocus() + }) + + test("a frozen view prop pins the calendar to that level", async () => { + await render( + , + ) + await openCalendar() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", "calendar year") + + await page.getByRole("button", {name: /return to calendar/i}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", "calendar year") + }) + + test("a frozen focusedValue prop ignores keyboard navigation", async () => { + const onFocusChange = vi.fn() + await render( + , + ) + await openCalendar() + + await userEvent.keyboard("{ArrowRight}") + + expect(onFocusChange).toHaveBeenCalledWith( + expect.objectContaining({ + focusedValue: expect.objectContaining({day: 16}), + }), + ) + await expect.element(page.getByLabelText(/June 15, 2024/)).toHaveFocus() + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-dismiss.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-dismiss.spec.tsx new file mode 100644 index 000000000..44973873c --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-dismiss.spec.tsx @@ -0,0 +1,166 @@ +import type {ReactNode} from "react" + +import {describe, expect, test} from "vitest" +import {render} from "vitest-browser-react" +import {page} from "vitest/browser" + +import {DatePicker, parseDate} from "@qualcomm-ui/react/date-picker" + +const seeded = parseDate("2024-06-15") + +const grid = () => page.getByRole("grid") +const trigger = () => + page.getByRole("button", {name: /(?:choose|change) date/i}) +const openCalendar = () => trigger().click() + +/** + * `trackDismissableElement` distinguishes focusable from non-focusable outside + * targets, so both are needed to cover the focus-restore branch. They sit above + * the picker because the popover opens downwards and would otherwise cover them. + */ +function Outside({children}: {children: ReactNode}) { + return ( + <> + +
Plain outside
+ {children} + + ) +} + +describe("DatePicker - Dismiss", () => { + test("clicking outside closes the calendar and keeps the committed value", async () => { + await render( + + + , + ) + await openCalendar() + await expect.element(grid()).toBeVisible() + + await page.getByRole("button", {name: "Focusable outside"}).click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(page.getByRole("textbox")).toHaveValue("06/10/2024") + }) + + test("clicking outside discards a pending selection when closeOnSelect is false", async () => { + await render( + + + , + ) + await openCalendar() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect.element(page.getByRole("textbox")).toHaveValue("06/20/2024") + + await page.getByRole("button", {name: "Focusable outside"}).click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(page.getByRole("textbox")).toHaveValue("06/10/2024") + }) + + test("dismissing onto a non-focusable target returns focus to the trigger", async () => { + await render( + + + , + ) + await openCalendar() + + await page.getByText("Plain outside").click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(trigger()).toHaveFocus() + }) + + test("a focusable dismiss keeps focus even after an earlier non-focusable one", async () => { + await render( + + + , + ) + const outsideButton = page.getByRole("button", {name: "Focusable outside"}) + + await openCalendar() + await page.getByText("Plain outside").click() + await expect.element(trigger()).toHaveFocus() + + await openCalendar() + await outsideButton.click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(outsideButton).toHaveFocus() + }) + + test("dismissing onto a focusable target leaves focus where the user put it", async () => { + await render( + + + , + ) + await openCalendar() + + const outsideButton = page.getByRole("button", {name: "Focusable outside"}) + await outsideButton.click() + + await expect.element(grid()).not.toBeInTheDocument() + await expect.element(outsideButton).toHaveFocus() + }) + + test("clicking the input does not count as an outside interaction", async () => { + await render( + + + , + ) + await openCalendar() + + await page.getByRole("textbox").click() + + await expect.element(grid()).toBeVisible() + }) + + test("clicking inside the calendar does not close it", async () => { + await render( + + + , + ) + await openCalendar() + + await page.getByRole("button", {name: /switch to next month/i}).click() + + await expect.element(grid()).toBeVisible() + }) + + test("the inline variant ignores outside interaction entirely", async () => { + await render( + + + , + ) + await expect.element(grid()).toBeVisible() + + await page.getByRole("button", {name: "Focusable outside"}).click() + + await expect.element(grid()).toBeVisible() + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-focus-trap.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-focus-trap.spec.tsx new file mode 100644 index 000000000..39ce0388c --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-focus-trap.spec.tsx @@ -0,0 +1,201 @@ +import {describe, expect, test} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const seeded = parseDate("2024-06-15") + +const trailingLabel = "After the picker" + +function Composite(props: Partial) { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + <> + + + + ) +} + +const grid = () => page.getByRole("grid") +const input = () => page.getByRole("textbox") +const trailingButton = () => page.getByRole("button", {name: trailingLabel}) +const focusedCell = () => + page.getByRole("button", {exact: false, name: /June 15, 2024/}) + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +const tests: MultiComponentTestCase>[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("Tab from the calendar returns to the input instead of leaving the picker", async () => { + await render(getComponent()) + + await openCalendar() + await expect.element(focusedCell()).toHaveFocus() + + await userEvent.tab() + + await expect.element(input()).toHaveFocus() + await expect.element(grid()).toBeVisible() + await expect.element(trailingButton()).not.toHaveFocus() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("Shift+Tab from the input returns to the calendar instead of dismissing it", async () => { + await render(getComponent()) + + await openCalendar() + await userEvent.tab() + await expect.element(input()).toHaveFocus() + + await userEvent.tab({shift: true}) + + await expect.element(focusedCell()).toHaveFocus() + await expect.element(grid()).toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("repeated Tab presses never reach content outside the picker", async () => { + await render(getComponent()) + + await openCalendar() + + for (let i = 0; i < 8; i++) { + await userEvent.tab() + await expect.element(trailingButton()).not.toHaveFocus() + } + + await expect.element(grid()).toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("opening from the input keeps focus in the input and still allows typing", async () => { + await render(getComponent()) + + await input().click() + await expect.element(grid()).toBeVisible() + await expect.element(input()).toHaveFocus() + + await userEvent.keyboard("06/20/2024") + + await expect.element(input()).toHaveValue("06/20/2024") + await expect.element(input()).toHaveFocus() + }) + }, + }, +] + +describe("DatePicker - Focus trap", () => { + runTests(tests) + + test("containment survives the presets panel making the paging triggers inert", async () => { + await render( + <> + + + , + ) + + await openCalendar() + await page.getByRole("button", {name: /show presets/i}).click() + await expect.element(page.getByText("Next 7 days")).toBeVisible() + + await page.getByRole("textbox", {name: /start date/i}).click() + await userEvent.tab({shift: true}) + + await expect.element(page.getByRole("button", {name: "OK"})).toHaveFocus() + await expect.element(page.getByText("Next 7 days")).toBeVisible() + }) + + test("an inline calendar does not trap focus", async () => { + await render( + <> + + + , + ) + + await focusedCell().click() + await expect.element(focusedCell()).toHaveFocus() + + await userEvent.tab() + + await expect.element(trailingButton()).toHaveFocus() + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-form.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-form.spec.tsx new file mode 100644 index 000000000..9c1308b95 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-form.spec.tsx @@ -0,0 +1,362 @@ +import type {FormEvent} from "react" + +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {DatePicker, parseDate} from "@qualcomm-ui/react/date-picker" + +const seeded = parseDate("2024-06-15") + +function Form({ + children, + onSubmit, +}: { + children: React.ReactNode + onSubmit?: (data: FormData) => void +}) { + function handleSubmit(event: FormEvent) { + event.preventDefault() + onSubmit?.(new FormData(event.currentTarget)) + } + + return ( +
+ {children} + + +
+ ) +} + +const submit = () => page.getByRole("button", {name: "Submit"}).click() +const reset = () => page.getByRole("button", {name: "Reset"}).click() + +const isRootDisabled = () => + document + .querySelector('[data-date-picker-part="root"]') + ?.hasAttribute("data-disabled") + +describe("DatePicker - Form", () => { + test("a single picker submits its formatted value under the given name", async () => { + const onSubmit = vi.fn<(data: FormData) => void>() + await render( +
+ + , + ) + + await submit() + + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit.mock.calls[0][0].getAll("departure")).toEqual([ + "06/15/2024", + ]) + }) + + test("a range picker submits both endpoints under the same name", async () => { + const onSubmit = vi.fn<(data: FormData) => void>() + await render( +
+ + , + ) + + await submit() + + expect(onSubmit.mock.calls[0][0].getAll("trip")).toEqual([ + "06/10/2024", + "06/20/2024", + ]) + }) + + test("multiple mode submits one entry per selected date", async () => { + const onSubmit = vi.fn<(data: FormData) => void>() + await render( +
+ + , + ) + + await submit() + + expect(onSubmit.mock.calls[0][0].getAll("dates")).toEqual([ + "06/10/2024", + "06/20/2024", + ]) + }) + + test("multiple mode submits an empty entry when nothing is selected", async () => { + const onSubmit = vi.fn<(data: FormData) => void>() + await render( +
+ + , + ) + + await submit() + + expect(onSubmit.mock.calls[0][0].getAll("dates")).toEqual([""]) + }) + + test("required blocks submission of an empty single picker", async () => { + const onSubmit = vi.fn<(data: FormData) => void>() + await render( +
+ + , + ) + + await submit() + + expect(onSubmit).not.toHaveBeenCalled() + }) + + test("required blocks submission of an empty multiple picker", async () => { + const onSubmit = vi.fn<(data: FormData) => void>() + await render( +
+ + , + ) + + await submit() + + expect(onSubmit).not.toHaveBeenCalled() + }) + + test("a picked date satisfies required", async () => { + const onSubmit = vi.fn<(data: FormData) => void>() + await render( +
+ + , + ) + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await submit() + + expect(onSubmit.mock.calls[0][0].getAll("departure")).toEqual([ + "06/20/2024", + ]) + }) + + test("a disabled picker is left out of the submitted data", async () => { + const onSubmit = vi.fn<(data: FormData) => void>() + await render( +
+ + , + ) + + await submit() + + expect(onSubmit.mock.calls[0][0].getAll("departure")).toEqual([]) + }) + + test("resetting the form restores the initial value", async () => { + const onValueChange = vi.fn() + await render( +
+ + , + ) + + const input = page.getByRole("textbox") + await input.fill("07/15/2024") + await userEvent.keyboard("{Enter}") + await expect.element(input).toHaveValue("07/15/2024") + + onValueChange.mockClear() + await reset() + + await expect.element(input).toHaveValue("06/10/2024") + expect(onValueChange).toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["06/10/2024"]}), + ) + }) + + test("resetting the form clears a picker that had no initial value", async () => { + await render( +
+ + , + ) + + const input = page.getByRole("textbox") + await input.fill("07/15/2024") + await userEvent.keyboard("{Enter}") + await expect.element(input).toHaveValue("07/15/2024") + + await reset() + + await expect.element(input).toHaveValue("") + }) + + test("resetting the form restores both endpoints of a range", async () => { + await render( +
+ + , + ) + + const startInput = page.getByRole("textbox", {name: /start date/i}) + const endInput = page.getByRole("textbox", {name: /end date/i}) + + await startInput.fill("06/05/2024") + await userEvent.keyboard("{Enter}") + await expect.element(startInput).toHaveValue("06/05/2024") + + await reset() + + await expect.element(startInput).toHaveValue("06/10/2024") + await expect.element(endInput).toHaveValue("06/20/2024") + }) + + test("resetting the form restores which range endpoint is next", async () => { + await render( +
+ + , + ) + + const startInput = page.getByRole("textbox", {name: /start date/i}) + const endInput = page.getByRole("textbox", {name: /end date/i}) + + await startInput.fill("06/10/2024") + await userEvent.keyboard("{Enter}") + await expect.element(startInput).toHaveValue("06/10/2024") + + await reset() + await expect.element(startInput).toHaveValue("") + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 12, 2024/}).click() + + await expect.element(startInput).toHaveValue("06/12/2024") + await expect.element(endInput).toHaveValue("") + }) + + test("resetting an end-only range on an inline picker leaves the start slot next", async () => { + const onValueChange = vi.fn() + await render( +
+ + , + ) + + await reset() + onValueChange.mockClear() + + await page.getByRole("gridcell", {name: /June 12, 2024/}).click() + + await expect + .poll(() => onValueChange.mock.calls.at(-1)?.[0].valueAsString) + .toEqual(["06/12/2024", "06/20/2024"]) + }) + + test("an ancestor disabled fieldset puts the picker in its disabled state", async () => { + await render( +
+
+ +
+
, + ) + + await expect.poll(() => isRootDisabled()).toBe(true) + }) + + test("re-enabling the fieldset returns the picker to its enabled state", async () => { + await render( +
+
+ +
+
, + ) + + await expect.poll(() => isRootDisabled()).toBe(true) + + document.querySelector("fieldset")!.removeAttribute("disabled") + + await expect.poll(() => isRootDisabled()).toBe(false) + }) + + test("an ancestor disabled fieldset disables an inline picker", async () => { + await render( +
+
+ +
+
, + ) + + await expect.poll(() => isRootDisabled()).toBe(true) + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-input.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-input.spec.tsx new file mode 100644 index 000000000..dfa7cdce1 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-input.spec.tsx @@ -0,0 +1,196 @@ +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + type DateValue, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return +} + +const tests: MultiComponentTestCase>[] = [ + { + composite: (props) => , + simple: (props) => , + testCase: (getComponent) => { + test("typing a valid date and pressing Enter commits it", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + + const input = page.getByRole("textbox") + await input.fill("06/20/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("06/20/2024") + expect(onValueChange).toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["06/20/2024"]}), + ) + }) + }, + }, + { + composite: (props) => ( + + ), + simple: (props) => ( + + ), + testCase: (getComponent) => { + test("an invalid date restores the last committed value", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + + const input = page.getByRole("textbox") + await input.fill("13/45/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("06/15/2024") + expect(onValueChange).not.toHaveBeenCalled() + }) + }, + }, + { + composite: (props) => ( + + ), + simple: (props) => ( + + ), + testCase: (getComponent) => { + test("custom parse and format functions drive the input round trip", async () => { + const format = vi.fn((date: DateValue) => + date.toString().replaceAll("-", "/"), + ) + const parse = vi.fn((value: string) => + value === "2024/06/20" ? parseDate("2024-06-20") : undefined, + ) + await render( + getComponent({ + format, + locale: "en-GB", + parse, + timeZone: "Europe/Paris", + }), + ) + + const input = page.getByRole("textbox") + await expect.element(input).toHaveValue("2024/06/15") + + await input.fill("2024/06/20") + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("2024/06/20") + expect(parse).toHaveBeenCalledWith("2024/06/20", { + locale: "en-GB", + timeZone: "Europe/Paris", + }) + expect(format).toHaveBeenCalledWith( + expect.objectContaining({day: 20, month: 6, year: 2024}), + {locale: "en-GB", timeZone: "Europe/Paris"}, + ) + }) + }, + }, + { + composite: (props) => ( + + ), + simple: (props) => ( + + ), + testCase: (getComponent) => { + test("clearing the input clears the committed value", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + + const input = page.getByRole("textbox") + await expect.element(input).toHaveValue("06/15/2024") + + await input.fill("") + await userEvent.keyboard("{Enter}") + + await expect.element(input).toHaveValue("") + expect(onValueChange).toHaveBeenCalledWith( + expect.objectContaining({value: [], valueAsString: []}), + ) + }) + }, + }, + { + composite: () => ( + + ), + simple: () => ( + + ), + testCase: (getComponent) => { + test("constrains an above-max date to the max on blur", async () => { + await render(getComponent()) + + const input = page.getByRole("textbox") + await input.fill("06/25/2024") + await userEvent.keyboard("{Tab}") + + await expect.element(input).toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => ( + + ), + simple: () => ( + + ), + testCase: (getComponent) => { + test("constrains a below-min date to the min on blur", async () => { + await render(getComponent()) + + const input = page.getByRole("textbox") + await input.fill("06/05/2024") + await userEvent.keyboard("{Tab}") + + await expect.element(input).toHaveValue("06/10/2024") + }) + }, + }, +] + +describe("DatePicker - Input", () => { + runTests(tests) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-locale.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-locale.spec.tsx new file mode 100644 index 000000000..ad7d6a80f --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-locale.spec.tsx @@ -0,0 +1,192 @@ +import {useState} from "react" + +import {describe, expect, test} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {DatePicker, parseDate} from "@qualcomm-ui/react/date-picker" + +const seeded = parseDate("2024-06-15") +const input = () => page.getByRole("textbox") + +describe("DatePicker - Locale", () => { + test("the placeholder follows the locale's field order and separators", async () => { + await render() + + await expect.element(input()).toHaveAttribute("placeholder", "dd.mm.yyyy") + }) + + test("an explicit placeholder overrides the locale-derived one", async () => { + await render( + , + ) + + await expect.element(input()).toHaveAttribute("placeholder", "Pick a day") + }) + + test("the input describes the expected format, following the locale", async () => { + await render() + + await expect + .element(input()) + .toHaveAttribute("aria-description", "Date format: dd.mm.yyyy") + }) + + test("an explicit placeholder does not change the described format", async () => { + await render( + , + ) + + await expect + .element(input()) + .toHaveAttribute("aria-description", "Date format: dd.mm.yyyy") + }) + + test("translations override the described format", async () => { + await render( + `Datumsformat: ${format}`, + }} + />, + ) + + await expect + .element(input()) + .toHaveAttribute("aria-description", "Datumsformat: dd.mm.yyyy") + }) + + test("the committed value is formatted for the locale", async () => { + await render( + , + ) + + await expect.element(input()).toHaveValue("15.06.2024") + }) + + test("typing in the locale's own format commits the date", async () => { + await render() + + await input().fill("20.06.2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input()).toHaveValue("20.06.2024") + }) + + test("a date typed in another locale's format is rejected, not reinterpreted", async () => { + await render( + , + ) + await expect.element(input()).toHaveValue("15.06.2024") + + await input().fill("10/06/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input()).toHaveValue("15.06.2024") + }) + + test("a date whose month is out of range for the locale's field order is rejected", async () => { + await render( + , + ) + + await input().fill("06.20.2024") + await userEvent.keyboard("{Enter}") + + await expect.element(input()).toHaveValue("15.06.2024") + }) + + test("the weekday header starts on the locale's first day of the week", async () => { + await render( + , + ) + await expect.element(page.getByRole("grid")).toBeVisible() + + const headers = Array.from( + page.getByRole("grid").element().querySelectorAll("thead th"), + ).map((cell) => cell.getAttribute("aria-label")) + + expect(headers[0]).toBe("Montag") + expect(headers.at(-1)).toBe("Sonntag") + }) + + test("the month heading is localized", async () => { + await render( + , + ) + + await expect + .element(page.getByRole("button", {name: /switch to month view/i})) + .toHaveTextContent("Juni") + }) + + test("switching locale reformats the value already in the field", async () => { + function Switcher() { + const [locale, setLocale] = useState("en-US") + return ( + <> + + + + ) + } + + await render() + await expect.element(input()).toHaveValue("06/15/2024") + + await page.getByRole("button", {name: "German"}).click() + + await expect.element(input()).toHaveValue("15.06.2024") + }) + + test("an explicitly undefined translation falls back to the built-in label", async () => { + await render( + , + ) + + await expect + .element(page.getByRole("button", {name: "Choose date"})) + .toBeVisible() + }) + + test("translations override the built-in accessible labels", async () => { + await render( + + open ? "Kalender schließen" : "Kalender öffnen", + }} + />, + ) + + await expect + .element(page.getByRole("button", {name: "Kalender öffnen"})) + .toBeVisible() + + await page.getByRole("button", {name: "Kalender öffnen"}).click() + + await expect + .element(page.getByRole("button", {name: "Kalender schließen"})) + .toBeVisible() + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-multiple.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-multiple.spec.tsx new file mode 100644 index 000000000..a0c183446 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-multiple.spec.tsx @@ -0,0 +1,442 @@ +import {useState} from "react" + +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import { + DatePicker, + type DatePickerProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +function Simple(props: Partial) { + return ( + + ) +} + +// the field is the calendar trigger, so the label names it +function getField() { + return page.getByRole("combobox", {name: "Dates"}) +} + +const tests: MultiComponentTestCase[] = [ + { + simple: () => , + testCase: (getComponent) => { + test("shows the placeholder when nothing is selected", async () => { + await render(getComponent()) + await expect.element(page.getByText("Select dates")).toBeVisible() + }) + }, + }, + { + simple: () => ( + + ), + testCase: (getComponent) => { + test("renders one dismissible tag per selected date", async () => { + await render(getComponent()) + await expect.element(page.getByText("06/10/2024")).toBeVisible() + await expect.element(page.getByText("06/20/2024")).toBeVisible() + }) + }, + }, + { + simple: () => ( + + ), + testCase: (getComponent) => { + test("dismissing a tag removes that date", async () => { + await render(getComponent()) + + await page.getByRole("button", {name: /Remove 06\/10\/2024/i}).click() + + await expect + .element(page.getByText("06/10/2024")) + .not.toBeInTheDocument() + await expect.element(page.getByText("06/20/2024")).toBeVisible() + }) + }, + }, + { + simple: () => ( + + ), + testCase: (getComponent) => { + test("disables only unselected days once maxSelectedDates is reached", async () => { + await render(getComponent()) + await getField().click() + + await expect + .element(page.getByRole("gridcell", {name: /June 25, 2024/})) + .toHaveAttribute("aria-disabled", "true") + + // already-selected dates stay enabled so they can be deselected + await expect + .element(page.getByRole("gridcell", {name: /June 10, 2024/})) + .toHaveAttribute("aria-disabled", "false") + }) + }, + }, +] + +describe("DatePicker - Multiple", () => { + runTests(tests) + + test("clicking a selected day removes it from the selection", async () => { + await render( + , + ) + await getField().click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect.element(page.getByText("06/10/2024")).not.toBeInTheDocument() + await expect.element(page.getByText("06/20/2024")).toBeVisible() + }) + + test("Enter toggles the focused day without closing the calendar", async () => { + await render() + await getField().click() + + await userEvent.keyboard("{Enter}") + await expect.element(page.getByText("06/15/2024")).toBeVisible() + + await userEvent.keyboard("{Enter}") + await expect.element(page.getByText("06/15/2024")).not.toBeInTheDocument() + await expect.element(page.getByRole("grid")).toBeVisible() + }) + + test("selections are kept in chronological order regardless of pick order", async () => { + const onValueChange = vi.fn() + await render() + await getField().click() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + expect(onValueChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + valueAsString: ["06/10/2024", "06/20/2024"], + }), + ) + }) + + test("maxSelectedDates rejects a further selection", async () => { + const onValueChange = vi.fn() + await render( + , + ) + await getField().click() + + const cell = page.getByLabelText(/June 25, 2024/).element() as HTMLElement + cell.click() + + expect(onValueChange).not.toHaveBeenCalled() + await expect.element(page.getByText("06/25/2024")).not.toBeInTheDocument() + }) + + test("a date freed up by deselection can be re-spent under maxSelectedDates", async () => { + await render( + , + ) + await getField().click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await page.getByRole("gridcell", {name: /June 25, 2024/}).click() + + await expect.element(page.getByText("06/20/2024")).toBeVisible() + await expect.element(page.getByText("06/25/2024")).toBeVisible() + await expect.element(page.getByText("06/10/2024")).not.toBeInTheDocument() + }) + + test("the multiple field has no text input, so the label names the field", async () => { + await render() + + await expect.element(getField()).toBeVisible() + await expect.element(page.getByRole("textbox")).not.toBeInTheDocument() + }) + + test("a disabled picker renders its tags as disabled", async () => { + await render() + + await expect + .element(page.getByRole("button", {name: /Remove 06\/10\/2024/i})) + .toBeDisabled() + }) + + test("the inline headline collapses more than two dates into a summary", async () => { + await render( + , + ) + + await expect.element(page.getByText("Jun 10, 2024 +2 more")).toBeVisible() + }) + + test("the inline headline lists two dates in full", async () => { + await render( + , + ) + + await expect + .element(page.getByText("Jun 10, 2024, Jun 20, 2024")) + .toBeVisible() + }) + + test("headlineValueProps overrides the summary suffix", async () => { + await render( + `and ${count} others`}} + variant="inline" + />, + ) + + await expect + .element(page.getByText("Jun 10, 2024 and 2 others")) + .toBeVisible() + }) + + describe("the field acts as the calendar trigger", () => { + test("clicking the field toggles the calendar", async () => { + await render() + + await getField().click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await getField().click() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test("clicking the placeholder opens the calendar", async () => { + await render() + + await page.getByText("Select dates").click() + + await expect.element(page.getByRole("grid")).toBeVisible() + }) + + test("clicking a tag does not open the calendar", async () => { + await render() + + await page.getByText("06/10/2024").click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test("dismissing a tag does not open the calendar", async () => { + await render() + + await page.getByRole("button", {name: /Remove 06\/10\/2024/i}).click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test("clearing the value does not open the calendar", async () => { + await render() + + await page.getByRole("button", {name: /clear/i}).click() + + await expect.element(page.getByText("Select dates")).toBeVisible() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test.for(["{Enter}", " "])("%s opens the calendar", async (key) => { + await render() + + // open then close leaves the field focused and closed + await getField().click() + await getField().click() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect.element(getField()).toHaveFocus() + + await userEvent.keyboard(key) + await expect.element(page.getByRole("grid")).toBeVisible() + }) + + test("ArrowDown opens the calendar", async () => { + await render() + + await getField().click() + await getField().click() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + + await userEvent.keyboard("{ArrowDown}") + await expect.element(page.getByRole("grid")).toBeVisible() + }) + + test("escape closes the calendar and restores focus to the field", async () => { + await render() + + await getField().click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Escape}") + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect.element(getField()).toHaveFocus() + }) + + test("a disabled field does not open the calendar", async () => { + await render() + + await expect.element(getField()).toHaveAttribute("aria-disabled", "true") + + const field = getField().element() as HTMLElement + field.click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test("a readonly field does not open the calendar", async () => { + await render() + + await expect.element(getField()).toHaveAttribute("aria-readonly", "true") + + await getField().click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + + test("switching selectionMode at runtime hands the trigger over cleanly", async () => { + function ModeSwitcher() { + const [mode, setMode] = + useState("multiple") + return ( + <> + + + + ) + } + + await render() + await expect.element(getField()).toBeVisible() + + await page.getByRole("button", {name: "Swap mode"}).click() + + const trigger = page.getByRole("button", { + name: /(?:choose|change) date/i, + }) + await expect.element(trigger).toBeVisible() + await trigger.click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Escape}") + await expect.element(trigger).toHaveFocus() + + await page.getByRole("button", {name: "Swap mode"}).click() + + await expect.element(getField()).toBeVisible() + await getField().click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Escape}") + await expect.element(getField()).toHaveFocus() + }) + + test("switching to range restores the group role on the field", async () => { + function RangeSwitcher() { + const [mode, setMode] = + useState("multiple") + return ( + <> + + + + ) + } + + await render() + await expect.element(getField()).toBeVisible() + + await page.getByRole("button", {name: "Swap mode"}).click() + + const group = page.getByRole("group", {name: "Dates"}) + await expect.element(group).toBeVisible() + await expect.element(group).toHaveAttribute("dir", "ltr") + + await page.getByRole("button", {name: "Swap mode"}).click() + + await expect.element(getField()).toBeVisible() + await expect.element(getField()).toHaveAttribute("dir", "ltr") + }) + + test("clicking the label focuses the field", async () => { + await render() + + await page.getByText("Dates", {exact: true}).click() + + await expect.element(getField()).toHaveFocus() + }) + + test("the calendar icon is not a separate tab stop", async () => { + await render() + + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .not.toBeInTheDocument() + }) + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-navigation.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-navigation.spec.tsx new file mode 100644 index 000000000..c2d57c4d9 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-navigation.spec.tsx @@ -0,0 +1,298 @@ +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const seeded = parseDate("2024-06-15") + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +const nextTrigger = () => + page.getByRole("button", {name: /switch to next month/i}) +const prevTrigger = () => + page.getByRole("button", {name: /switch to previous month/i}) + +const tests: MultiComponentTestCase>[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the next trigger advances the visible month", async () => { + await render(getComponent()) + await openCalendar() + + await nextTrigger().click() + + await expect + .element(page.getByRole("gridcell", {name: /July 1, 2024/})) + .toBeVisible() + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .not.toBeInTheDocument() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the previous trigger steps back across the year boundary", async () => { + await render(getComponent()) + await openCalendar() + + for (let i = 0; i < 6; i++) { + await prevTrigger().click() + } + + await expect + .element(page.getByRole("gridcell", {name: /December 15, 2023/})) + .toBeVisible() + }) + }, + }, + { + composite: () => ( + + ), + simple: () => ( + + ), + testCase: (getComponent) => { + test("both paging triggers are disabled when min and max fence the visible month in", async () => { + await render(getComponent()) + await openCalendar() + + await expect.element(prevTrigger()).toBeDisabled() + await expect.element(nextTrigger()).toBeDisabled() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the next trigger disables only once the last allowed month is reached", async () => { + await render(getComponent()) + await openCalendar() + + await expect.element(nextTrigger()).not.toBeDisabled() + await nextTrigger().click() + + await expect.element(nextTrigger()).toBeDisabled() + await expect.element(prevTrigger()).not.toBeDisabled() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("arrow keys page the visible month when focus crosses its edge", async () => { + await render(getComponent()) + await openCalendar() + + // June 15 -> June 1 -> May 25 + await userEvent.keyboard("{Home}{ArrowUp}") + + await expect + .element(page.getByRole("gridcell", {name: /May 25, 2024/})) + .toBeVisible() + await expect.element(page.getByLabelText(/May 25, 2024/)).toHaveFocus() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("PageDown and PageUp move focus a month at a time", async () => { + await render(getComponent()) + await openCalendar() + + await userEvent.keyboard("{PageDown}") + await expect.element(page.getByLabelText(/July 15, 2024/)).toHaveFocus() + + await userEvent.keyboard("{PageUp}") + await expect.element(page.getByLabelText(/June 15, 2024/)).toHaveFocus() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("Shift+PageDown moves focus a year at a time", async () => { + await render(getComponent()) + await openCalendar() + + await userEvent.keyboard("{Shift>}{PageDown}{/Shift}") + + await expect.element(page.getByLabelText(/June 15, 2025/)).toHaveFocus() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("arrow keys are mirrored in RTL", async () => { + await render(getComponent()) + await openCalendar() + + await userEvent.keyboard("{ArrowRight}") + await expect.element(page.getByLabelText(/June 14, 2024/)).toHaveFocus() + + await userEvent.keyboard("{ArrowLeft}") + await expect.element(page.getByLabelText(/June 15, 2024/)).toHaveFocus() + }) + }, + }, +] + +describe("DatePicker - Navigation", () => { + runTests(tests) + + test("the paging triggers step by year in the month view and by decade in the year view", async () => { + await render() + await openCalendar() + + await page.getByRole("button", {name: /switch to month view/i}).click() + await page.getByRole("button", {name: /switch to next year/i}).click() + await expect + .element(page.getByRole("button", {name: /switch to year view/i})) + .toHaveTextContent("2025") + + await page.getByRole("button", {name: /return to calendar/i}).click() + await page.getByRole("button", {name: /switch to year view/i}).click() + await page.getByRole("button", {name: /switch to previous decade/i}).click() + + await expect + .element(page.getByRole("gridcell", {name: "2010"})) + .toBeVisible() + await expect + .element(page.getByRole("gridcell", {name: "2019"})) + .toBeVisible() + }) + + test("a range picker announces one visible month without repeating it", async () => { + await render() + await openCalendar() + + await nextTrigger().click() + + await expect + .element(page.getByRole("status")) + .toHaveTextContent(/^July 2024$/) + }) + + test("paging the day view announces the newly visible month", async () => { + await render() + await openCalendar() + + await nextTrigger().click() + + await expect + .element(page.getByRole("status")) + .toHaveTextContent("July 2024") + }) + + test("a visible range change is announced politely", async () => { + await render() + await openCalendar() + + await page.getByRole("button", {name: /switch to month view/i}).click() + await page.getByRole("button", {name: /switch to next year/i}).click() + + await expect.element(page.getByRole("status")).toHaveTextContent("2025") + }) + + test("onVisibleRangeChange reports the newly visible month", async () => { + const onVisibleRangeChange = vi.fn() + await render() + await openCalendar() + + await nextTrigger().click() + + expect(onVisibleRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ + visibleRange: expect.objectContaining({ + start: expect.objectContaining({month: 7, year: 2024}), + }), + }), + ) + }) + + test("onFocusChange reports roving focus without committing a value", async () => { + const onFocusChange = vi.fn() + const onValueChange = vi.fn() + await render( + , + ) + await openCalendar() + + await userEvent.keyboard("{ArrowRight}") + + expect(onFocusChange).toHaveBeenCalledWith( + expect.objectContaining({ + focusedValue: expect.objectContaining({ + day: 16, + month: 6, + year: 2024, + }), + }), + ) + expect(onValueChange).not.toHaveBeenCalled() + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-open-close.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-open-close.spec.tsx new file mode 100644 index 000000000..683449128 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-open-close.spec.tsx @@ -0,0 +1,115 @@ +import {describe, expect, test} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return +} + +const tests: MultiComponentTestCase[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("opens on trigger click and closes on Escape, restoring focus to the trigger", async () => { + await render(getComponent()) + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Escape}") + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toHaveFocus() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("toggles closed when the trigger is clicked while open", async () => { + await render(getComponent()) + + const trigger = page.getByRole("button", { + name: /(?:choose|change) date|close calendar/i, + }) + await trigger.click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await trigger.click() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("defaultOpen renders the calendar initially open", async () => { + await render(getComponent()) + await expect.element(page.getByRole("grid")).toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("controlled open={false} wins over defaultOpen", async () => { + await render(getComponent()) + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + }, + }, +] + +describe("DatePicker - Open/Close", () => { + runTests(tests) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-parts.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-parts.spec.tsx new file mode 100644 index 000000000..3b8311fd4 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-parts.spec.tsx @@ -0,0 +1,379 @@ +import {describe, expect, test} from "vitest" +import {render} from "vitest-browser-react" +import {page} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import {Button} from "@qualcomm-ui/react/button" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const seeded = parseDate("2024-06-15") + +const weekdays = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] + +function DayView() { + return ( + + + + + + + + + + + ) +} + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const tests: MultiComponentTestCase[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("trigger accessible name reflects the open state", async () => { + await render(getComponent()) + + const trigger = page.getByRole("button", { + name: /(?:choose|change) date/i, + }) + await trigger.click() + + await expect + .element(page.getByRole("button", {name: /close calendar/i})) + .toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the popup is a dialog announced by name, wrapping an application view", async () => { + await render(getComponent()) + + const trigger = page.getByRole("button", { + name: /(?:choose|change) date/i, + }) + await expect.element(trigger).toHaveAttribute("aria-haspopup", "dialog") + await trigger.click() + + const dialog = page.getByRole("dialog") + await expect.element(dialog).toBeVisible() + await expect.element(dialog).toHaveAccessibleName("calendar") + await expect + .element(dialog) + .toHaveAttribute("aria-roledescription", "datepicker") + + await expect.element(page.getByRole("application")).toBeVisible() + }) + }, + }, + { + simple: () => , + testCase: (getComponent) => { + test("the inline calendar is a group, not a dialog", async () => { + await render(getComponent()) + + await expect.element(page.getByRole("grid")).toBeVisible() + expect(page.getByRole("dialog").elements()).toHaveLength(0) + await expect + .element(page.getByRole("group", {name: "calendar"})) + .toBeVisible() + await expect.element(page.getByRole("application")).toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("weekday headers expose full day names to assistive technology", async () => { + await render(getComponent()) + + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + const headers = page.getByRole("columnheader") + expect(headers.elements()).toHaveLength(7) + + for (const [index, weekday] of weekdays.entries()) { + await expect.element(headers.nth(index)).toHaveAccessibleName(weekday) + } + }) + }, + }, + { + simple: () => , + testCase: (getComponent) => { + test("inline variant renders an always-open calendar with no popover trigger", async () => { + await render(getComponent()) + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .not.toBeInTheDocument() + }) + }, + }, + { + composite: () => ( + + + {(api) => ( + + }> + {api.valueAsString[0] ?? "Pick a date"} + + + )} + + + + + + + + + + ), + testCase: (getComponent) => { + test("a custom Trigger renders custom content and opens the calendar", async () => { + await render(getComponent()) + + await expect.element(page.getByText("Pick a date")).toBeVisible() + + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + }) + }, + }, + { + composite: () => , + testCase: (getComponent) => { + test("minView='month' opens directly at the month view", async () => { + await render(getComponent()) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + + await expect + .element(page.getByRole("grid")) + .toHaveAttribute("aria-roledescription", "calendar year") + }) + }, + }, +] + +describe("DatePicker - Parts", () => { + runTests(tests) + + test("the trigger confirms the selected date in its accessible name", async () => { + await render() + + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toHaveAccessibleName("Change date, Monday, June 10, 2024") + }) + + test("the trigger names both ends of a committed range, and a half-open one", async () => { + await render( + , + ) + + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toHaveAccessibleName("Change date range, from Monday, June 10, 2024") + }) + + test("the field-as-trigger counts selections in multiple mode", async () => { + await render( + , + ) + + await expect + .element(page.getByLabelText(/change dates/i)) + .toHaveAccessibleName("Change dates, 2 selected") + }) + + test("the calendar grid is named by the range it shows, at every view level", async () => { + await render() + + const grid = () => page.getByRole("grid") + await expect.element(grid()).toHaveAccessibleName("June 2024") + + await page.getByRole("button", {name: /switch to next month/i}).click() + await expect.element(grid()).toHaveAccessibleName("July 2024") + + await page.getByRole("button", {name: /switch to month view/i}).click() + await expect.element(grid()).toHaveAccessibleName("2024") + + await page.getByRole("button", {name: /return to calendar/i}).click() + await page.getByRole("button", {name: /switch to year view/i}).click() + await expect.element(grid()).toHaveAccessibleName("2020 - 2029") + }) + + test("a range picker's grid name does not repeat a single visible month", async () => { + await render() + + await expect + .element(page.getByRole("grid")) + .toHaveAccessibleName("June 2024") + }) + + test("the inline headline captions the current selection", async () => { + await render() + + await expect.element(page.getByText("Date", {exact: true})).toBeVisible() + await expect.element(page.getByText("Select date")).toBeVisible() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByText("Thu, June 20, 2024")).toBeVisible() + }) + + test("a range picker's headline caption names the range", async () => { + await render() + + await expect.element(page.getByText("Date range")).toBeVisible() + }) + + test("headline={false} drops the headline from the inline calendar", async () => { + await render() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect.element(page.getByText("Select date")).not.toBeInTheDocument() + }) + + test("the inline variant has no field, so there is no input or clear trigger", async () => { + await render() + + await expect.element(page.getByRole("textbox")).not.toBeInTheDocument() + await expect + .element(page.getByRole("button", {name: /clear selected dates/i})) + .not.toBeInTheDocument() + }) + + test("the context render prop re-renders once a selection commits", async () => { + await render( + + + {(api) => {api.valueAsString[0] ?? "nothing selected"}} + + + + + + + + + + + + , + ) + await expect.element(page.getByText("nothing selected")).toBeVisible() + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByText("06/20/2024")).toBeVisible() + await expect + .element(page.getByText("nothing selected")) + .not.toBeInTheDocument() + }) + + test("a trigger-only picker returns focus to the trigger after a selection", async () => { + await render( + + + }> + Pick a date + + + + + + + + + + , + ) + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toHaveFocus() + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-presets.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-presets.spec.tsx new file mode 100644 index 000000000..e4c72e37c --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-presets.spec.tsx @@ -0,0 +1,213 @@ +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerPreset, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +const seeded = parseDate("2024-06-15") + +const presets: DatePickerPreset[] = [ + {label: "Next 7 days", value: "next7Days"}, + {label: "This month", value: "thisMonth"}, + { + label: "Fixed span", + value: [parseDate("2024-07-01"), parseDate("2024-07-10")], + }, +] + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + + {presets.map((preset, index) => ( + + {preset.label} + + ))} + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() +const presetsTrigger = () => page.getByRole("button", {name: /show presets/i}) +const calendarTrigger = () => page.getByRole("button", {name: /show calendar/i}) +const startInput = () => page.getByRole("textbox", {name: /start date/i}) + +const endInput = () => page.getByRole("textbox", {name: /end date/i}) + +describe("DatePicker - Presets", () => { + test("the presets trigger reveals the preset list and flips its own label", async () => { + await render() + await openCalendar() + + await expect.element(page.getByText("Next 7 days")).not.toBeVisible() + + await presetsTrigger().click() + + await expect.element(page.getByText("Next 7 days")).toBeVisible() + await expect.element(calendarTrigger()).toBeVisible() + + await calendarTrigger().click() + + await expect.element(page.getByText("Next 7 days")).not.toBeVisible() + await expect.element(presetsTrigger()).toBeVisible() + }) + + test("no presets trigger is rendered when no presets are given", async () => { + await render() + await openCalendar() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect.element(presetsTrigger()).not.toBeInTheDocument() + }) + + test("a named preset commits a complete range and closes the panel", async () => { + const onValueChange = vi.fn() + await render() + await openCalendar() + + await presetsTrigger().click() + await page.getByText("Next 7 days").click() + + await expect.element(startInput()).not.toHaveValue("") + await expect.element(endInput()).not.toHaveValue("") + await expect.element(page.getByText("Next 7 days")).not.toBeVisible() + expect(onValueChange).toHaveBeenCalled() + }) + + test("a preset given as explicit dates commits exactly those dates", async () => { + await render() + await openCalendar() + + await presetsTrigger().click() + await page.getByText("Fixed span").click() + + await expect.element(startInput()).toHaveValue("07/01/2024") + await expect.element(endInput()).toHaveValue("07/10/2024") + }) + + test("choosing a preset moves the calendar onto the preset's range", async () => { + await render() + await openCalendar() + + await presetsTrigger().click() + await page.getByText("Fixed span").click() + + await expect + .element(page.getByRole("button", {name: /switch to month view/i})) + .toHaveTextContent("July") + }) + + test("the paging and view triggers are inert while the preset panel is open", async () => { + await render() + await openCalendar() + + await presetsTrigger().click() + + await expect + .element(page.getByRole("button", {name: /switch to next month/i})) + .toBeDisabled() + await expect + .element(page.getByRole("button", {name: /switch to previous month/i})) + .toBeDisabled() + await expect + .element(page.getByRole("button", {name: /switch to month view/i})) + .toBeDisabled() + }) + + test("reopening the calendar starts with the preset panel closed", async () => { + await render() + await openCalendar() + await presetsTrigger().click() + await expect.element(page.getByText("Next 7 days")).toBeVisible() + + await page.getByRole("button", {name: /close calendar/i}).click() + await openCalendar() + + await expect.element(page.getByText("Next 7 days")).not.toBeVisible() + }) + + test("a preset trigger describes the range it will apply", async () => { + await render() + await openCalendar() + await presetsTrigger().click() + + await expect + .element(page.getByText("Fixed span")) + .toHaveAttribute( + "aria-label", + "select Monday, July 1, 2024 to Wednesday, July 10, 2024", + ) + }) + + test("the preset label follows the picker's locale and time zone", async () => { + await render() + await openCalendar() + await presetsTrigger().click() + + await expect + .element(page.getByText("Fixed span")) + .toHaveAttribute( + "aria-label", + "select Montag, 1. Juli 2024 to Mittwoch, 10. Juli 2024", + ) + }) + + test("a readOnly picker will not open the preset panel", async () => { + await render() + + await presetsTrigger().click() + + await expect.element(page.getByText("Next 7 days")).not.toBeVisible() + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-range.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-range.spec.tsx new file mode 100644 index 000000000..5acef5901 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-range.spec.tsx @@ -0,0 +1,377 @@ +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const seeded = parseDate("2024-06-15") + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const startInput = () => page.getByRole("textbox", {name: /start date/i}) +const endInput = () => page.getByRole("textbox", {name: /end date/i}) + +/** + * Cell state (`data-*`) and focus live on the trigger inside the `gridcell`, + * not on the cell itself. + */ +const dayTrigger = (day: string) => page.getByLabelText(day) + +const tests: MultiComponentTestCase>[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the range start keeps the calendar open and the end closes it", async () => { + await render(getComponent()) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("") + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("typing the end date first leaves the start slot empty, then accepts a start date", async () => { + await render(getComponent()) + + await endInput().fill("06/20/2024") + await userEvent.keyboard("{Enter}") + + // the end date must stay in slot 1 rather than collapsing into slot 0 + await expect.element(startInput()).toHaveValue("") + await expect.element(endInput()).toHaveValue("06/20/2024") + + await startInput().fill("06/10/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("normalizes a reversed range entered via input", async () => { + await render(getComponent()) + + await startInput().fill("06/20/2024") + await userEvent.keyboard("{Enter}") + await endInput().fill("06/10/2024") + await userEvent.keyboard("{Enter}") + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + }, + }, + { + composite: (props) => ( + + ), + simple: (props) => ( + + ), + testCase: (getComponent) => { + test("clearing only the start input leaves the end date intact", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + + await startInput().fill("") + await userEvent.keyboard("{Enter}") + + await expect.element(startInput()).toHaveValue("") + await expect.element(endInput()).toHaveValue("06/20/2024") + // the cleared slot becomes a positional hole, not a collapsed array + expect(onValueChange).toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["", "06/20/2024"]}), + ) + }) + }, + }, + { + composite: () => ( + + ), + simple: () => ( + + ), + testCase: (getComponent) => { + test("reverts an unparsable start date when tabbing to the end input, leaving the end intact", async () => { + await render(getComponent()) + + await startInput().fill("64564") + await userEvent.keyboard("{Tab}") + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("reverts trailing garbage typed into the start input after it committed", async () => { + await render(getComponent()) + + await startInput().fill("06/10/2024") + await userEvent.keyboard("{Enter}") + await expect.element(startInput()).toHaveValue("06/10/2024") + + await userEvent.keyboard("99") + await userEvent.keyboard("{Tab}") + + await expect.element(startInput()).toHaveValue("06/10/2024") + }) + }, + }, +] + +describe("DatePicker - Range", () => { + runTests(tests) + + test("previews the range band while hovering after picking a start date", async () => { + await render() + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await dayTrigger("June 20, 2024").hover() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect + .element(dayTrigger("June 10, 2024")) + .toHaveAttribute("data-hover-range-start") + await expect + .element(dayTrigger("June 15, 2024")) + .toHaveAttribute("data-in-hover-range") + await expect + .element(dayTrigger("June 20, 2024")) + .toHaveAttribute("data-hover-range-end") + + // outside the previewed span + await expect + .element(dayTrigger("June 25, 2024")) + .not.toHaveAttribute("data-in-hover-range") + }) + + test("marks the committed range band once both endpoints are selected", async () => { + // closeOnSelect would hide the calendar the moment the range completes + await render() + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect + .element(dayTrigger("June 10, 2024")) + .toHaveAttribute("data-range-start") + await expect + .element(dayTrigger("June 15, 2024")) + .toHaveAttribute("data-in-range") + await expect + .element(dayTrigger("June 20, 2024")) + .toHaveAttribute("data-range-end") + + await expect + .element(dayTrigger("June 25, 2024")) + .not.toHaveAttribute("data-in-range") + }) + + test("leaving the grid clears the previewed band", async () => { + await render() + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await dayTrigger("June 20, 2024").hover() + await expect + .element(dayTrigger("June 15, 2024")) + .toHaveAttribute("data-in-hover-range") + + await page.getByRole("button", {name: /switch to next month/i}).hover() + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect + .element(dayTrigger("June 15, 2024")) + .not.toHaveAttribute("data-in-hover-range") + }) + + test("keyboard navigation previews the band the same way hovering does", async () => { + await render() + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await userEvent.keyboard("{Enter}") + await userEvent.keyboard("{ArrowRight}{ArrowRight}") + + await expect.element(page.getByRole("grid")).toBeVisible() + await expect + .element(dayTrigger("June 17, 2024")) + .toHaveAttribute("data-in-hover-range") + await expect + .element(dayTrigger("June 18, 2024")) + .toHaveAttribute("data-hover-range-end") + }) + + test("a third click starts a new range from the clicked day", async () => { + await render() + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + + await page.getByRole("gridcell", {name: /June 25, 2024/}).click() + + await expect.element(startInput()).toHaveValue("06/25/2024") + await expect.element(endInput()).toHaveValue("") + }) + + test("a range half-entered by typing resumes at the end slot in the calendar", async () => { + await render() + + await startInput().fill("06/10/2024") + await userEvent.keyboard("{Enter}") + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + + test("a range left with only an end date resumes at the start slot in the calendar", async () => { + await render( + , + ) + + await startInput().fill("") + await userEvent.keyboard("{Enter}") + await expect.element(startInput()).toHaveValue("") + + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + await page.getByRole("gridcell", {name: /June 5, 2024/}).click() + + await expect.element(startInput()).toHaveValue("06/05/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + + test("the second calendar pick completes the range regardless of click order", async () => { + await render() + await page.getByRole("button", {name: /(?:choose|change) date/i}).click() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("06/20/2024") + }) + + test("clearing only the end input leaves the start date intact", async () => { + const onValueChange = vi.fn() + await render( + , + ) + + await endInput().fill("") + await userEvent.keyboard("{Enter}") + + await expect.element(startInput()).toHaveValue("06/10/2024") + await expect.element(endInput()).toHaveValue("") + expect(onValueChange).toHaveBeenCalledWith( + expect.objectContaining({valueAsString: ["06/10/2024"]}), + ) + }) + + test("the range headline shows placeholders until both endpoints are set", async () => { + await render() + + await expect.element(page.getByText("Start - End")).toBeVisible() + + await page.getByRole("gridcell", {name: /June 10, 2024/}).click() + + await expect.element(page.getByText("Jun 10, 2024 - End")).toBeVisible() + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-selection.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-selection.spec.tsx new file mode 100644 index 000000000..9aacb04dc --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-selection.spec.tsx @@ -0,0 +1,192 @@ +import {describe, expect, test} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const seeded = parseDate("2024-06-15") + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const tests: MultiComponentTestCase[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("selecting a day with the pointer commits it and closes", async () => { + await render(getComponent()) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await page.getByRole("gridcell", {name: /June 20, 2024/}).click() + + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/20/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("Space selects the focused day the calendar opened on", async () => { + await render(getComponent()) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{ }") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/15/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("Enter selects the focused day the calendar opened on", async () => { + await render(getComponent()) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Enter}") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/15/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("arrow keys move day focus before committing", async () => { + await render(getComponent()) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{ArrowRight}{Enter}") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/16/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("ArrowDown moves focus forward one week", async () => { + await render(getComponent()) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{ArrowDown}{Enter}") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/22/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("Home jumps to the first day of the month", async () => { + await render(getComponent()) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{Home}{Enter}") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/01/2024") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("End jumps to the last day of the month", async () => { + await render(getComponent()) + await page + .getByRole("button", {name: /(?:choose|change) date/i}) + .click() + await expect.element(page.getByRole("grid")).toBeVisible() + + await userEvent.keyboard("{End}{Enter}") + await expect + .element(page.getByRole("textbox")) + .toHaveValue("06/30/2024") + }) + }, + }, +] + +describe("DatePicker - Selection", () => { + runTests(tests) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-states.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-states.spec.tsx new file mode 100644 index 000000000..244aaf0ed --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-states.spec.tsx @@ -0,0 +1,108 @@ +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const seeded = parseDate("2024-06-15") + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const tests: MultiComponentTestCase[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("disabled prevents opening the calendar", async () => { + await render(getComponent()) + + await expect + .element(page.getByRole("button", {name: /(?:choose|change) date/i})) + .toBeDisabled() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("readOnly leaves the trigger enabled but does not open the popover", async () => { + await render(getComponent()) + + const trigger = page.getByRole("button", { + name: /(?:choose|change) date/i, + }) + await expect.element(trigger).not.toBeDisabled() + + await trigger.click() + await expect.element(page.getByRole("grid")).not.toBeInTheDocument() + }) + }, + }, +] + +describe("DatePicker - States", () => { + runTests(tests) + + test("readOnly inline calendar stays navigable but ignores selection", async () => { + const onValueChange = vi.fn() + await render( + , + ) + + await page.getByRole("gridcell", {name: /June 15, 2024/}).click() + await userEvent.keyboard("{ArrowRight}") + await expect.element(page.getByLabelText(/June 16, 2024/)).toHaveFocus() + + await userEvent.keyboard("{Enter}") + expect(onValueChange).not.toHaveBeenCalled() + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-validation.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-validation.spec.tsx new file mode 100644 index 000000000..6aa09ec68 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-validation.spec.tsx @@ -0,0 +1,181 @@ +import {describe, expect, test} from "vitest" +import {render} from "vitest-browser-react" +import {page} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const hint = "Arrival must be after this date" +const errorText = "Pick a date in the future" + +function Composite(props: Partial) { + return ( + + + + + {hint} + {errorText} + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const input = () => page.getByRole("textbox") + +const tests: MultiComponentTestCase>[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("a valid picker shows the hint and hides the error text", async () => { + await render(getComponent()) + + await expect.element(page.getByText(hint)).toBeVisible() + await expect.element(page.getByText(errorText)).not.toBeVisible() + await expect + .element(input()) + .not.toHaveAttribute("aria-invalid", "true") + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("an invalid picker swaps the hint for the error text", async () => { + await render(getComponent()) + + await expect.element(page.getByText(errorText)).toBeVisible() + await expect.element(page.getByText(hint)).not.toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("an invalid picker marks the input and points it at the error text", async () => { + await render(getComponent()) + + await expect.element(input()).toHaveAttribute("aria-invalid", "true") + + const describedBy = input().element().getAttribute("aria-describedby") + const errorId = page.getByText(errorText).element().id + expect(describedBy?.split(" ")).toContain(errorId) + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the hint is associated with the input", async () => { + await render(getComponent()) + + const describedBy = input().element().getAttribute("aria-describedby") + const hintId = page.getByText(hint).element().id + expect(describedBy?.split(" ")).toContain(hintId) + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the error indicator only appears while invalid", async () => { + await render(getComponent()) + + await expect.element(page.getByLabelText("Error")).toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the error indicator is absent while valid", async () => { + await render(getComponent()) + + await expect.element(page.getByLabelText("Error")).not.toBeVisible() + }) + }, + }, +] + +describe("DatePicker - Validation", () => { + runTests(tests) + + test("the label is wired to the single input", async () => { + await render() + + await expect + .element(page.getByLabelText("Departure date")) + .toHaveAttribute("data-date-picker-part", "input") + }) + + test("a range picker groups its two inputs under the shared label", async () => { + await render() + + const group = page.getByRole("group", {name: "Trip"}) + await expect.element(group).toBeVisible() + expect(group.element().querySelectorAll("input")).toHaveLength(2) + }) + + test("an invalid range picker marks both of its inputs", async () => { + await render() + + await expect + .element(page.getByRole("textbox", {name: /start date/i})) + .toHaveAttribute("aria-invalid", "true") + await expect + .element(page.getByRole("textbox", {name: /end date/i})) + .toHaveAttribute("aria-invalid", "true") + }) + + test("multiple mode carries the invalid state on the field, since it has no input to mark", async () => { + await render( + , + ) + + const field = page.getByRole("combobox", {name: "Dates"}) + await expect.element(field).toHaveAttribute("aria-invalid", "true") + + const describedBy = field.element().getAttribute("aria-describedby") + const errorId = page.getByText(errorText).element().id + expect(describedBy?.split(" ")).toContain(errorId) + }) +}) diff --git a/packages/frameworks/react/src/date-picker/__tests__/date-picker-views.spec.tsx b/packages/frameworks/react/src/date-picker/__tests__/date-picker-views.spec.tsx new file mode 100644 index 000000000..ec4042bc3 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/__tests__/date-picker-views.spec.tsx @@ -0,0 +1,500 @@ +import {describe, expect, test, vi} from "vitest" +import {render} from "vitest-browser-react" +import {page, userEvent} from "vitest/browser" + +import {Portal} from "@qualcomm-ui/react-core/portal" +import { + DatePicker, + type DatePickerProps, + type DatePickerRootProps, + parseDate, +} from "@qualcomm-ui/react/date-picker" + +import {type MultiComponentTestCase, runTests} from "~test-utils/runner" + +const seeded = parseDate("2024-06-15") + +function SubView(props: {view: "month" | "year"}) { + return ( + + + + + + + + {props.view === "month" ? ( + + ) : ( + + )} + + + ) +} + +function Composite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Simple(props: Partial) { + return ( + + ) +} + +const openCalendar = () => + page.getByRole("button", {name: /(?:choose|change) date/i}).click() + +/** + * Only the active view is exposed to the a11y tree, so the single visible grid + * identifies the current view. + */ +const grid = () => page.getByRole("grid") + +const dayLevel = "calendar month" +const monthLevel = "calendar year" +const yearLevel = "calendar decade" + +const tests: MultiComponentTestCase>[] = [ + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the month view trigger switches from the day grid to the month grid", async () => { + await render(getComponent()) + await openCalendar() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + + await page.getByRole("button", {name: /switch to month view/i}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + await expect + .element(page.getByRole("gridcell", {name: "July"})) + .toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the year view trigger switches from the day grid to the year grid", async () => { + await render(getComponent()) + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", yearLevel) + await expect + .element(page.getByRole("gridcell", {name: "2020"})) + .toBeVisible() + await expect + .element(page.getByRole("gridcell", {name: "2029"})) + .toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("picking a month returns to the day grid on that month", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + await openCalendar() + + await page.getByRole("button", {name: /switch to month view/i}).click() + await page.getByRole("gridcell", {name: "September"}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + await expect + .element(page.getByRole("gridcell", {name: /September 15, 2024/})) + .toBeVisible() + expect(onValueChange).not.toHaveBeenCalled() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("viewOnSelect defaults to jumping straight from the year grid to the day grid", async () => { + await render(getComponent()) + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + await page.getByRole("gridcell", {name: "2026"}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2026/})) + .toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("viewOnSelect='previous' steps the year grid back one level to the month grid", async () => { + await render(getComponent()) + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + await page.getByRole("gridcell", {name: "2026"}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("the view close trigger abandons the month grid and returns to the day grid", async () => { + await render(getComponent()) + await openCalendar() + + await page.getByRole("button", {name: /switch to month view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + + await page.getByRole("button", {name: /return to calendar/i}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .toBeVisible() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("Enter drills down from the year grid without committing a value", async () => { + const onValueChange = vi.fn() + await render(getComponent({onValueChange})) + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + await userEvent.keyboard("{Enter}") + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + expect(onValueChange).not.toHaveBeenCalled() + }) + }, + }, + { + composite: () => , + simple: () => , + testCase: (getComponent) => { + test("defaultView opens at the requested level", async () => { + await render(getComponent()) + await openCalendar() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", yearLevel) + }) + }, + }, +] + +/** + * A `ViewTrigger` without a `view` prop cycles to the next level rather than + * jumping to a named one, which is the path `maxView` clamps. + */ +function CyclingComposite(props: Partial) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +describe("DatePicker - Views", () => { + runTests(tests) + + test("a bare view trigger cycles day to month to year", async () => { + await render() + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + + await page.getByRole("button", {name: /switch to day view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", yearLevel) + }) + + test("maxView='month' stops the cycling view trigger at the month grid", async () => { + await render() + await openCalendar() + + await page.getByRole("button", {name: /switch to year view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + + await page.getByRole("button", {name: /switch to day view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + }) + + test("minView='month' makes a month click the selection, normalized to the first of the month", async () => { + await render() + await openCalendar() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + + await page.getByRole("gridcell", {name: "September"}).click() + + await expect.element(page.getByRole("textbox")).toHaveValue("09/01/2024") + await expect.element(grid()).not.toBeInTheDocument() + }) + + test("maxView='month' disables a view trigger that targets the year grid", async () => { + await render() + await openCalendar() + + await expect + .element(page.getByRole("button", {name: /switch to year view/i})) + .toBeDisabled() + await expect + .element(page.getByRole("button", {name: /switch to month view/i})) + .not.toBeDisabled() + }) + + test("minView='month' disables a view trigger that targets the day grid", async () => { + await render( + + + + + Days + Years + + + + + + + , + ) + + await expect + .element(page.getByRole("button", {name: /switch to day view/i})) + .toBeDisabled() + await expect + .element(page.getByRole("button", {name: /switch to year view/i})) + .not.toBeDisabled() + }) + + test("the view close trigger returns to minView rather than to the day grid", async () => { + await render( + + + + + Years + + + + + + + + + , + ) + + await page.getByRole("button", {name: /switch to year view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", yearLevel) + + await page.getByRole("button", {name: /return to calendar/i}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + }) + + test("setView clamps a request that falls outside minView and maxView", async () => { + await render( + + + {(api) => ( + + )} + + + + + + + + + + + + , + ) + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + + await page.getByRole("button", {name: "Go year"}).click() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + }) + + test("reopening discards uncommitted navigation, in both view and visible month", async () => { + await render() + + await openCalendar() + await page.getByRole("button", {name: /switch to month view/i}).click() + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", monthLevel) + + await page.getByRole("button", {name: /close calendar/i}).click() + await openCalendar() + + await expect + .element(grid()) + .toHaveAttribute("aria-roledescription", dayLevel) + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .toBeVisible() + }) + + test("reopening returns to the month of the committed value, not the last browsed month", async () => { + await render() + + const monthHeading = () => + page.getByRole("button", {name: /switch to month view/i}) + + await openCalendar() + await page.getByRole("button", {name: /switch to next month/i}).click() + await expect.element(monthHeading()).toHaveTextContent("July") + + await page.getByRole("button", {name: /close calendar/i}).click() + await openCalendar() + + await expect.element(monthHeading()).toHaveTextContent("June") + await expect + .element(page.getByRole("gridcell", {name: /June 15, 2024/})) + .toBeVisible() + }) + + test("onViewChange reports each level change", async () => { + const onViewChange = vi.fn() + await render() + await openCalendar() + + await page.getByRole("button", {name: /switch to month view/i}).click() + expect(onViewChange).toHaveBeenCalledWith({view: "month"}) + + await page.getByRole("button", {name: /return to calendar/i}).click() + expect(onViewChange).toHaveBeenCalledWith({view: "day"}) + }) +}) diff --git a/packages/frameworks/react/src/date-picker/date-picker-actions.tsx b/packages/frameworks/react/src/date-picker/date-picker-actions.tsx new file mode 100644 index 000000000..04107e91f --- /dev/null +++ b/packages/frameworks/react/src/date-picker/date-picker-actions.tsx @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + type ElementRenderProp, + PolymorphicElement, +} from "@qualcomm-ui/react-core/system" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.js" + +export interface DatePickerActionsProps extends ElementRenderProp<"div"> {} + +/** + * Footer container for the confirm/cancel actions. Intended for use when + * `closeOnSelect` is `false`. Renders a `
` element by default. + */ +export function DatePickerActions(props: DatePickerActionsProps): ReactElement { + const qdsContext = useQdsDatePickerContext() + const mergedProps = mergeProps(qdsContext.getActionsBindings(), props) + + return +} diff --git a/packages/frameworks/react/src/date-picker/date-picker-cancel-trigger.tsx b/packages/frameworks/react/src/date-picker/date-picker-cancel-trigger.tsx new file mode 100644 index 000000000..2f812d803 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/date-picker-cancel-trigger.tsx @@ -0,0 +1,41 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import {useDatePickerContext} from "@qualcomm-ui/react-core/date-picker" +import {Button, type ButtonProps} from "@qualcomm-ui/react/button" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +export interface DatePickerCancelTriggerProps extends ButtonProps {} + +/** + * Discards selection, restores previous value, and closes the calendar. Renders a + * {@link Button} with the label "Cancel" by default. + */ +export function DatePickerCancelTrigger({ + children, + ...props +}: DatePickerCancelTriggerProps): ReactElement { + const api = useDatePickerContext() + const mergedProps = mergeProps( + { + onClick() { + api.cancel() + }, + type: "button" as const, + }, + props, + ) + + return ( + + ) +} diff --git a/packages/frameworks/react/src/date-picker/date-picker-clear-trigger.tsx b/packages/frameworks/react/src/date-picker/date-picker-clear-trigger.tsx new file mode 100644 index 000000000..500e3ddb2 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/date-picker-clear-trigger.tsx @@ -0,0 +1,28 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreDatePicker, + type CoreDatePickerClearTriggerProps, +} from "@qualcomm-ui/react-core/date-picker" + +export interface DatePickerClearTriggerProps extends CoreDatePickerClearTriggerProps {} + +/** + * A button that clears the selection. Renders a ` + ) +} diff --git a/packages/frameworks/react/src/date-picker/date-picker-positioner.tsx b/packages/frameworks/react/src/date-picker/date-picker-positioner.tsx new file mode 100644 index 000000000..028ba0f83 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/date-picker-positioner.tsx @@ -0,0 +1,27 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreDatePicker, + type CoreDatePickerPositionerProps, +} from "@qualcomm-ui/react-core/date-picker" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.js" + +export interface DatePickerPositionerProps extends CoreDatePickerPositionerProps {} + +/** + * Positions the calendar relative to the control. Renders a `
` element by + * default. + */ +export function DatePickerPositioner( + props: DatePickerPositionerProps, +): ReactElement { + const qdsContext = useQdsDatePickerContext() + const mergedProps = mergeProps(qdsContext.getPositionerBindings(), props) + + return +} diff --git a/packages/frameworks/react/src/date-picker/date-picker-preset-trigger.tsx b/packages/frameworks/react/src/date-picker/date-picker-preset-trigger.tsx new file mode 100644 index 000000000..42b492820 --- /dev/null +++ b/packages/frameworks/react/src/date-picker/date-picker-preset-trigger.tsx @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreDatePicker, + type CoreDatePickerPresetTriggerProps, +} from "@qualcomm-ui/react-core/date-picker" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsDatePickerContext} from "./qds-date-picker-context.js" + +export interface DatePickerPresetTriggerProps extends CoreDatePickerPresetTriggerProps {} + +/** + * Selects a preset date or range. Renders a `