diff --git a/package.json b/package.json index e3e68255c..605761c4c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prettier": "^3.2.5", "prettier-plugin-svelte": "^3.2.2", "prettier-plugin-tailwindcss": "0.5.13", - "svelte": "^5.0.0-next.157", + "svelte": "5.0.0-next.164", "svelte-eslint-parser": "^0.34.1", "wrangler": "^3.44.0" }, diff --git a/packages/bits-ui/other/setupTest.ts b/packages/bits-ui/other/setupTest.ts index 9e565a609..e467f299b 100644 --- a/packages/bits-ui/other/setupTest.ts +++ b/packages/bits-ui/other/setupTest.ts @@ -90,5 +90,10 @@ vi.mock("$app/stores", (): typeof stores => { // eslint-disable-next-line ts/no-require-imports globalThis.ResizeObserver = require("resize-observer-polyfill"); Element.prototype.scrollIntoView = () => {}; -Element.prototype.hasPointerCapture = (() => {}) as any +Element.prototype.hasPointerCapture = (() => {}) as any; +// @ts-expect-error - shut it +globalThis.window.CSS.supports = (property: string, value: string) => true; + +globalThis.document.elementsFromPoint = () => []; +globalThis.document.elementFromPoint = () => null; diff --git a/packages/bits-ui/package.json b/packages/bits-ui/package.json index f0782f689..77523b51a 100644 --- a/packages/bits-ui/package.json +++ b/packages/bits-ui/package.json @@ -45,7 +45,7 @@ "jsdom": "^24.0.0", "publint": "^0.2.7", "resize-observer-polyfill": "^1.5.1", - "svelte": "5.0.0-next.157", + "svelte": "5.0.0-next.164", "svelte-check": "^3.6.9", "tslib": "^2.6.2", "typescript": "^5.3.3", diff --git a/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts b/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts index 18251d3d4..16e2c3c5b 100644 --- a/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts +++ b/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts @@ -94,10 +94,6 @@ class CheckboxInputState { constructor(root: CheckboxRootState) { this.root = root; - - $effect(() => { - console.log("shouldRender", this.shouldRender); - }); } props = $derived.by( diff --git a/packages/bits-ui/src/lib/bits/date-field/components/date-field-input.svelte b/packages/bits-ui/src/lib/bits/date-field/components/date-field-input.svelte index e7c224005..f3acadb42 100644 --- a/packages/bits-ui/src/lib/bits/date-field/components/date-field-input.svelte +++ b/packages/bits-ui/src/lib/bits/date-field/components/date-field-input.svelte @@ -1,35 +1,34 @@ {#if asChild} - + {@render child?.({ props: mergedProps, segments: inputState.root.segmentContents })} {:else} -
- +
+ {@render children?.({ segments: inputState.root.segmentContents })}
{/if} diff --git a/packages/bits-ui/src/lib/bits/date-field/components/date-field-label.svelte b/packages/bits-ui/src/lib/bits/date-field/components/date-field-label.svelte index fc5cca374..56999d294 100644 --- a/packages/bits-ui/src/lib/bits/date-field/components/date-field-label.svelte +++ b/packages/bits-ui/src/lib/bits/date-field/components/date-field-label.svelte @@ -1,34 +1,34 @@ {#if asChild} - + {@render child?.({ props: mergedProps })} {:else} - - - +
+ {@render children?.()} +
{/if} diff --git a/packages/bits-ui/src/lib/bits/date-field/components/date-field-segment.svelte b/packages/bits-ui/src/lib/bits/date-field/components/date-field-segment.svelte index a033034b1..8744e1f13 100644 --- a/packages/bits-ui/src/lib/bits/date-field/components/date-field-segment.svelte +++ b/packages/bits-ui/src/lib/bits/date-field/components/date-field-segment.svelte @@ -1,45 +1,37 @@ {#if asChild} - + {@render child?.({ props: mergedProps })} {:else} -
- -
+ + {@render children?.()} + {/if} diff --git a/packages/bits-ui/src/lib/bits/date-field/components/date-field.svelte b/packages/bits-ui/src/lib/bits/date-field/components/date-field.svelte index ddf24a8e2..cc8d18562 100644 --- a/packages/bits-ui/src/lib/bits/date-field/components/date-field.svelte +++ b/packages/bits-ui/src/lib/bits/date-field/components/date-field.svelte @@ -1,125 +1,71 @@ - +{@render children?.()} diff --git a/packages/bits-ui/src/lib/bits/date-field/ctx.ts b/packages/bits-ui/src/lib/bits/date-field/ctx.ts deleted file mode 100644 index 53dfeffa8..000000000 --- a/packages/bits-ui/src/lib/bits/date-field/ctx.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type CreateDateFieldProps, createDateField } from "@melt-ui/svelte"; -import { getContext, setContext } from "svelte"; -import { createBitAttrs, getOptionUpdater, removeUndefined } from "$lib/internal/index.js"; - -export function getDateFieldData() { - const NAME = "date-field" as const; - const PARTS = ["label", "input", "segment"] as const; - - return { - NAME, - PARTS, - }; -} - -type GetReturn = Omit, "updateOption">; - -export function setCtx(props: CreateDateFieldProps) { - const { NAME, PARTS } = getDateFieldData(); - const getAttrs = createBitAttrs(NAME, PARTS); - - const dateField = { ...createDateField(removeUndefined(props)), getAttrs }; - setContext(NAME, dateField); - - return { - ...dateField, - updateOption: getOptionUpdater(dateField.options), - }; -} - -export function getCtx() { - const { NAME } = getDateFieldData(); - return getContext(NAME); -} diff --git a/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts b/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts new file mode 100644 index 000000000..98b6fb313 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts @@ -0,0 +1,2158 @@ +import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; +import type { WithRefProps } from "$lib/internal/types.js"; +import { useRefById } from "$lib/internal/useRefById.svelte.js"; +import { getAnnouncer, type Announcer } from "$lib/shared/date/announcer.js"; +import { + areAllSegmentsFilled, + createContent, + getValueFromSegments, + inferGranularity, + initializeSegmentValues, + initSegmentStates, + isAcceptableSegmentKey, + isDateAndTimeSegmentObj, + isDateSegmentPart, + isFirstSegment, + removeDescriptionElement, + setDescription, +} from "$lib/shared/date/field/helpers.js"; +import { + type DateAndTimeSegmentObj, + type DateSegmentObj, + type DateSegmentPart, + type SegmentValueObj, + type TimeSegmentObj, + type TimeSegmentPart, +} from "$lib/shared/date/field/types.js"; +import { createFormatter, type Formatter } from "$lib/shared/date/formatter.js"; +import { getDaysInMonth, isBefore, toDate } from "$lib/shared/date/utils.js"; +import type { Updater } from "svelte/store"; +import type { DateValue } from "@internationalized/date"; +import type { WritableBox } from "svelte-toolbelt"; +import { + getAriaDisabled, + getAriaHidden, + getAriaInvalid, + getAriaReadonly, + getDataDisabled, + getDataInvalid, + getDataReadonly, +} from "$lib/internal/attrs.js"; +import { isBrowser, isNumberString } from "$lib/internal/is.js"; +import { kbd } from "$lib/internal/kbd.js"; +import { + getFirstSegment, + handleSegmentNavigation, + isSegmentNavigationKey, + moveToNextSegment, + moveToPrevSegment, +} from "$lib/shared/date/field.js"; +import type { SegmentPart } from "$lib/shared/index.js"; +import { DATE_SEGMENT_PARTS, TIME_SEGMENT_PARTS } from "$lib/shared/date/field/parts.js"; +import { untrack } from "svelte"; +import { createContext } from "$lib/internal/createContext.js"; +import { useId } from "$lib/internal/useId.svelte.js"; +import type { Granularity, Matcher } from "$lib/shared/date/types.js"; + +type DateFieldRootStateProps = WritableBoxedValues<{ + value: DateValue | undefined; + placeholder: DateValue; +}> & + ReadableBoxedValues<{ + readonlySegments: SegmentPart[]; + isDateUnavailable: Matcher | undefined; + minValue: DateValue | undefined; + maxValue: DateValue | undefined; + disabled: boolean; + readonly: boolean; + granularity: Granularity | undefined; + hourCycle: 12 | 24 | undefined; + locale: string; + hideTimeZone: boolean; + name: string; + required: boolean; + }>; + +class DateFieldRootState { + value: DateFieldRootStateProps["value"]; + placeholder: WritableBox; + isDateUnavailable: DateFieldRootStateProps["isDateUnavailable"]; + minValue: DateFieldRootStateProps["minValue"]; + maxValue: DateFieldRootStateProps["maxValue"]; + disabled: DateFieldRootStateProps["disabled"]; + readonly: DateFieldRootStateProps["readonly"]; + granularity: DateFieldRootStateProps["granularity"]; + readonlySegments: DateFieldRootStateProps["readonlySegments"]; + hourCycle: DateFieldRootStateProps["hourCycle"]; + locale: DateFieldRootStateProps["locale"]; + hideTimeZone: DateFieldRootStateProps["hideTimeZone"]; + name: DateFieldRootStateProps["name"]; + required: DateFieldRootStateProps["required"]; + descriptionId = useId(); + formatter: Formatter; + initialSegments: SegmentValueObj; + segmentValues = $state() as SegmentValueObj; + announcer: Announcer; + readonlySegmentsSet = $derived.by(() => new Set(this.readonlySegments.value)); + segmentStates = initSegmentStates(); + fieldNode = $state(null); + labelNode = $state(null); + descriptionNode = $state(null); + validationNode = $state(null); + states = initSegmentStates(); + dayPeriodNode = $state(null); + + constructor(props: DateFieldRootStateProps) { + this.value = props.value; + this.placeholder = props.placeholder; + this.isDateUnavailable = props.isDateUnavailable; + this.minValue = props.minValue; + this.maxValue = props.maxValue; + this.disabled = props.disabled; + this.readonly = props.readonly; + this.granularity = props.granularity; + this.readonlySegments = props.readonlySegments; + this.hourCycle = props.hourCycle; + this.locale = props.locale; + this.hideTimeZone = props.hideTimeZone; + this.name = props.name; + this.required = props.required; + this.formatter = createFormatter(this.locale.value); + this.initialSegments = initializeSegmentValues(this.inferredGranularity); + this.segmentValues = this.initialSegments; + + $effect(() => { + untrack(() => { + this.initialSegments = initializeSegmentValues(this.inferredGranularity); + }); + }); + + $effect(() => { + this.announcer = getAnnouncer(); + return () => { + removeDescriptionElement(this.descriptionId); + }; + }); + this.announcer = getAnnouncer(); + + $effect(() => { + if (this.formatter.getLocale() === this.locale.value) return; + this.formatter.setLocale(this.locale.value); + }); + + $effect(() => { + if (this.value.value) { + const descriptionId = untrack(() => this.descriptionId); + setDescription(descriptionId, this.formatter, this.value.value); + } + const placeholder = untrack(() => this.placeholder.value); + if (this.value.value && placeholder !== this.value.value) { + untrack(() => { + if (this.value.value) { + this.placeholder.value = this.value.value; + } + }); + } + }); + + if (this.value.value) { + this.syncSegmentValues(this.value.value); + } + + $effect(() => { + this.locale.value; + if (this.value.value) { + this.syncSegmentValues(this.value.value); + } + + this.clearUpdating(); + }); + } + + clearUpdating() { + this.states.day.updating = null; + this.states.month.updating = null; + this.states.year.updating = null; + this.states.hour.updating = null; + this.states.dayPeriod.updating = null; + } + + setValue(value: DateValue | undefined) { + this.value.value = value; + } + + syncSegmentValues(value: DateValue) { + const dateValues = DATE_SEGMENT_PARTS.map((part) => { + const partValue = value[part]; + + if (part === "month") { + if (this.states.month.updating) { + return [part, this.states.month.updating]; + } + if (partValue < 10) { + return [part, `0${partValue}`]; + } + } + + if (part === "day") { + if (this.states.day.updating) { + return [part, this.states.day.updating]; + } + + if (partValue < 10) { + return [part, `0${partValue}`]; + } + } + + if (part === "year") { + if (this.states.year.updating) { + return [part, this.states.year.updating]; + } + const valueDigits = `${partValue}`.length; + const diff = 4 - valueDigits; + if (diff > 0) { + return [part, `${"0".repeat(diff)}${partValue}`]; + } + } + + return [part, `${partValue}`]; + }); + if ("hour" in value) { + const timeValues = TIME_SEGMENT_PARTS.map((part) => { + if (part === "dayPeriod") { + if (this.states.dayPeriod.updating) { + return [part, this.states.dayPeriod.updating]; + } else { + return [part, this.formatter.dayPeriod(toDate(value))]; + } + } else if (part === "hour") { + if (value[part] === 0) { + /** + * If we're rendering a `dayPeriod` segment, we're operating in a + * 12-hour clock, so we never allow the displayed hour to be 0. + */ + if (this.dayPeriodNode) { + return [part, "12"]; + } + } + } + return [part, `${value[part]}`]; + }); + + const mergedSegmentValues = [...dateValues, ...timeValues]; + this.segmentValues = Object.fromEntries(mergedSegmentValues); + this.states.dayPeriod.updating = null; + return; + } + + this.segmentValues = Object.fromEntries(dateValues); + } + + isInvalid = $derived.by(() => { + const value = this.value.value; + if (!value) return false; + if (this.isDateUnavailable.value?.(value)) return true; + const minValue = this.minValue.value; + if (minValue && isBefore(value, minValue)) return true; + const maxValue = this.maxValue.value; + if (maxValue && isBefore(maxValue, value)) return true; + return false; + }); + + inferredGranularity = $derived.by(() => { + const granularity = this.granularity.value; + if (granularity) return granularity; + const inferred = inferGranularity(this.placeholder.value, this.granularity.value); + return inferred; + }); + + allSegmentContent = $derived.by(() => + createContent({ + segmentValues: this.segmentValues, + formatter: this.formatter, + locale: this.locale.value, + granularity: this.inferredGranularity, + dateRef: this.placeholder.value, + hideTimeZone: this.hideTimeZone.value, + hourCycle: this.hourCycle.value, + }) + ); + + segmentContents = $derived.by(() => this.allSegmentContent.arr); + + sharedSegmentAttrs = { + role: "spinbutton", + contenteditable: true, + tabindex: 0, + spellcheck: false, + inputmode: "numeric", + autocorrect: "off", + eterkeyhint: "next", + style: { + caretColor: "transparent", + }, + }; + + getLabelledBy = (segmentId: string) => { + return `${segmentId} ${this.labelNode?.id ?? ""}`; + }; + + updateSegment = ( + part: T, + cb: T extends DateSegmentPart + ? Updater + : T extends TimeSegmentPart + ? Updater + : Updater + ) => { + const disabled = this.disabled.value; + const readonly = this.readonly.value; + const readonlySegmentsSet = this.readonlySegmentsSet; + if (disabled || readonly || readonlySegmentsSet.has(part)) return; + + const prev = this.segmentValues; + + let newSegmentValues: SegmentValueObj = prev; + + const dateRef = this.placeholder.value; + if (isDateAndTimeSegmentObj(prev)) { + const pVal = prev[part]; + const castCb = cb as Updater; + if (part === "month") { + const next = castCb(pVal) as DateAndTimeSegmentObj["month"]; + this.states.month.updating = next; + if (next !== null && prev.day !== null) { + const date = dateRef.set({ month: parseInt(next) }); + const daysInMonth = getDaysInMonth(toDate(date)); + const prevDay = parseInt(prev.day); + if (prevDay > daysInMonth) { + prev.day = `${daysInMonth}`; + } + } + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "dayPeriod") { + const next = castCb(pVal) as DateAndTimeSegmentObj["dayPeriod"]; + this.states.dayPeriod.updating = next; + const date = this.value.value; + if (date && "hour" in date) { + const trueHour = date.hour; + if (next === "AM") { + if (trueHour >= 12) { + prev.hour = `${trueHour - 12}`; + } + } else if (next === "PM") { + if (trueHour < 12) { + prev.hour = `${trueHour + 12}`; + } + } + } + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "hour") { + const next = castCb(pVal) as DateAndTimeSegmentObj["hour"]; + if (next !== null && prev.dayPeriod !== null) { + const dayPeriod = this.formatter.dayPeriod( + toDate(dateRef.set({ hour: parseInt(next) })) + ); + if (dayPeriod === "AM" || dayPeriod === "PM") { + prev.dayPeriod = dayPeriod; + } + } + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "year") { + const next = castCb(pVal) as DateAndTimeSegmentObj["year"]; + this.states.year.updating = next; + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "day") { + const next = castCb(pVal) as DateAndTimeSegmentObj["day"]; + this.states.day.updating = next; + newSegmentValues = { ...prev, [part]: next }; + } else { + const next = castCb(pVal); + newSegmentValues = { ...prev, [part]: next }; + } + } else if (isDateSegmentPart(part)) { + const pVal = prev[part]; + const castCb = cb as Updater; + const next = castCb(pVal); + if (part === "month" && next !== null && prev.day !== null) { + this.states.month.updating = next; + const date = dateRef.set({ month: parseInt(next) }); + const daysInMonth = getDaysInMonth(toDate(date)); + if (parseInt(prev.day) > daysInMonth) { + prev.day = `${daysInMonth}`; + } + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "year") { + const next = castCb(pVal) as DateAndTimeSegmentObj["year"]; + this.states.year.updating = next; + newSegmentValues = { ...prev, [part]: next }; + } else if (part === "day") { + const next = castCb(pVal) as DateAndTimeSegmentObj["day"]; + this.states.day.updating = next; + newSegmentValues = { ...prev, [part]: next }; + } else { + newSegmentValues = { ...prev, [part]: next }; + } + } + this.segmentValues = newSegmentValues; + if (areAllSegmentsFilled(newSegmentValues, this.fieldNode)) { + this.setValue( + getValueFromSegments({ + segmentObj: newSegmentValues, + fieldNode: this.fieldNode, + dateRef: this.placeholder.value, + }) + ); + } else { + this.setValue(undefined); + this.segmentValues = newSegmentValues; + } + }; + + handleSegmentClick = (e: MouseEvent) => { + if (this.disabled.value) { + e.preventDefault(); + return; + } + }; + + getBaseSegmentAttrs = (part: SegmentPart, segmentId: string) => { + const inReadonlySegments = this.readonlySegmentsSet.has(part); + const defaultAttrs = { + "aria-invalid": getAriaInvalid(this.isInvalid), + "aria-disabled": getAriaDisabled(this.disabled.value), + "aria-readonly": getAriaReadonly(this.readonly.value || inReadonlySegments), + "data-invalid": getDataInvalid(this.isInvalid), + "data-disabled": getDataDisabled(this.disabled.value), + "data-segment": `${part}`, + }; + + if (part === "literal") return defaultAttrs; + + const descriptionId = this.descriptionNode?.id; + const hasDescription = isFirstSegment(segmentId, this.fieldNode) && descriptionId; + const validationId = this.validationNode?.id; + + const describedBy = hasDescription + ? `${descriptionId} ${this.isInvalid && validationId ? validationId : ""}` + : undefined; + + const contenteditable = + this.readonly.value || inReadonlySegments || this.disabled.value ? false : undefined; + + return { + ...defaultAttrs, + "aria-labelledby": this.getLabelledBy(segmentId), + contenteditable, + "aria-describedby": describedBy, + tabindex: this.disabled.value ? undefined : 0, + }; + }; + + createInput(props: DateFieldInputStateProps) { + return new DateFieldInputState(props, this); + } + + createLabel(props: DateFieldLabelStateProps) { + return new DateFieldLabelState(props, this); + } + + createHiddenInput() { + return new DateFieldHiddenInputState(this); + } + + createSegment(part: SegmentPart, props: WithRefProps) { + return segmentPartToInstance({ + part, + segmentProps: props, + root: this, + }); + } +} + +type DateFieldInputStateProps = WithRefProps; + +class DateFieldInputState { + #id: DateFieldInputStateProps["id"]; + #ref: DateFieldInputStateProps["ref"]; + root: DateFieldRootState; + + constructor(props: DateFieldInputStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.root = root; + + useRefById({ + id: this.#id, + ref: this.#ref, + onRefChange: (node) => { + this.root.fieldNode = node; + }, + }); + } + + #ariaDescribedBy = $derived.by(() => { + if (!isBrowser) return undefined; + const doesDescriptionExist = document.getElementById(this.root.descriptionId); + if (!doesDescriptionExist) return undefined; + return this.root.descriptionId; + }); + + props = $derived.by( + () => + ({ + id: this.#id.value, + role: "group", + "aria-labelledby": this.root.labelNode?.id ?? undefined, + "aria-describedby": this.#ariaDescribedBy, + "aria-disabled": getAriaDisabled(this.root.disabled.value), + "data-invalid": this.root.isInvalid ? "" : undefined, + "data-disabled": getDataDisabled(this.root.disabled.value), + }) as const + ); +} + +class DateFieldHiddenInputState { + #root: DateFieldRootState; + shouldRender = $derived.by(() => this.#root.name.value !== ""); + isoValue = $derived.by(() => (this.#root.value.value ? this.#root.value.value.toString() : "")); + + constructor(root: DateFieldRootState) { + this.#root = root; + } + + props = $derived.by(() => { + return { + name: this.#root.name.value, + value: this.isoValue, + required: this.#root.required.value, + }; + }); +} + +type DateFieldLabelStateProps = WithRefProps; + +class DateFieldLabelState { + #id: DateFieldLabelStateProps["id"]; + #ref: DateFieldLabelStateProps["ref"]; + #root: DateFieldRootState; + + constructor(props: DateFieldLabelStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + + useRefById({ + id: this.#id, + ref: this.#ref, + onRefChange: (node) => { + this.#root.labelNode = node; + }, + }); + } + + #onclick = () => { + if (this.#root.disabled.value) return; + const firstSegment = getFirstSegment(this.#root.fieldNode); + if (!firstSegment) return; + firstSegment.focus(); + }; + + props = $derived.by( + () => + ({ + id: this.#id.value, + "data-invalid": getDataInvalid(this.#root.isInvalid), + "data-disabled": getDataDisabled(this.#root.disabled.value), + onclick: this.#onclick, + }) as const + ); +} + +type DateFieldDaySegmentStateProps = WithRefProps; + +class DateFieldDaySegmentState { + #id: DateFieldDaySegmentStateProps["id"]; + #ref: DateFieldDaySegmentStateProps["ref"]; + #root: DateFieldRootState; + #announcer: Announcer; + #updateSegment: DateFieldRootState["updateSegment"]; + + constructor(props: DateFieldDaySegmentStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + this.#announcer = this.#root.announcer; + this.#updateSegment = this.#root.updateSegment; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + #onkeydown = (e: KeyboardEvent) => { + if (e.key !== kbd.TAB) e.preventDefault(); + if (this.#root.disabled.value) return; + if (!isAcceptableSegmentKey(e.key)) return; + + const segmentMonthValue = this.#root.segmentValues.month; + const placeholder = this.#root.placeholder.value; + + const daysInMonth = segmentMonthValue + ? getDaysInMonth(placeholder.set({ month: parseInt(segmentMonthValue) })) + : getDaysInMonth(placeholder); + + if (isArrowUp(e.key)) { + this.#updateSegment("day", (prev) => { + if (prev === null) { + const next = placeholder.day; + this.#announcer.announce(next); + return `${next}`; + } + const next = placeholder.set({ day: parseInt(prev) }).cycle("day", 1).day; + this.#announcer.announce(next); + return `${next}`; + }); + return; + } + if (isArrowDown(e.key)) { + this.#updateSegment("day", (prev) => { + if (prev === null) { + const next = placeholder.day; + this.#announcer.announce(next); + return `${next}`; + } + const next = placeholder.set({ day: parseInt(prev) }).cycle("day", -1).day; + this.#announcer.announce(next); + return `${next}`; + }); + return; + } + + const fieldNode = this.#root.fieldNode; + + if (isNumberString(e.key)) { + const num = parseInt(e.key); + let moveToNext = false; + this.#updateSegment("day", (prev) => { + const max = daysInMonth; + const maxStart = Math.floor(max / 10); + const numIsZero = num === 0; + + /** + * If the user has left the segment, we want to reset the + * `prev` value so that we can start the segment over again + * when the user types a number. + */ + if (this.#root.states.day.hasLeftFocus) { + prev = null; + this.#root.states.day.hasLeftFocus = false; + } + + /** + * We are starting over in the segment if prev is null, which could + * happen in one of two scenarios: + * - the user has left the segment and then comes back to it + * - the segment was empty and the user begins typing a number + */ + if (prev === null) { + /** + * If the user types a 0 as the first number, we want + * to keep track of that so that when they type the next + * number, we can move to the next segment. + */ + if (numIsZero) { + this.#root.states.day.lastKeyZero = true; + this.#announcer.announce("0"); + return "0"; + } + + /////////////////////////// + + /** + * If the last key was a 0, or if the first number is + * greater than the max start digit (0-3 in most cases), then + * we want to move to the next segment, since it's not possible + * to continue typing a valid number in this segment. + */ + if (this.#root.states.day.lastKeyZero || num > maxStart) { + moveToNext = true; + } + + this.#root.states.day.lastKeyZero = false; + + /** + * If we're moving to the next segment and the number is less than + * two digits, we want to announce the number and return it with a + * leading zero to follow the placeholder format of `MM/DD/YYYY`. + */ + if (moveToNext && String(num).length === 1) { + this.#announcer.announce(num); + return `0${num}`; + } + + /** + * If none of the above conditions are met, then we can just + * return the number as the segment value and continue typing + * in this segment. + */ + return `${num}`; + } + + /** + * If the number of digits is 2, or if the total with the existing digit + * and the pressed digit is greater than the maximum value for this + * month, then we will reset the segment as if the user had pressed the + * backspace key and then typed the number. + */ + const total = parseInt(prev + num.toString()); + + if (this.#root.states.day.lastKeyZero) { + /** + * If the new number is not 0, then we reset the lastKeyZero state and + * move to the next segment, returning the new number with a leading 0. + */ + if (num !== 0) { + moveToNext = true; + this.#root.states.day.lastKeyZero = false; + return `0${num}`; + } + + /** + * If the new number is 0, then we simply return the previous value, since + * they didn't actually type a new number. + */ + return prev; + } + + /** + * If the total is greater than the max day value possible for this month, then + * we want to move to the next segment, trimming the first digit from the total, + * replacing it with a 0. + */ + if (total > max) { + moveToNext = true; + return `0${num}`; + } + + /** + * If the total has two digits and is less than or equal to the max day value, + * we will move to the next segment and return the total as the segment value. + */ + moveToNext = true; + return `${total}`; + }); + + if (moveToNext) { + moveToNextSegment(e, fieldNode); + } + } + + if (isBackspace(e.key)) { + let moveToPrev = false; + this.#updateSegment("day", (prev) => { + this.#root.states.day.hasLeftFocus = false; + if (prev === null) { + moveToPrev = true; + return null; + } + if (prev.length === 2 && prev.startsWith("0")) { + return null; + } + const str = prev.toString(); + if (str.length === 1) return null; + return str.slice(0, -1); + }); + + if (moveToPrev) { + moveToPrevSegment(e, fieldNode); + } + } + + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, fieldNode); + } + }; + + #onfocusout = () => { + this.#root.states.day.hasLeftFocus = true; + this.#updateSegment("month", (prev) => { + if (prev && prev.length === 1) { + return `0${prev}`; + } + return prev; + }); + }; + + props = $derived.by(() => { + const segmentValues = this.#root.segmentValues; + const isEmpty = segmentValues.day === null; + const placeholder = this.#root.placeholder.value; + const date = segmentValues.day + ? placeholder.set({ day: parseInt(segmentValues.day) }) + : placeholder; + + const valueNow = date.day; + const valueMin = 1; + const valueMax = getDaysInMonth(toDate(date)); + const valueText = isEmpty ? "Empty" : `${valueNow}`; + + return { + ...this.#root.sharedSegmentAttrs, + id: this.#id.value, + "aria-label": "day,", + "aria-valuemin": valueMin, + "aria-valuemax": valueMax, + "aria-valuenow": valueNow, + "aria-valuetext": valueText, + onkeydown: this.#onkeydown, + onfocusout: this.#onfocusout, + onclick: this.#root.handleSegmentClick, + ...this.#root.getBaseSegmentAttrs("day", this.#id.value), + }; + }); +} + +type DateFieldMonthSegmentStateProps = WithRefProps; + +class DateFieldMonthSegmentState { + #id: DateFieldMonthSegmentStateProps["id"]; + #ref: DateFieldMonthSegmentStateProps["ref"]; + #root: DateFieldRootState; + #announcer: Announcer; + #updateSegment: DateFieldRootState["updateSegment"]; + + constructor(props: DateFieldMonthSegmentStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + this.#announcer = this.#root.announcer; + this.#updateSegment = this.#root.updateSegment; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + getAnnouncement = (month: number) => { + return `${month} - ${this.#root.formatter.fullMonth(toDate(this.#root.placeholder.value.set({ month })))}`; + }; + + #onkeydown = (e: KeyboardEvent) => { + if (e.key !== kbd.TAB) e.preventDefault(); + if (this.#root.disabled.value) return; + if (!isAcceptableSegmentKey(e.key)) return; + + const placeholder = this.#root.placeholder.value; + const max = 12; + + if (isArrowUp(e.key)) { + this.#updateSegment("month", (prev) => { + if (prev === null) { + const next = placeholder.month; + this.#announcer.announce(this.getAnnouncement(next)); + + if (String(next).length === 1) { + return `0${next}`; + } + + return `${next}`; + } + const next = placeholder.set({ month: parseInt(prev) }).cycle("month", 1).month; + this.#announcer.announce(this.getAnnouncement(next)); + if (String(next).length === 1) { + return `0${next}`; + } + return `${next}`; + }); + return; + } + + if (isArrowDown(e.key)) { + this.#updateSegment("month", (prev) => { + if (prev === null) { + const next = placeholder.month; + this.#announcer.announce(this.getAnnouncement(next)); + if (String(next).length === 1) { + return `0${next}`; + } + return `${next}`; + } + const next = placeholder.set({ month: parseInt(prev) }).cycle("month", -1).month; + this.#announcer.announce(this.getAnnouncement(next)); + if (String(next).length === 1) { + return `0${next}`; + } + return `${next}`; + }); + return; + } + + if (isNumberString(e.key)) { + const num = parseInt(e.key); + let moveToNext = false; + + this.#updateSegment("month", (prev) => { + const maxStart = Math.floor(max / 10); + const numIsZero = num === 0; + + /** + * If the user has left the segment, we want to reset the + * `prev` value so that we can start the segment over again + * when the user types a number. + */ + if (this.#root.states.month.hasLeftFocus) { + prev = null; + this.#root.states.month.hasLeftFocus = false; + } + + /** + * We are starting over in the segment if prev is null, which could + * happen in one of two scenarios: + * - the user has left the segment and then comes back to it + * - the segment was empty and the user begins typing a number + */ + if (prev === null) { + /** + * If the user types a 0 as the first number, we want + * to keep track of that so that when they type the next + * number, we can move to the next segment. + */ + if (numIsZero) { + this.#root.states.month.lastKeyZero = true; + this.#announcer.announce("0"); + return "0"; + } + + /////////////////////////// + + /** + * If the last key was a 0, or if the first number is + * greater than the max start digit (0-3 in most cases), then + * we want to move to the next segment, since it's not possible + * to continue typing a valid number in this segment. + */ + if (this.#root.states.month.lastKeyZero || num > maxStart) { + moveToNext = true; + } + + this.#root.states.month.lastKeyZero = false; + + /** + * If we're moving to the next segment and the number is less than + * two digits, we want to announce the number and return it with a + * leading zero to follow the placeholder format of `MM/DD/YYYY`. + */ + if (moveToNext && String(num).length === 1) { + this.#announcer.announce(num); + return `0${num}`; + } + + /** + * If none of the above conditions are met, then we can just + * return the number as the segment value and continue typing + * in this segment. + */ + return `${num}`; + } + + /** + * If the number of digits is 2, or if the total with the existing digit + * and the pressed digit is greater than the maximum value for this + * month, then we will reset the segment as if the user had pressed the + * backspace key and then typed the number. + */ + const total = parseInt(prev + num.toString()); + + if (this.#root.states.month.lastKeyZero) { + /** + * If the new number is not 0, then we reset the lastKeyZero state and + * move to the next segment, returning the new number with a leading 0. + */ + if (num !== 0) { + moveToNext = true; + this.#root.states.month.lastKeyZero = false; + return `0${num}`; + } + + /** + * If the new number is 0, then we simply return the previous value, since + * they didn't actually type a new number. + */ + return prev; + } + + /** + * If the total is greater than the max day value possible for this month, then + * we want to move to the next segment, trimming the first digit from the total, + * replacing it with a 0. + */ + if (total > max) { + moveToNext = true; + return `0${num}`; + } + + /** + * If the total has two digits and is less than or equal to the max day value, + * we will move to the next segment and return the total as the segment value. + */ + moveToNext = true; + return `${total}`; + }); + + if (moveToNext) { + moveToNextSegment(e, this.#root.fieldNode); + } + } + + if (isBackspace(e.key)) { + this.#root.states.month.hasLeftFocus = false; + let moveToPrev = false; + this.#updateSegment("month", (prev) => { + if (prev === null) { + this.#announcer.announce(null); + moveToPrev = true; + return null; + } + + if (prev.length === 2 && prev.startsWith("0")) { + this.#announcer.announce(null); + return null; + } + + const str = prev.toString(); + if (str.length === 1) { + this.#announcer.announce(null); + return null; + } + const next = parseInt(str.slice(0, -1)); + this.#announcer.announce(this.getAnnouncement(next)); + return `${next}`; + }); + + if (moveToPrev) { + moveToPrevSegment(e, this.#root.fieldNode); + } + } + + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.#root.fieldNode); + } + }; + + #onfocusout = () => { + this.#root.states.month.hasLeftFocus = true; + this.#updateSegment("month", (prev) => { + if (prev && prev.length === 1) { + return `0${prev}`; + } + return prev; + }); + }; + + props = $derived.by(() => { + const segmentValues = this.#root.segmentValues; + const placeholder = this.#root.placeholder.value; + const isEmpty = segmentValues.month === null; + const date = segmentValues.month + ? placeholder.set({ month: parseInt(segmentValues.month) }) + : placeholder; + const valueNow = date.month; + const valueMin = 1; + const valueMax = 12; + const valueText = isEmpty + ? "Empty" + : `${valueNow} - ${this.#root.formatter.fullMonth(toDate(date))}`; + + return { + ...this.#root.sharedSegmentAttrs, + id: this.#id.value, + "aria-label": "month, ", + contenteditable: true, + "aria-valuemin": valueMin, + "aria-valuemax": valueMax, + "aria-valuenow": valueNow, + "aria-valuetext": valueText, + onkeydown: this.#onkeydown, + onfocusout: this.#onfocusout, + onclick: this.#root.handleSegmentClick, + ...this.#root.getBaseSegmentAttrs("month", this.#id.value), + } as const; + }); +} + +type DateFieldYearSegmentStateProps = WithRefProps; + +class DateFieldYearSegmentState { + #id: DateFieldYearSegmentStateProps["id"]; + #ref: DateFieldYearSegmentStateProps["ref"]; + #root: DateFieldRootState; + #announcer: Announcer; + #updateSegment: DateFieldRootState["updateSegment"]; + + /** + * When typing a year, a user may want to type `0090` to represent `90`. + * So we track the keys they've pressed in this specific interaction to + * determine once they've pressed four to move to the next segment. + * + * On `focusout` this is reset to an empty array. + */ + #pressedKeys: string[] = []; + + /** + * When a user re-enters a completed segment and backspaces, if they have + * leading zeroes on the year, they won't automatically be sent to the next + * segment even if they complete all 4 digits. This is because the leading zeroes + * get stripped out for the digit count. + * + * This lets us keep track of how many times the user has backspaced in a row + * to determine how many additional keypresses should move them to the next segment. + * + * For example, if the user has `0098` in the year segment and backspaces once, + * the segment will contain `009` and if the user types `7`, the segment should + * contain `0097` and move to the next segment. + * + * If the segment contains `0100` and the user backspaces twice, the segment will + * contain `01` and if the user types `2`, the segment should contain `012` and + * it should _not_ move to the next segment until the user types another digit. + */ + #backspaceCount = 0; + + constructor(props: DateFieldYearSegmentStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + this.#announcer = this.#root.announcer; + this.#updateSegment = this.#root.updateSegment; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + #resetBackspaceCount() { + this.#backspaceCount = 0; + } + + #incrementBackspaceCount() { + this.#backspaceCount++; + } + + #onkeydown = (e: KeyboardEvent) => { + if (e.key !== kbd.TAB) e.preventDefault(); + if (this.#root.disabled.value || !isAcceptableSegmentKey(e.key)) return; + + const placeholder = this.#root.placeholder.value; + + if (isArrowUp(e.key)) { + this.#resetBackspaceCount(); + this.#updateSegment("year", (prev) => { + if (prev === null) { + const next = placeholder.year; + this.#announcer.announce(next); + return `${next}`; + } + const next = placeholder.set({ year: parseInt(prev) }).cycle("year", 1).year; + this.#announcer.announce(next); + return `${next}`; + }); + return; + } + if (isArrowDown(e.key)) { + this.#resetBackspaceCount(); + this.#updateSegment("year", (prev) => { + if (prev === null) { + const next = placeholder.year; + this.#announcer.announce(next); + return `${next}`; + } + const next = placeholder.set({ year: parseInt(prev) }).cycle("year", -1).year; + this.#announcer.announce(next); + return `${next}`; + }); + return; + } + + if (isNumberString(e.key)) { + this.#pressedKeys.push(e.key); + let moveToNext = false; + const num = parseInt(e.key); + this.#updateSegment("year", (prev) => { + if (this.#root.states.year.hasLeftFocus) { + prev = null; + this.#root.states.year.hasLeftFocus = false; + } + + if (prev === null) { + this.#announcer.announce(num); + return `000${num}`; + } + + const str = prev.toString() + num.toString(); + const mergedInt = parseInt(str); + const mergedIntDigits = String(mergedInt).length; + + if (mergedIntDigits < 4) { + /** + * If the user has backspaced and hasn't typed enough digits to make up + * for the amount of backspaces, then we want to keep them in the segment + * and not prepend any zeroes to the number. + */ + if ( + this.#backspaceCount > 0 && + this.#pressedKeys.length <= this.#backspaceCount && + str.length <= 4 + ) { + this.#announcer.announce(mergedInt); + return str; + } + + /** + * If the mergedInt is less than 4 digits and we haven't backspaced, + * then we want to prepend zeroes to the number to keep the format + * of `YYYY` + */ + this.#announcer.announce(mergedInt); + return prependYearZeros(mergedInt); + } + + this.#announcer.announce(mergedInt); + moveToNext = true; + return `${mergedInt}`; + }); + + if ( + this.#pressedKeys.length === 4 || + this.#pressedKeys.length === this.#backspaceCount + ) { + moveToNext = true; + } + + if (moveToNext) { + moveToNextSegment(e, this.#root.fieldNode); + } + } + + if (isBackspace(e.key)) { + this.#pressedKeys = []; + this.#incrementBackspaceCount(); + let moveToPrev = false; + this.#updateSegment("year", (prev) => { + this.#root.states.year.hasLeftFocus = false; + if (prev === null) { + moveToPrev = true; + this.#announcer.announce(null); + return null; + } + const str = prev.toString(); + if (str.length === 1) { + this.#announcer.announce(null); + return null; + } + const next = str.slice(0, -1); + this.#announcer.announce(next); + + return `${next}`; + }); + + if (moveToPrev) { + moveToPrevSegment(e, this.#root.fieldNode); + } + } + + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.#root.fieldNode); + } + }; + + #onfocusout = () => { + this.#root.states.year.hasLeftFocus = true; + this.#pressedKeys = []; + this.#resetBackspaceCount(); + this.#updateSegment("year", (prev) => { + if (prev && prev.length !== 4) { + return prependYearZeros(parseInt(prev)); + } + return prev; + }); + }; + + props = $derived.by(() => { + const segmentValues = this.#root.segmentValues; + const placeholder = this.#root.placeholder.value; + const isEmpty = segmentValues.year === null; + const date = segmentValues.year + ? placeholder.set({ year: parseInt(segmentValues.year) }) + : placeholder; + const valueMin = 1; + const valueMax = 9999; + const valueNow = date.year; + const valueText = isEmpty ? "Empty" : `${valueNow}`; + + return { + ...this.#root.sharedSegmentAttrs, + id: this.#id.value, + "aria-label": "year, ", + "aria-valuemin": valueMin, + "aria-valuemax": valueMax, + "aria-valuenow": valueNow, + "aria-valuetext": valueText, + onkeydown: this.#onkeydown, + onclick: this.#root.handleSegmentClick, + onfocusout: this.#onfocusout, + ...this.#root.getBaseSegmentAttrs("year", this.#id.value), + }; + }); +} + +type DateFieldHourSegmentStateProps = WithRefProps; + +class DateFieldHourSegmentState { + #id: DateFieldHourSegmentStateProps["id"]; + #ref: DateFieldHourSegmentStateProps["ref"]; + #root: DateFieldRootState; + #announcer: Announcer; + #updateSegment: DateFieldRootState["updateSegment"]; + + constructor(props: DateFieldHourSegmentStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + this.#announcer = this.#root.announcer; + this.#updateSegment = this.#root.updateSegment; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + #onkeydown = (e: KeyboardEvent) => { + if (e.key !== kbd.TAB) e.preventDefault(); + const placeholder = this.#root.placeholder.value; + if ( + this.#root.disabled.value || + !isAcceptableSegmentKey(e.key) || + !("hour" in placeholder) + ) { + return; + } + + const hourCycle = this.#root.hourCycle.value; + + if (isArrowUp(e.key)) { + this.#updateSegment("hour", (prev) => { + if (prev === null) { + const next = placeholder.cycle("hour", 1, { hourCycle }).hour; + this.#announcer.announce(next); + return `${next}`; + } + const next = placeholder + .set({ hour: parseInt(prev) }) + .cycle("hour", 1, { hourCycle }).hour; + this.#announcer.announce(next); + return `${next}`; + }); + return; + } + + if (isArrowDown(e.key)) { + this.#updateSegment("hour", (prev) => { + if (prev === null) { + const next = placeholder.cycle("hour", -1, { hourCycle }).hour; + this.#announcer.announce(next); + return `${next}`; + } + const next = placeholder + .set({ hour: parseInt(prev) }) + .cycle("hour", -1, { hourCycle }).hour; + this.#announcer.announce(next); + return `${next}`; + }); + return; + } + + if (isNumberString(e.key)) { + const num = parseInt(e.key); + const max = 24; + const maxStart = Math.floor(max / 10); + let moveToNext = false; + this.#updateSegment("hour", (prev) => { + /** + * If the user has left the segment, we want to reset the + * `prev` value so that we can start the segment over again + * when the user types a number. + */ + if (this.#root.states.hour.hasLeftFocus) { + prev = null; + this.#root.states.hour.hasLeftFocus = false; + } + + if (prev === null) { + /** + * If the user types a 0 as the first number, we want + * to keep track of that so that when they type the next + * number, we can move to the next segment. + */ + if (num === 0) { + this.#root.states.hour.lastKeyZero = true; + this.#announcer.announce(null); + return null; + } + + /** + * If the last key was a 0, or if the first number is + * greater than the max start digit, then we want to move + * to the next segment, since it's not possible to continue + * typing a valid number in this segment. + */ + if (this.#root.states.hour.lastKeyZero || num > maxStart) { + moveToNext = true; + } + + this.#root.states.hour.lastKeyZero = false; + this.#announcer.announce(num); + /** + * If none of the above conditions are met, then we can just + * return the number as the segment value and continue typing + * in this segment. + */ + return `${num}`; + } + + const digits = prev.toString().length; + const total = parseInt(prev.toString() + num.toString()); + + /** + * If the number of digits is 2, or if the total with the existing digit + * and the pressed digit is greater than the maximum value, then we will + * reset the segment as if the user had pressed the backspace key and then + * typed a number. + */ + if (digits === 2 || total > max) { + /** + * As we're doing elsewhere, we're checking if the number is greater + * than the max start digit, and if so, we're moving to the next segment. + */ + if (num > maxStart) { + moveToNext = true; + } + this.#announcer.announce(num); + return `${num}`; + } + moveToNext = true; + this.#announcer.announce(total); + return `${total}`; + }); + + if (moveToNext) { + moveToNextSegment(e, this.#root.fieldNode); + } + } + + if (isBackspace(e.key)) { + this.#root.states.hour.hasLeftFocus = false; + let moveToPrev = false; + this.#updateSegment("hour", (prev) => { + if (prev === null) { + this.#announcer.announce(null); + moveToPrev = true; + return null; + } + const str = prev.toString(); + if (str.length === 1) { + this.#announcer.announce(null); + return null; + } + const next = parseInt(str.slice(0, -1)); + this.#announcer.announce(next); + return `${next}`; + }); + + if (moveToPrev) { + moveToPrevSegment(e, this.#root.fieldNode); + } + } + + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.#root.fieldNode); + } + }; + + #onfocusout = () => { + this.#root.states.hour.hasLeftFocus = true; + }; + + props = $derived.by(() => { + const segmentValues = this.#root.segmentValues; + const hourCycle = this.#root.hourCycle.value; + const placeholder = this.#root.placeholder.value; + if (!("hour" in segmentValues) || !("hour" in placeholder)) return {}; + const isEmpty = segmentValues.hour === null; + const date = segmentValues.hour + ? placeholder.set({ hour: parseInt(segmentValues.hour) }) + : placeholder; + const valueMin = hourCycle === 12 ? 1 : 0; + const valueMax = hourCycle === 12 ? 12 : 23; + const valueNow = date.hour; + const valueText = isEmpty ? "Empty" : `${valueNow} ${segmentValues.dayPeriod ?? ""}`; + + return { + ...this.#root.sharedSegmentAttrs, + id: this.#id.value, + "aria-label": "hour, ", + "aria-valuemin": valueMin, + "aria-valuemax": valueMax, + "aria-valuenow": valueNow, + "aria-valuetext": valueText, + onkeydown: this.#onkeydown, + onfocusout: this.#onfocusout, + onclick: this.#root.handleSegmentClick, + ...this.#root.getBaseSegmentAttrs("hour", this.#id.value), + }; + }); +} + +type DateFieldMinuteSegmentStateProps = WithRefProps; + +class DateFieldMinuteSegmentState { + #id: DateFieldMinuteSegmentStateProps["id"]; + #ref: DateFieldMinuteSegmentStateProps["ref"]; + #root: DateFieldRootState; + #announcer: Announcer; + #updateSegment: DateFieldRootState["updateSegment"]; + + constructor(props: DateFieldMinuteSegmentStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + this.#announcer = this.#root.announcer; + this.#updateSegment = this.#root.updateSegment; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + #onkeydown = (e: KeyboardEvent) => { + if (e.key !== kbd.TAB) e.preventDefault(); + const placeholder = this.#root.placeholder.value; + if ( + this.#root.disabled.value || + !isAcceptableSegmentKey(e.key) || + !("minute" in placeholder) + ) + return; + + const min = 0; + const max = 59; + + if (isArrowUp(e.key)) { + this.#updateSegment("minute", (prev) => { + if (prev === null) { + this.#announcer.announce(min); + return `${min}`; + } + const next = placeholder.set({ minute: parseInt(prev) }).cycle("minute", 1).minute; + this.#announcer.announce(next); + return `${next}`; + }); + return; + } + + if (isArrowDown(e.key)) { + this.#updateSegment("minute", (prev) => { + if (prev === null) { + this.#announcer.announce(max); + return `${max}`; + } + const next = placeholder.set({ minute: parseInt(prev) }).cycle("minute", -1).minute; + this.#announcer.announce(next); + return `${next}`; + }); + return; + } + + if (isNumberString(e.key)) { + const num = parseInt(e.key); + let moveToNext = false; + this.#updateSegment("minute", (prev) => { + const maxStart = Math.floor(max / 10); + + /** + * If the user has left the segment, we want to reset the + * `prev` value so that we can start the segment over again + * when the user types a number. + */ + if (this.#root.states.minute.hasLeftFocus) { + prev = null; + this.#root.states.minute.hasLeftFocus = false; + } + + if (prev === null) { + /** + * If the user types a 0 as the first number, we want + * to keep track of that so that when they type the next + * number, we can move to the next segment. + */ + if (num === 0) { + this.#root.states.minute.lastKeyZero = true; + this.#announcer.announce(null); + return "0"; + } + + /** + * If the last key was a 0, or if the first number is + * greater than the max start digit, then we want to move + * to the next segment, since it's not possible to continue + * typing a valid number in this segment. + */ + if (this.#root.states.minute.lastKeyZero || num > maxStart) { + moveToNext = true; + } + + this.#root.states.minute.lastKeyZero = false; + this.#announcer.announce(num); + /** + * If none of the above conditions are met, then we can just + * return the number as the segment value and continue typing + * in this segment. + */ + return `${num}`; + } + + const digits = prev.length; + const total = parseInt(prev + num.toString()); + + /** + * If the number of digits is 2, or if the total with the existing digit + * and the pressed digit is greater than the maximum value, then we will + * reset the segment as if the user had pressed the backspace key and then + * typed a number. + */ + if (digits === 2 || total > max) { + /** + * As we're doing elsewhere, we're checking if the number is greater + * than the max start digit, and if so, we're moving to the next segment. + */ + if (num > maxStart) { + moveToNext = true; + } + this.#announcer.announce(num); + return `${num}`; + } + moveToNext = true; + this.#announcer.announce(total); + return `${total}`; + }); + + if (moveToNext) { + moveToNextSegment(e, this.#root.fieldNode); + } + return; + } + + if (isBackspace(e.key)) { + this.#root.states.minute.hasLeftFocus = false; + let moveToPrev = false; + this.#updateSegment("minute", (prev) => { + if (prev === null) { + moveToPrev = true; + this.#announcer.announce("Empty"); + return null; + } + const str = prev.toString(); + if (str.length === 1) { + this.#announcer.announce("Empty"); + return null; + } + const next = parseInt(str.slice(0, -1)); + this.#announcer.announce(next); + return `${next}`; + }); + + if (moveToPrev) { + moveToPrevSegment(e, this.#root.fieldNode); + } + return; + } + + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.#root.fieldNode); + } + }; + + #onfocusout = () => { + this.#root.states.minute.hasLeftFocus = true; + }; + + props = $derived.by(() => { + const segmentValues = this.#root.segmentValues; + const placeholder = this.#root.placeholder.value; + + if (!("minute" in segmentValues) || !("minute" in placeholder)) return {}; + const isEmpty = segmentValues.minute === null; + const date = segmentValues.minute + ? placeholder.set({ minute: parseInt(segmentValues.minute) }) + : placeholder; + const valueNow = date.minute; + const valueMin = 0; + const valueMax = 59; + const valueText = isEmpty ? "Empty" : `${valueNow}`; + + return { + ...this.#root.sharedSegmentAttrs, + id: this.#id.value, + "aria-label": "minute, ", + "aria-valuemin": valueMin, + "aria-valuemax": valueMax, + "aria-valuenow": valueNow, + "aria-valuetext": valueText, + onkeydown: this.#onkeydown, + onfocusout: this.#onfocusout, + onclick: this.#root.handleSegmentClick, + ...this.#root.getBaseSegmentAttrs("minute", this.#id.value), + }; + }); +} + +type DateFieldSecondSegmentStateProps = WithRefProps; + +class DateFieldSecondSegmentState { + #id: DateFieldSecondSegmentStateProps["id"]; + #ref: DateFieldSecondSegmentStateProps["ref"]; + #root: DateFieldRootState; + #announcer: Announcer; + #updateSegment: DateFieldRootState["updateSegment"]; + + constructor(props: DateFieldSecondSegmentStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + this.#announcer = this.#root.announcer; + this.#updateSegment = this.#root.updateSegment; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + #onkeydown = (e: KeyboardEvent) => { + if (e.key !== kbd.TAB) e.preventDefault(); + if (!isAcceptableSegmentKey(e.key) || this.#root.disabled.value) return; + + const min = 0; + const max = 59; + + const placeholder = this.#root.placeholder.value; + + if (!("second" in placeholder)) return; + + if (isArrowUp(e.key)) { + this.#updateSegment("second", (prev) => { + if (prev === null) { + this.#announcer.announce(min); + return `${min}`; + } + const next = placeholder.set({ second: parseInt(prev) }).cycle("second", 1).second; + this.#announcer.announce(next); + return `${next}`; + }); + return; + } + + if (isArrowDown(e.key)) { + this.#updateSegment("second", (prev) => { + if (prev === null) { + this.#announcer.announce(max); + return `${max}`; + } + const next = placeholder.set({ second: parseInt(prev) }).cycle("second", -1).second; + this.#announcer.announce(next); + return `${next}`; + }); + return; + } + + if (isNumberString(e.key)) { + const num = parseInt(e.key); + let moveToNext = false; + this.#updateSegment("second", (prev) => { + const maxStart = Math.floor(max / 10); + + /** + * If the user has left the segment, we want to reset the + * `prev` value so that we can start the segment over again + * when the user types a number. + */ + if (this.#root.states.second.hasLeftFocus) { + prev = null; + this.#root.states.second.hasLeftFocus = false; + } + + if (prev === null) { + /** + * If the user types a 0 as the first number, we want + * to keep track of that so that when they type the next + * number, we can move to the next segment. + */ + if (num === 0) { + this.#root.states.second.lastKeyZero = true; + this.#announcer.announce(null); + return "0"; + } + + /** + * If the last key was a 0, or if the first number is + * greater than the max start digit, then we want to move + * to the next segment, since it's not possible to continue + * typing a valid number in this segment. + */ + if (this.#root.states.second.lastKeyZero || num > maxStart) { + moveToNext = true; + } + this.#root.states.second.lastKeyZero = false; + + /** + * If none of the above conditions are met, then we can just + * return the number as the segment value and continue typing + * in this segment. + */ + this.#announcer.announce(num); + return `${num}`; + } + + const digits = prev.toString().length; + const total = parseInt(prev.toString() + num.toString()); + + /** + * If the number of digits is 2, or if the total with the existing digit + * and the pressed digit is greater than the maximum value, then we will + * reset the segment as if the user had pressed the backspace key and then + * typed a number. + */ + if (digits === 2 || total > max) { + /** + * As we're doing elsewhere, we're checking if the number is greater + * than the max start digit, and if so, we're moving to the next segment. + */ + if (num > maxStart) { + moveToNext = true; + } + this.#announcer.announce(num); + return `${num}`; + } + moveToNext = true; + this.#announcer.announce(total); + return `${total}`; + }); + + if (moveToNext) { + moveToNextSegment(e, this.#root.fieldNode); + } + } + + if (isBackspace(e.key)) { + this.#root.states.second.hasLeftFocus = false; + let moveToPrev = false; + this.#updateSegment("second", (prev) => { + if (prev === null) { + moveToPrev = true; + this.#announcer.announce(null); + return null; + } + const str = prev.toString(); + if (str.length === 1) { + this.#announcer.announce(null); + return null; + } + const next = parseInt(str.slice(0, -1)); + this.#announcer.announce(next); + return `${next}`; + }); + + if (moveToPrev) { + moveToPrevSegment(e, this.#root.fieldNode); + } + } + + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.#root.fieldNode); + } + }; + + #onfocusout = () => { + this.#root.states.second.hasLeftFocus = true; + }; + + props = $derived.by(() => { + const segmentValues = this.#root.segmentValues; + const placeholder = this.#root.placeholder.value; + if (!("second" in segmentValues) || !("second" in placeholder)) return {}; + const isEmpty = segmentValues.second === null; + const date = segmentValues.second + ? placeholder.set({ second: parseInt(segmentValues.second) }) + : placeholder; + const valueNow = date.second; + const valueMin = 0; + const valueMax = 59; + const valueText = isEmpty ? "Empty" : `${valueNow}`; + + return { + ...this.#root.sharedSegmentAttrs, + id: this.#id.value, + "aria-label": "second, ", + "aria-valuemin": valueMin, + "aria-valuemax": valueMax, + "aria-valuenow": valueNow, + "aria-valuetext": valueText, + onkeydown: this.#onkeydown, + onfocusout: this.#onfocusout, + onclick: this.#root.handleSegmentClick, + ...this.#root.getBaseSegmentAttrs("second", this.#id.value), + }; + }); +} + +type DateFieldDayPeriodSegmentStateProps = WithRefProps; + +class DateFieldDayPeriodSegmentState { + #id: DateFieldDayPeriodSegmentStateProps["id"]; + #ref: DateFieldDayPeriodSegmentStateProps["ref"]; + #root: DateFieldRootState; + #announcer: Announcer; + #updateSegment: DateFieldRootState["updateSegment"]; + + constructor(props: DateFieldMinuteSegmentStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + this.#announcer = this.#root.announcer; + this.#updateSegment = this.#root.updateSegment; + + useRefById({ + id: this.#id, + ref: this.#ref, + onRefChange: (node) => { + this.#root.dayPeriodNode = node; + }, + }); + } + + #onkeydown = (e: KeyboardEvent) => { + if (e.key !== kbd.TAB) e.preventDefault(); + if (!isAcceptableDayPeriodKey(e.key) || this.#root.disabled.value) return; + + if (isArrowUp(e.key) || isArrowDown(e.key)) { + this.#updateSegment("dayPeriod", (prev) => { + if (prev === "AM") { + const next = "PM"; + this.#announcer.announce(next); + return next; + } + const next = "AM"; + this.#announcer.announce(next); + return next; + }); + return; + } + + if (isBackspace(e.key)) { + this.#root.states.dayPeriod.hasLeftFocus = false; + this.#updateSegment("dayPeriod", () => { + const next = "AM"; + this.#announcer.announce(next); + return next; + }); + } + + if (e.key === kbd.A || e.key === kbd.P) { + this.#updateSegment("dayPeriod", () => { + const next = e.key === kbd.A ? "AM" : "PM"; + this.#announcer.announce(next); + return next; + }); + } + + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.#root.fieldNode); + } + }; + + props = $derived.by(() => { + const segmentValues = this.#root.segmentValues; + if (!("dayPeriod" in segmentValues)) return; + + const valueMin = 0; + const valueMax = 12; + const valueNow = segmentValues.dayPeriod ?? 0; + const valueText = segmentValues.dayPeriod ?? "AM"; + + return { + ...this.#root.sharedSegmentAttrs, + id: this.#id.value, + inputmode: "text", + "aria-label": "AM/PM", + "aria-valuemin": valueMin, + "aria-valuemax": valueMax, + "aria-valuenow": valueNow, + "aria-valuetext": valueText, + onkeydown: this.#onkeydown, + onclick: this.#root.handleSegmentClick, + ...this.#root.getBaseSegmentAttrs("dayPeriod", this.#id.value), + }; + }); +} + +type DateFieldDayLiteralSegmentStateProps = WithRefProps; + +class DateFieldDayLiteralSegmentState { + #id: DateFieldDayLiteralSegmentStateProps["id"]; + #ref: DateFieldDayLiteralSegmentStateProps["ref"]; + #root: DateFieldRootState; + + constructor(props: DateFieldMinuteSegmentStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + props = $derived.by( + () => + ({ + id: this.#id.value, + "aria-hidden": getAriaHidden(true), + ...this.#root.getBaseSegmentAttrs("literal", this.#id.value), + }) as const + ); +} + +type DateFieldTimeZoneSegmentStateProps = WithRefProps; + +class DateFieldTimeZoneSegmentState { + #id: DateFieldTimeZoneSegmentStateProps["id"]; + #ref: DateFieldTimeZoneSegmentStateProps["ref"]; + #root: DateFieldRootState; + + constructor(props: DateFieldMinuteSegmentStateProps, root: DateFieldRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + #onkeydown = (e: KeyboardEvent) => { + if (e.key !== kbd.TAB) e.preventDefault(); + if (this.#root.disabled.value) return; + if (isSegmentNavigationKey(e.key)) { + handleSegmentNavigation(e, this.#root.fieldNode); + } + }; + + props = $derived.by( + () => + ({ + role: "textbox", + id: this.#id.value, + "aria-label": "timezone, ", + "data-readonly": getDataReadonly(true), + tabindex: 0, + style: { + caretColor: "transparent", + }, + onkeydown: this.#onkeydown, + ...this.#root.getBaseSegmentAttrs("timeZoneName", this.#id.value), + }) as const + ); +} + +// Utils/helpers + +function isAcceptableDayPeriodKey(key: string) { + return isAcceptableSegmentKey(key) || key === kbd.A || key === kbd.P; +} + +function isArrowUp(key: string) { + return key === kbd.ARROW_UP; +} + +function isArrowDown(key: string) { + return key === kbd.ARROW_DOWN; +} + +function isBackspace(key: string) { + return key === kbd.BACKSPACE; +} + +const [setDateFieldRootContext, getDateFieldRootContext] = + createContext("DateField.Root"); + +export function useDateFieldRoot(props: DateFieldRootStateProps) { + return setDateFieldRootContext(new DateFieldRootState(props)); +} + +export function useDateFieldInput(props: DateFieldInputStateProps) { + return getDateFieldRootContext().createInput(props); +} + +export function useDateFieldHiddenInput() { + return getDateFieldRootContext().createHiddenInput(); +} + +export function useDateFieldSegment(part: SegmentPart, props: WithRefProps) { + return getDateFieldRootContext().createSegment(part, props); +} + +export function useDateFieldLabel(props: DateFieldLabelStateProps) { + return getDateFieldRootContext().createLabel(props); +} + +type SegmentPartToInstanceProps = { + part: SegmentPart; + root: DateFieldRootState; + segmentProps: WithRefProps; +}; + +function segmentPartToInstance(props: SegmentPartToInstanceProps) { + const { part, root, segmentProps } = props; + switch (part) { + case "day": + return new DateFieldDaySegmentState(segmentProps, root); + case "month": + return new DateFieldMonthSegmentState(segmentProps, root); + case "year": + return new DateFieldYearSegmentState(segmentProps, root); + case "hour": + return new DateFieldHourSegmentState(segmentProps, root); + case "minute": + return new DateFieldMinuteSegmentState(segmentProps, root); + case "second": + return new DateFieldSecondSegmentState(segmentProps, root); + case "dayPeriod": + return new DateFieldDayPeriodSegmentState(segmentProps, root); + case "literal": + return new DateFieldDayLiteralSegmentState(segmentProps, root); + case "timeZoneName": + return new DateFieldTimeZoneSegmentState(segmentProps, root); + } +} + +function prependYearZeros(year: number) { + const digits = String(year).length; + const diff = 4 - digits; + return `${"0".repeat(diff)}${year}`; +} diff --git a/packages/bits-ui/src/lib/bits/date-field/index.ts b/packages/bits-ui/src/lib/bits/date-field/index.ts index 48547f9f3..de30c1b86 100644 --- a/packages/bits-ui/src/lib/bits/date-field/index.ts +++ b/packages/bits-ui/src/lib/bits/date-field/index.ts @@ -4,10 +4,9 @@ export { default as Label } from "./components/date-field-label.svelte"; export { default as Segment } from "./components/date-field-segment.svelte"; export type { - DateFieldProps as Props, + DateFieldRootProps as RootProps, DateFieldInputProps as InputProps, DateFieldLabelProps as LabelProps, DateFieldSegmentProps as SegmentProps, - DateFieldDescriptionProps as DescriptionProps, - DateFieldSegmentEvents as SegmentEvents, + // DateFieldDescriptionProps as DescriptionProps, } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/date-field/types.ts b/packages/bits-ui/src/lib/bits/date-field/types.ts index 4e9f3c118..bdd88beea 100644 --- a/packages/bits-ui/src/lib/bits/date-field/types.ts +++ b/packages/bits-ui/src/lib/bits/date-field/types.ts @@ -1,77 +1,164 @@ -import type { DateValue } from "@internationalized/date"; -import type { CreateDateFieldProps as MeltDateFieldProps } from "@melt-ui/svelte"; -import type { CustomEventHandler } from "$lib/index.js"; import type { - DOMElement, - Expand, - HTMLDivAttributes, - HTMLSpanAttributes, - OmitDates, OnChangeFn, -} from "$lib/internal/index.js"; -import type { SegmentPart } from "$lib/shared/index.js"; - -export type DateFieldPropsWithoutHTML = Expand< - Omit, "required" | "name"> & { - /** - * The value of the date field. - * You can bind this to a `DateValue` object to programmatically control the value. - */ - value?: DateValue; - - /** - * A callback function called when the value changes. - */ - onValueChange?: OnChangeFn; - - /** - * The placeholder date used to start the field. - */ - placeholder?: DateValue; - - /** - * A callback function called when the placeholder changes. - */ - onPlaceholderChange?: OnChangeFn; - - /** - * The id of the validation message element which is used to apply the - * appropriate `aria-describedby` attribute to the input. - */ - validationId?: string; - - /** - * The id of the description element which is used to describe the input. - * This is used to apply the appropriate `aria-describedby` attribute to the input. - */ - descriptionId?: string; - } ->; - -export type DateFieldInputPropsWithoutHTML = DOMElement; - -export type DateFieldDescriptionPropsWithoutHTML = DOMElement; - -export type DateFieldLabelPropsWithoutHTML = DOMElement; + PrimitiveDivAttributes, + PrimitiveSpanAttributes, + WithAsChild, + Without, +} from "$lib/internal/types.js"; +import type { EditableSegmentPart } from "$lib/shared/date/field/types.js"; +import type { Granularity, Matcher } from "$lib/shared/date/types.js"; +import type { DateValue } from "@internationalized/date"; +import type { SegmentPart } from "@melt-ui/svelte"; +import type { Snippet } from "svelte"; + +export type DateFieldRootPropsWithoutHTML = { + /** + * The value of the date field. + * + * @bindable + */ + value?: DateValue | undefined; + + /** + * A callback that is called when the date field value changes. + * + */ + onValueChange?: OnChangeFn; + + /** + * The placeholder value of the date field. This determines the format + * and what date the field starts at when it is empty. + * + * @bindable + */ + placeholder?: DateValue | undefined; + + /** + * A callback that is called when the date field's placeholder value changes. + */ + onPlaceholderChange?: OnChangeFn; + + /** + * A function that returns true if the given date is unavailable, + * where if selected, the date field will be marked as invalid. + */ + isDateUnavailable?: Matcher; + + /** + * The minimum acceptable date. When provided, the date field + * will be marked as invalid if the user enters a date before this date. + */ + minValue?: DateValue | undefined; + + /** + * The maximum acceptable date. When provided, the date field + * will be marked as invalid if the user enters a date after this date. + */ + maxValue?: DateValue | undefined; + + /** + * If true, the date field will be disabled and users will not be able + * to interact with it. This also disables the hidden input element if + * the date field is used in a form. + * + * @defaultValue false + */ + disabled?: boolean; + + /** + * If true, the date field will be readonly, and users will not be able to + * edit the values of any of the individual segments. + * + * @defaultValue false + */ + readonly?: boolean; + + /** + * An array of segment names that should be readonly. If provided, only the + * segments not in this array will be editable. + */ + readonlySegments?: EditableSegmentPart[]; + + /** + * The format to use for displaying the time in the input. + * If using a 12 hour clock, ensure you also include the `dayPeriod` + * segment in your input to ensure the user can select AM/PM. + * + * @defaultValue the locale's default time format + */ + hourCycle?: 12 | 24; + + /** + * The locale to use for formatting the date field. + * + * @defaultValue 'en' + */ + locale?: string; + + /** + * The granularity of the date field. This determines which + * segments will be includes in the segments array used to + * build the date field. + * + * By default, when a `CalendarDate` value is used, the granularity + * will default to `'day'`, and when a `CalendarDateTime` or `ZonedDateTime` + * value is used, the granularity will default to `'minute'`. + * + * Granularity is only used for visual purposes, and does not impact + * the value of the date field. You can have the same value synced + * between multiple date fields with different granularities and they + * will all contain the same value. + * + * @defaultValue 'day' + */ + granularity?: Granularity; + + /** + * Whether or not to hide the timeZoneName segment from the date field. + * + * @defaultValue false; + */ + hideTimeZone?: boolean; + + /** + * The name to use for the hidden input element of the date field, + * which is used to submit the ISO string value of the date field + * to a server. If not provided, the hidden input element will not + * be rendered. + */ + name?: string; + + /** + * Whether or not the hidden input of the date field requires a value + * to be submitted. + * + * @defaultValue false + */ + required?: boolean; + + children?: Snippet; +}; -export type DateFieldSegmentPropsWithoutHTML = Expand< - { - part: SegmentPart; - } & DOMElement ->; +export type DateFieldRootProps = DateFieldRootPropsWithoutHTML; -export type DateFieldProps = DateFieldPropsWithoutHTML; +export type DateFieldInputPropsWithoutHTML = Omit< + WithAsChild<{}, { segments: Array<{ part: SegmentPart; value: string }> }>, + "children" +> & { + children?: Snippet<[{ segments: Array<{ part: SegmentPart; value: string }> }]>; +}; -export type DateFieldLabelProps = DateFieldLabelPropsWithoutHTML & HTMLSpanAttributes; +export type DateFieldInputProps = DateFieldInputPropsWithoutHTML & + Without; -export type DateFieldSegmentProps = DateFieldSegmentPropsWithoutHTML & HTMLDivAttributes; +export type DateFieldSegmentPropsWithoutHTML = WithAsChild<{ + part: SegmentPart; +}>; -export type DateFieldInputProps = DateFieldInputPropsWithoutHTML & HTMLDivAttributes; +export type DateFieldSegmentProps = DateFieldSegmentPropsWithoutHTML & + Without; -export type DateFieldDescriptionProps = DateFieldDescriptionPropsWithoutHTML & HTMLDivAttributes; +export type DateFieldLabelPropsWithoutHTML = WithAsChild<{}>; -export type DateFieldSegmentEvents = { - click: CustomEventHandler; - focusout: CustomEventHandler; - keydown: CustomEventHandler; -}; +export type DateFieldLabelProps = DateFieldLabelPropsWithoutHTML & + Without; diff --git a/packages/bits-ui/src/lib/bits/date-picker/ctx.ts b/packages/bits-ui/src/lib/bits/date-picker/ctx.ts index 24a458808..e69de29bb 100644 --- a/packages/bits-ui/src/lib/bits/date-picker/ctx.ts +++ b/packages/bits-ui/src/lib/bits/date-picker/ctx.ts @@ -1,60 +0,0 @@ -import { type CreateDatePickerProps, createDatePicker } from "@melt-ui/svelte"; -import { getContext, setContext } from "svelte"; -import type { Writable } from "svelte/store"; -import { createBitAttrs, getOptionUpdater, removeUndefined } from "$lib/internal/index.js"; -import { getCalendarData } from "$lib/bits/calendar/ctx.js"; -import { getDateFieldData } from "$lib/bits/date-field/ctx.js"; -import { getPopoverData } from "$lib/bits/popover/ctx.js"; -import { getPositioningUpdater } from "$lib/bits/floating/helpers.js"; -import type { FloatingConfig } from "$lib/bits/floating/floating-config.js"; -import type { FloatingProps } from "$lib/bits/floating/_types.js"; - -function getDatePickerData() { - const NAME = "date-picker" as const; - return { - NAME, - }; -} - -export function setCtx(props: CreateDatePickerProps) { - const { NAME } = getDatePickerData(); - const { NAME: CALENDAR_NAME, PARTS: CALENDAR_PARTS } = getCalendarData(); - const getCalendarAttrs = createBitAttrs(CALENDAR_NAME, CALENDAR_PARTS); - const { NAME: FIELD_NAME, PARTS: FIELD_PARTS } = getDateFieldData(); - const getFieldAttrs = createBitAttrs(FIELD_NAME, FIELD_PARTS); - const { NAME: POPOVER_NAME, PARTS: POPOVER_PARTS } = getPopoverData(); - const getPopoverAttrs = createBitAttrs(POPOVER_NAME, POPOVER_PARTS); - - const datePicker = { - ...createDatePicker({ ...removeUndefined(props), forceVisible: true }), - getCalendarAttrs, - getFieldAttrs, - getPopoverAttrs, - }; - const updateOption = getOptionUpdater(datePicker.options); - setContext(NAME, { ...datePicker, updateOption }); - - return { - ...datePicker, - updateOption, - }; -} - -export function getCtx() { - const { NAME } = getDatePickerData(); - return getContext>(NAME); -} - -export function updatePositioning(props: FloatingProps) { - const defaultPlacement = { - side: "bottom", - align: "center", - } satisfies FloatingProps; - const withDefaults = { ...defaultPlacement, ...props } satisfies FloatingProps; - const { - options: { positioning }, - } = getCtx(); - - const updater = getPositioningUpdater(positioning as Writable); - updater(withDefaults); -} diff --git a/packages/bits-ui/src/lib/bits/date-picker/index.ts b/packages/bits-ui/src/lib/bits/date-picker/index.ts index fd071ff9e..f0c9409b7 100644 --- a/packages/bits-ui/src/lib/bits/date-picker/index.ts +++ b/packages/bits-ui/src/lib/bits/date-picker/index.ts @@ -1,52 +1,52 @@ -export { default as Arrow } from "./components/date-picker-arrow.svelte"; -export { default as Calendar } from "./components/date-picker-calendar.svelte"; -export { default as Close } from "./components/date-picker-close.svelte"; -export { default as Content } from "./components/date-picker-content.svelte"; -export { default as Field } from "./components/date-picker-field.svelte"; -export { default as Input } from "./components/date-picker-input.svelte"; -export { default as Label } from "./components/date-picker-label.svelte"; -export { default as Segment } from "./components/date-picker-segment.svelte"; -export { default as Trigger } from "./components/date-picker-trigger.svelte"; -export { default as Root } from "./components/date-picker.svelte"; -export { default as GridBody } from "./components/date-picker-grid-body.svelte"; -export { default as GridHead } from "./components/date-picker-grid-head.svelte"; -export { default as GridRow } from "./components/date-picker-grid-row.svelte"; -export { default as HeadCell } from "./components/date-picker-head-cell.svelte"; -export { default as Header } from "./components/date-picker-header.svelte"; -export { default as Cell } from "./components/date-picker-cell.svelte"; -export { default as Day } from "./components/date-picker-day.svelte"; -export { default as Grid } from "./components/date-picker-grid.svelte"; +// export { default as Arrow } from "./components/date-picker-arrow.svelte"; +// export { default as Calendar } from "./components/date-picker-calendar.svelte"; +// export { default as Close } from "./components/date-picker-close.svelte"; +// export { default as Content } from "./components/date-picker-content.svelte"; +// export { default as Field } from "./components/date-picker-field.svelte"; +// export { default as Input } from "./components/date-picker-input.svelte"; +// export { default as Label } from "./components/date-picker-label.svelte"; +// export { default as Segment } from "./components/date-picker-segment.svelte"; +// export { default as Trigger } from "./components/date-picker-trigger.svelte"; +// export { default as Root } from "./components/date-picker.svelte"; +// export { default as GridBody } from "./components/date-picker-grid-body.svelte"; +// export { default as GridHead } from "./components/date-picker-grid-head.svelte"; +// export { default as GridRow } from "./components/date-picker-grid-row.svelte"; +// export { default as HeadCell } from "./components/date-picker-head-cell.svelte"; +// export { default as Header } from "./components/date-picker-header.svelte"; +// export { default as Cell } from "./components/date-picker-cell.svelte"; +// export { default as Day } from "./components/date-picker-day.svelte"; +// export { default as Grid } from "./components/date-picker-grid.svelte"; -export { default as Heading } from "./components/date-picker-heading.svelte"; -export { default as NextButton } from "./components/date-picker-next-button.svelte"; -export { default as PrevButton } from "./components/date-picker-prev-button.svelte"; +// export { default as Heading } from "./components/date-picker-heading.svelte"; +// export { default as NextButton } from "./components/date-picker-next-button.svelte"; +// export { default as PrevButton } from "./components/date-picker-prev-button.svelte"; -export type { - DatePickerProps as Props, - DatePickerLabelProps as LabelProps, - DatePickerInputProps as InputProps, - DatePickerSegmentProps as SegmentProps, - DatePickerArrowProps as ArrowProps, - DatePickerCloseEvents as CloseEvents, - DatePickerCloseProps as CloseProps, - DatePickerContentProps as ContentProps, - DatePickerTriggerEvents as TriggerEvents, - DatePickerTriggerProps as TriggerProps, - DatePickerCalendarEvents as CalendarEvents, - DatePickerCalendarProps as CalendarProps, - DatePickerCellProps as CellProps, - DatePickerDayEvents as DayEvents, - DatePickerDayProps as DayProps, - DatePickerGridBodyProps as GridBodyProps, - DatePickerGridHeadProps as GridHeadProps, - DatePickerGridProps as GridProps, - DatePickerGridRowProps as GridRowProps, - DatePickerHeadCellProps as HeadCellProps, - DatePickerHeaderProps as HeaderProps, - DatePickerHeadingProps as HeadingProps, - DatePickerNextButtonEvents as NextButtonEvents, - DatePickerNextButtonProps as NextButtonProps, - DatePickerPrevButtonEvents as PrevButtonEvents, - DatePickerPrevButtonProps as PrevButtonProps, - DatePickerSegmentEvents as SegmentEvents, -} from "./types.js"; +// export type { +// DatePickerProps as Props, +// DatePickerLabelProps as LabelProps, +// DatePickerInputProps as InputProps, +// DatePickerSegmentProps as SegmentProps, +// DatePickerArrowProps as ArrowProps, +// DatePickerCloseEvents as CloseEvents, +// DatePickerCloseProps as CloseProps, +// DatePickerContentProps as ContentProps, +// DatePickerTriggerEvents as TriggerEvents, +// DatePickerTriggerProps as TriggerProps, +// DatePickerCalendarEvents as CalendarEvents, +// DatePickerCalendarProps as CalendarProps, +// DatePickerCellProps as CellProps, +// DatePickerDayEvents as DayEvents, +// DatePickerDayProps as DayProps, +// DatePickerGridBodyProps as GridBodyProps, +// DatePickerGridHeadProps as GridHeadProps, +// DatePickerGridProps as GridProps, +// DatePickerGridRowProps as GridRowProps, +// DatePickerHeadCellProps as HeadCellProps, +// DatePickerHeaderProps as HeaderProps, +// DatePickerHeadingProps as HeadingProps, +// DatePickerNextButtonEvents as NextButtonEvents, +// DatePickerNextButtonProps as NextButtonProps, +// DatePickerPrevButtonEvents as PrevButtonEvents, +// DatePickerPrevButtonProps as PrevButtonProps, +// DatePickerSegmentEvents as SegmentEvents, +// } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/date-range-field/ctx.ts b/packages/bits-ui/src/lib/bits/date-range-field/ctx.ts index 574516f4f..e69de29bb 100644 --- a/packages/bits-ui/src/lib/bits/date-range-field/ctx.ts +++ b/packages/bits-ui/src/lib/bits/date-range-field/ctx.ts @@ -1,24 +0,0 @@ -import { type CreateDateRangeFieldProps, createDateRangeField } from "@melt-ui/svelte"; -import { getContext, setContext } from "svelte"; -import { getDateFieldData } from "../date-field/ctx.js"; -import { createBitAttrs, getOptionUpdater, removeUndefined } from "$lib/internal/index.js"; - -type GetReturn = Omit, "updateOption">; - -export function setCtx(props: CreateDateRangeFieldProps) { - const { NAME, PARTS } = getDateFieldData(); - const getAttrs = createBitAttrs(NAME, PARTS); - const dateRangeField = { ...createDateRangeField(removeUndefined(props)), getAttrs }; - - setContext(NAME, dateRangeField); - - return { - ...dateRangeField, - updateOption: getOptionUpdater(dateRangeField.options), - }; -} - -export function getCtx() { - const { NAME } = getDateFieldData(); - return getContext(NAME); -} diff --git a/packages/bits-ui/src/lib/bits/date-range-field/index.ts b/packages/bits-ui/src/lib/bits/date-range-field/index.ts index 80036ebd9..3d29d6682 100644 --- a/packages/bits-ui/src/lib/bits/date-range-field/index.ts +++ b/packages/bits-ui/src/lib/bits/date-range-field/index.ts @@ -1,13 +1,13 @@ -export { default as Root } from "./components/date-range-field.svelte"; -export { default as Input } from "./components/date-range-field-input.svelte"; -export { default as Label } from "./components/date-range-field-label.svelte"; -export { default as Segment } from "./components/date-range-field-segment.svelte"; +// export { default as Root } from "./components/date-range-field.svelte"; +// export { default as Input } from "./components/date-range-field-input.svelte"; +// export { default as Label } from "./components/date-range-field-label.svelte"; +// export { default as Segment } from "./components/date-range-field-segment.svelte"; -export type { - DateRangeFieldProps as Props, - DateRangeFieldLabelProps as LabelProps, - DateRangeFieldInputProps as InputProps, - DateRangeFieldSegmentProps as SegmentProps, - // - DateRangeFieldSegmentEvents as SegmentEvents, -} from "./types.js"; +// export type { +// DateRangeFieldProps as Props, +// DateRangeFieldLabelProps as LabelProps, +// DateRangeFieldInputProps as InputProps, +// DateRangeFieldSegmentProps as SegmentProps, +// // +// DateRangeFieldSegmentEvents as SegmentEvents, +// } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/date-range-picker/ctx.ts b/packages/bits-ui/src/lib/bits/date-range-picker/ctx.ts index a1595eb8c..e69de29bb 100644 --- a/packages/bits-ui/src/lib/bits/date-range-picker/ctx.ts +++ b/packages/bits-ui/src/lib/bits/date-range-picker/ctx.ts @@ -1,62 +0,0 @@ -import { type CreateDateRangePickerProps, createDateRangePicker } from "@melt-ui/svelte"; -import { getContext, setContext } from "svelte"; -import type { Writable } from "svelte/store"; -import { createBitAttrs, getOptionUpdater, removeUndefined } from "$lib/internal/index.js"; -import { getCalendarData } from "$lib/bits/calendar/ctx.js"; -import { getDateFieldData } from "$lib/bits/date-field/ctx.js"; -import { getPopoverData } from "$lib/bits/popover/ctx.js"; -import type { FloatingConfig } from "$lib/bits/floating/floating-config.js"; -import { getPositioningUpdater } from "$lib/bits/floating/helpers.js"; -import type { FloatingProps } from "$lib/bits/floating/_types.js"; - -function getDateRangePickerData() { - const NAME = "date-range-picker" as const; - return { - NAME, - }; -} - -export function setCtx(props: CreateDateRangePickerProps) { - const { NAME } = getDateRangePickerData(); - - const { NAME: CALENDAR_NAME, PARTS: CALENDAR_PARTS } = getCalendarData(); - const getCalendarAttrs = createBitAttrs(CALENDAR_NAME, CALENDAR_PARTS); - const { NAME: FIELD_NAME, PARTS: FIELD_PARTS } = getDateFieldData(); - const getFieldAttrs = createBitAttrs(FIELD_NAME, FIELD_PARTS); - const { NAME: POPOVER_NAME, PARTS: POPOVER_PARTS } = getPopoverData(); - const getPopoverAttrs = createBitAttrs(POPOVER_NAME, POPOVER_PARTS); - - const dateRangePicker = { - ...createDateRangePicker({ ...removeUndefined(props), forceVisible: true }), - getCalendarAttrs, - getFieldAttrs, - getPopoverAttrs, - }; - const updateOption = getOptionUpdater(dateRangePicker.options); - setContext(NAME, { ...dateRangePicker, updateOption }); - - return { - ...dateRangePicker, - updateOption, - }; -} - -export function getCtx() { - const { NAME } = getDateRangePickerData(); - return getContext>(NAME); -} - -export function updatePositioning(props: FloatingProps) { - const defaultPlacement = { - side: "bottom", - align: "center", - } satisfies FloatingProps; - - const withDefaults = { ...defaultPlacement, ...props } satisfies FloatingProps; - const { - options: { positioning }, - } = getCtx(); - - const updater = getPositioningUpdater(positioning as Writable); - updater(withDefaults); -} diff --git a/packages/bits-ui/src/lib/bits/date-range-picker/index.ts b/packages/bits-ui/src/lib/bits/date-range-picker/index.ts index 338eca2f1..937adca9c 100644 --- a/packages/bits-ui/src/lib/bits/date-range-picker/index.ts +++ b/packages/bits-ui/src/lib/bits/date-range-picker/index.ts @@ -1,55 +1,55 @@ -export { default as Arrow } from "./components/date-range-picker-arrow.svelte"; -export { default as Cell } from "./components/date-range-picker-cell.svelte"; -export { default as Day } from "./components/date-range-picker-day.svelte"; -export { default as Heading } from "./components/date-range-picker-heading.svelte"; -export { default as NextButton } from "./components/date-range-picker-next-button.svelte"; -export { default as PrevButton } from "./components/date-range-picker-prev-button.svelte"; -export { default as Calendar } from "./components/date-range-picker-calendar.svelte"; -export { default as Close } from "./components/date-range-picker-close.svelte"; -export { default as Content } from "./components/date-range-picker-content.svelte"; -export { default as Field } from "./components/date-range-picker-field.svelte"; -export { default as Input } from "./components/date-range-picker-input.svelte"; -export { default as Label } from "./components/date-range-picker-label.svelte"; -export { default as Segment } from "./components/date-range-picker-segment.svelte"; -export { default as Trigger } from "./components/date-range-picker-trigger.svelte"; -export { default as Root } from "./components/date-range-picker.svelte"; -export { default as Grid } from "./components/date-range-picker-grid.svelte"; -export { default as GridBody } from "./components/date-range-picker-grid-body.svelte"; -export { default as GridHead } from "./components/date-range-picker-grid-head.svelte"; -export { default as GridRow } from "./components/date-range-picker-grid-row.svelte"; -export { default as HeadCell } from "./components/date-range-picker-head-cell.svelte"; -export { default as Header } from "./components/date-range-picker-header.svelte"; +// export { default as Arrow } from "./components/date-range-picker-arrow.svelte"; +// export { default as Cell } from "./components/date-range-picker-cell.svelte"; +// export { default as Day } from "./components/date-range-picker-day.svelte"; +// export { default as Heading } from "./components/date-range-picker-heading.svelte"; +// export { default as NextButton } from "./components/date-range-picker-next-button.svelte"; +// export { default as PrevButton } from "./components/date-range-picker-prev-button.svelte"; +// export { default as Calendar } from "./components/date-range-picker-calendar.svelte"; +// export { default as Close } from "./components/date-range-picker-close.svelte"; +// export { default as Content } from "./components/date-range-picker-content.svelte"; +// export { default as Field } from "./components/date-range-picker-field.svelte"; +// export { default as Input } from "./components/date-range-picker-input.svelte"; +// export { default as Label } from "./components/date-range-picker-label.svelte"; +// export { default as Segment } from "./components/date-range-picker-segment.svelte"; +// export { default as Trigger } from "./components/date-range-picker-trigger.svelte"; +// export { default as Root } from "./components/date-range-picker.svelte"; +// export { default as Grid } from "./components/date-range-picker-grid.svelte"; +// export { default as GridBody } from "./components/date-range-picker-grid-body.svelte"; +// export { default as GridHead } from "./components/date-range-picker-grid-head.svelte"; +// export { default as GridRow } from "./components/date-range-picker-grid-row.svelte"; +// export { default as HeadCell } from "./components/date-range-picker-head-cell.svelte"; +// export { default as Header } from "./components/date-range-picker-header.svelte"; -export type { - DateRangePickerProps as Props, - DateRangePickerCalendarProps as CalendarProps, - DateRangePickerLabelProps as LabelProps, - DateRangePickerDescriptionProps as DescriptionProps, - DateRangePickerInputProps as InputProps, - DateRangePickerSegmentProps as SegmentProps, - DateRangePickerCellProps as CellProps, - DateRangePickerDayProps as DayProps, - DateRangePickerGridBodyProps as GridBodyProps, - DateRangePickerGridHeadProps as GridHeadProps, - DateRangePickerGridRowProps as GridRowProps, - DateRangePickerGridProps as GridProps, - DateRangePickerHeadCellProps as HeadCellProps, - DateRangePickerHeaderProps as HeaderProps, - DateRangePickerHeadingProps as HeadingProps, - DateRangePickerNextButtonProps as NextButtonProps, - DateRangePickerPrevButtonProps as PrevButtonProps, - DateRangePickerTriggerProps as TriggerProps, - DateRangePickerContentProps as ContentProps, - DateRangePickerArrowProps as ArrowProps, - DateRangePickerCloseProps as CloseProps, - // - // Events - // - DateRangePickerCloseEvents as CloseEvents, - DateRangePickerTriggerEvents as TriggerEvents, - DateRangePickerCalendarEvents as CalendarEvents, - DateRangePickerDayEvents as DayEvents, - DateRangePickerPrevButtonEvents as PrevButtonEvents, - DateRangePickerNextButtonEvents as NextButtonEvents, - DateRangePickerSegmentEvents as SegmentEvents, -} from "./types.js"; +// export type { +// DateRangePickerProps as Props, +// DateRangePickerCalendarProps as CalendarProps, +// DateRangePickerLabelProps as LabelProps, +// DateRangePickerDescriptionProps as DescriptionProps, +// DateRangePickerInputProps as InputProps, +// DateRangePickerSegmentProps as SegmentProps, +// DateRangePickerCellProps as CellProps, +// DateRangePickerDayProps as DayProps, +// DateRangePickerGridBodyProps as GridBodyProps, +// DateRangePickerGridHeadProps as GridHeadProps, +// DateRangePickerGridRowProps as GridRowProps, +// DateRangePickerGridProps as GridProps, +// DateRangePickerHeadCellProps as HeadCellProps, +// DateRangePickerHeaderProps as HeaderProps, +// DateRangePickerHeadingProps as HeadingProps, +// DateRangePickerNextButtonProps as NextButtonProps, +// DateRangePickerPrevButtonProps as PrevButtonProps, +// DateRangePickerTriggerProps as TriggerProps, +// DateRangePickerContentProps as ContentProps, +// DateRangePickerArrowProps as ArrowProps, +// DateRangePickerCloseProps as CloseProps, +// // +// // Events +// // +// DateRangePickerCloseEvents as CloseEvents, +// DateRangePickerTriggerEvents as TriggerEvents, +// DateRangePickerCalendarEvents as CalendarEvents, +// DateRangePickerDayEvents as DayEvents, +// DateRangePickerPrevButtonEvents as PrevButtonEvents, +// DateRangePickerNextButtonEvents as NextButtonEvents, +// DateRangePickerSegmentEvents as SegmentEvents, +// } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts index 2905b49d7..ddf51b857 100644 --- a/packages/bits-ui/src/lib/bits/index.ts +++ b/packages/bits-ui/src/lib/bits/index.ts @@ -12,7 +12,7 @@ export * as Combobox from "./combobox/index.js"; export * as ContextMenu from "./context-menu/index.js"; export * as DateField from "./date-field/index.js"; // export * as DatePicker from "./date-picker/index.js"; -export * as DateRangeField from "./date-range-field/index.js"; +// export * as DateRangeField from "./date-range-field/index.js"; // export * as DateRangePicker from "./date-range-picker/index.js"; export * as Dialog from "./dialog/index.js"; export * as DropdownMenu from "./dropdown-menu/index.js"; diff --git a/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts b/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts index 3837d6417..5fdff3b7b 100644 --- a/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts +++ b/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts @@ -339,7 +339,6 @@ class PinInputRootState { }; #oninput = (e: Event & { currentTarget: HTMLInputElement }) => { - console.log("new value", e.currentTarget.value); const newValue = e.currentTarget.value.slice(0, this.#maxLength.value); if (newValue.length > 0 && this.#regexPattern && !this.#regexPattern.test(newValue)) { e.preventDefault(); @@ -369,7 +368,6 @@ class PinInputRootState { this.#mirrorSelectionStart = start; this.#mirrorSelectionEnd = end; } - console.log("input focus!"); this.#isFocused.value = true; }; @@ -450,7 +448,6 @@ class PinInputRootState { idx === this.#mirrorSelectionStart) || (idx >= this.#mirrorSelectionStart && idx < this.#mirrorSelectionEnd)); - console.log("isActive", isActive); const char = this.value.value[idx] !== undefined ? this.value.value[idx] : null; return { diff --git a/packages/bits-ui/src/lib/bits/select/components/select.svelte b/packages/bits-ui/src/lib/bits/select/components/select.svelte index bcb9c84f7..a4b20e2da 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select.svelte @@ -60,7 +60,7 @@ {#if value === ""} {/if} - {#each rootState.nativeOptionsArr as opt (opt.value.key)} + {#each rootState.nativeOptionsArr as opt, idx (opt.value.key + idx)}