diff --git a/examples/next-ts/pages/date-picker-segment-single.tsx b/examples/next-ts/pages/date-picker-segment-single.tsx new file mode 100644 index 0000000000..66acf46836 --- /dev/null +++ b/examples/next-ts/pages/date-picker-segment-single.tsx @@ -0,0 +1,154 @@ +import * as datePicker from "@zag-js/date-picker" +import { normalizeProps, useMachine } from "@zag-js/react" +import { datePickerControls } from "@zag-js/shared" +import { useId } from "react" +import { StateVisualizer } from "../components/state-visualizer" +import { Toolbar } from "../components/toolbar" +import { useControls } from "../hooks/use-controls" + +export default function Page() { + const controls = useControls(datePickerControls) + const service = useMachine(datePicker.machine, { + id: useId(), + selectionMode: "single", + ...controls.context, + }) + + const api = datePicker.connect(service, normalizeProps) + + return ( + <> +
+
+ +
+

{`Visible range: ${api.visibleRangeText.formatted}`}

+ + +
Selected: {api.valueAsString ?? "-"}
+
Focused: {api.focusedValueAsString}
+
Placeholder: {api.placeholderValueAsString}
+
+ +
+
+ {api.getSegments().map((segment, i) => ( + + {segment.text} + + ))} +
+ + +
+ + + +
+
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + ) +} diff --git a/packages/machines/date-picker/LOGIC.md b/packages/machines/date-picker/LOGIC.md new file mode 100644 index 0000000000..1d7b622266 --- /dev/null +++ b/packages/machines/date-picker/LOGIC.md @@ -0,0 +1,68 @@ +## DatePicker Segment Focus Logic + +- The date-picker component has three states: `IDLE`, `FOCUSED`, and `OPEN` + +- In the `IDLE` state: + + - When a segment group is focused: + - Transition to the `FOCUSED` state + - Set the active index and active segment index + - Focus the first editable segment + +- In the `FOCUSED` state: + + - When a segment group is blurred: + - Transition to the `IDLE` state + - Reset the active segment index to -1 + - Reset the active index to start + + - When arrow right key is pressed: + - Move to the next editable segment + - If at the last segment, move to the first segment of next input (range mode) + + - When arrow left key is pressed: + - Move to the previous editable segment + - If at the first segment, move to the last segment of previous input (range mode) + + - When arrow up key is pressed: + - Increment the current segment value (day, month, year) + - Constrain the value within valid bounds + - Update the date value + + - When arrow down key is pressed: + - Decrement the current segment value (day, month, year) + - Constrain the value within valid bounds + - Update the date value + + - When a digit is typed: + - Update the current segment value + - If segment is complete, move to next segment + - Parse and validate the date + + - When backspace is pressed: + - Clear the current segment value + - Move to previous segment if current is empty + + - When delete is pressed: + - Clear the current segment value + - Stay on the current segment + + - When enter is pressed: + - Parse the complete input value + - Set the focused date if valid + - Select the focused date + + - When escape is pressed: + - Revert to the last valid value + - Blur the segment group + + - When text is pasted: + - Parse the pasted value as a complete date + - Update all segments if valid + - Focus the last segment + +- In the `OPEN` state: + + - Segment interactions are disabled + - Arrow keys control calendar navigation instead + - Typing characters may trigger date search/filter \ No newline at end of file diff --git a/packages/machines/date-picker/src/date-picker.anatomy.ts b/packages/machines/date-picker/src/date-picker.anatomy.ts index a4cc4d3e99..4e68fd3450 100644 --- a/packages/machines/date-picker/src/date-picker.anatomy.ts +++ b/packages/machines/date-picker/src/date-picker.anatomy.ts @@ -5,6 +5,8 @@ export const anatomy = createAnatomy("date-picker").parts( "content", "control", "input", + "segmentGroup", + "segment", "label", "monthSelect", "nextTrigger", diff --git a/packages/machines/date-picker/src/date-picker.connect.ts b/packages/machines/date-picker/src/date-picker.connect.ts index 343b06693a..06ad02a271 100644 --- a/packages/machines/date-picker/src/date-picker.connect.ts +++ b/packages/machines/date-picker/src/date-picker.connect.ts @@ -38,16 +38,18 @@ import type { TableCellProps, TableCellState, TableProps, + SegmentProps, + SegmentState, } from "./date-picker.types" import { adjustStartAndEndDate, - defaultTranslations, ensureValidCharacters, getInputPlaceholder, getLocaleSeparator, getRoleDescription, isDateWithinRange, isValidCharacter, + PAGE_STEP, } from "./date-picker.utils" export function connect( @@ -60,6 +62,7 @@ export function connect( const endValue = computed("endValue") const selectedValue = context.get("value") const focusedValue = context.get("focusedValue") + const placeholderValue = context.get("placeholderValue") const hoveredValue = context.get("hoveredValue") const hoveredRangeValue = hoveredValue ? adjustStartAndEndDate([selectedValue[0], hoveredValue]) : [] @@ -87,7 +90,7 @@ export function connect( }) const separator = getLocaleSeparator(locale) - const translations = { ...defaultTranslations, ...prop("translations") } + const translations = prop("translations") function getMonthWeeks(from = startValue) { const numOfWeeks = prop("fixedWeeks") ? 6 : undefined @@ -240,6 +243,16 @@ export function connect( return [view, id].filter(Boolean).join(" ") } + function getSegmentState(props: SegmentProps): SegmentState { + const { segment } = props + + const isEditable = !disabled && !readOnly && segment.isEditable + + return { + editable: isEditable, + } + } + return { focused, open, @@ -275,6 +288,9 @@ export function connect( focusedValue, focusedValueAsDate: focusedValue?.toDate(timeZone), focusedValueAsString: prop("format")(focusedValue, { locale, timeZone }), + placeholderValue: placeholderValue, + placeholderValueAsDate: placeholderValue?.toDate(timeZone), + placeholderValueAsString: prop("format")(placeholderValue, { locale, timeZone }), visibleRange: computed("visibleRange"), selectToday() { const value = constrainValue(getTodayDate(timeZone), min, max) @@ -731,6 +747,7 @@ export function connect( "data-state": open ? "open" : "closed", "aria-haspopup": "grid", disabled, + "data-readonly": dataAttr(readOnly), onClick(event) { if (event.defaultPrevented) return if (!interactive) return @@ -831,6 +848,182 @@ export function connect( }) }, + getSegments(props = {}) { + const { index = 0 } = props + return computed("segments")[index] ?? [] + }, + + getSegmentGroupProps(props = {}) { + const { index = 0 } = props + + return normalize.element({ + ...parts.segmentGroup.attrs, + id: dom.getSegmentGroupId(scope, index), + dir: prop("dir"), + "data-state": open ? "open" : "closed", + role: "presentation", + readOnly, + disabled, + style: { + unicodeBidi: "isolate", + }, + }) + }, + + getSegmentState, + + getSegmentProps(props) { + const { segment, index = 0 } = props + const segmentState = getSegmentState(props) + + if (segment.type === "literal") { + return normalize.element({ + ...parts.segment.attrs, + dir: prop("dir"), + "aria-hidden": true, // Literal segments should not be visible to screen readers. + "data-type": segment.type, + "data-readonly": dataAttr(true), + "data-disabled": dataAttr(true), + }) + } + + return normalize.element({ + ...parts.segment.attrs, + dir: prop("dir"), + role: "spinbutton", + tabIndex: disabled ? undefined : 0, + autoComplete: "off", + spellCheck: segmentState.editable ? "false" : undefined, + autoCorrect: segmentState.editable ? "off" : undefined, + contentEditable: segmentState.editable, + suppressContentEditableWarning: segmentState.editable, + inputMode: + disabled || segment.type === "dayPeriod" || segment.type === "era" || !segmentState.editable + ? undefined + : "numeric", + enterKeyHint: "next", + "aria-labelledby": dom.getSegmentGroupId(scope, index), + // "aria-label": translations.segmentLabel(segment), + "aria-valuenow": segment.value, + "aria-valuetext": segment.text, + "aria-valuemin": segment.minValue, + "aria-valuemax": segment.maxValue, + "aria-readonly": ariaAttr(!segment.isEditable || readOnly), + "aria-disabled": ariaAttr(disabled), + "data-value": segment.value, + "data-type": segment.type, + "data-readonly": dataAttr(!segment.isEditable || readOnly), + "data-disabled": dataAttr(disabled), + "data-editable": dataAttr(segment.isEditable && !readOnly && !disabled), + "data-placeholder": dataAttr(segment.isPlaceholder), + style: { + caretColor: "transparent", + }, + onFocus() { + send({ type: "SEGMENT.FOCUS", index }) + }, + onBlur() { + send({ type: "SEGMENT.BLUR", index: -1 }) + }, + onKeyDown(event) { + if ( + event.defaultPrevented || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey || + readOnly || + event.nativeEvent.isComposing + ) { + return + } + + const keyMap: EventKeyMap = { + ArrowLeft() { + send({ type: "SEGMENT.ARROW_LEFT" }) + }, + ArrowRight() { + send({ type: "SEGMENT.ARROW_RIGHT" }) + }, + ArrowUp() { + send({ type: "SEGMENT.ADJUST", segment, amount: 1 }) + }, + ArrowDown() { + send({ type: "SEGMENT.ADJUST", segment, amount: -1 }) + }, + PageUp() { + send({ + type: "SEGMENT.ADJUST", + segment, + amount: PAGE_STEP[segment.type] ?? 1, + }) + }, + PageDown() { + send({ + type: "SEGMENT.ADJUST", + segment, + amount: -(PAGE_STEP[segment.type] ?? 1), + }) + }, + Backspace() { + send({ type: "SEGMENT.BACKSPACE", segment }) + }, + Delete() { + send({ type: "SEGMENT.BACKSPACE", segment }) + }, + Home() { + send({ type: "SEGMENT.HOME", segment }) + }, + End() { + send({ type: "SEGMENT.END", segment }) + }, + } + + const exec = + keyMap[ + getEventKey(event, { + dir: prop("dir"), + }) + ] + + if (exec) { + exec(event) + event.preventDefault() + event.stopPropagation() + } + }, + onPointerDown(event) { + event.stopPropagation() + }, + onMouseDown(event) { + event.stopPropagation() + }, + onBeforeInput(event) { + const { data, inputType } = getNativeEvent(event) + const allowedInputTypes = ["deleteContentBackward", "deleteContentForward", "deleteByCut", "deleteByDrag"] + + if (allowedInputTypes.includes(inputType)) { + return + } + + if (inputType === "insertFromPaste") { + event.preventDefault() + return + } + + if (data && isValidCharacter(data, separator)) { + event.preventDefault() + send({ type: "SEGMENT.INPUT", segment, input: data }) + } else { + event.preventDefault() + } + }, + onPaste(event) { + event.preventDefault() + }, + }) + }, + getMonthSelectProps() { return normalize.select({ ...parts.monthSelect.attrs, diff --git a/packages/machines/date-picker/src/date-picker.dom.ts b/packages/machines/date-picker/src/date-picker.dom.ts index 0d0d8fc98f..03ce9f7cdd 100644 --- a/packages/machines/date-picker/src/date-picker.dom.ts +++ b/packages/machines/date-picker/src/date-picker.dom.ts @@ -22,6 +22,8 @@ export const getClearTriggerId = (ctx: Scope) => ctx.ids?.clearTrigger ?? `datep export const getControlId = (ctx: Scope) => ctx.ids?.control ?? `datepicker:${ctx.id}:control` export const getInputId = (ctx: Scope, index: number) => ctx.ids?.input?.(index) ?? `datepicker:${ctx.id}:input:${index}` +export const getSegmentGroupId = (ctx: Scope, index: number) => + ctx.ids?.segmentGroup?.(index) ?? `datepicker:${ctx.id}:segment-group:${index}` export const getTriggerId = (ctx: Scope) => ctx.ids?.trigger ?? `datepicker:${ctx.id}:trigger` export const getPositionerId = (ctx: Scope) => ctx.ids?.positioner ?? `datepicker:${ctx.id}:positioner` export const getMonthSelectId = (ctx: Scope) => ctx.ids?.monthSelect ?? `datepicker:${ctx.id}:month-select` @@ -32,6 +34,7 @@ export const getFocusedCell = (ctx: Scope, view: DateView) => export const getTriggerEl = (ctx: Scope) => ctx.getById(getTriggerId(ctx)) export const getContentEl = (ctx: Scope) => ctx.getById(getContentId(ctx)) export const getInputEls = (ctx: Scope) => queryAll(getControlEl(ctx), `[data-part=input]`) +export const getSegmentEls = (ctx: Scope) => queryAll(getControlEl(ctx), `[data-part=segment]`) export const getYearSelectEl = (ctx: Scope) => ctx.getById(getYearSelectId(ctx)) export const getMonthSelectEl = (ctx: Scope) => ctx.getById(getMonthSelectId(ctx)) export const getClearTriggerEl = (ctx: Scope) => ctx.getById(getClearTriggerId(ctx)) diff --git a/packages/machines/date-picker/src/date-picker.machine.ts b/packages/machines/date-picker/src/date-picker.machine.ts index 4433f3a36c..ff4ec9ad34 100644 --- a/packages/machines/date-picker/src/date-picker.machine.ts +++ b/packages/machines/date-picker/src/date-picker.machine.ts @@ -24,17 +24,24 @@ import { disableTextSelection, raf, restoreTextSelection, setElementValue } from import { createLiveRegion } from "@zag-js/live-region" import { getPlacement, type Placement } from "@zag-js/popper" import * as dom from "./date-picker.dom" -import type { DatePickerSchema, DateValue, DateView } from "./date-picker.types" +import type { DatePickerSchema, DateSegment, DateValue, DateView, Segments, SegmentType } from "./date-picker.types" import { + addSegment, adjustStartAndEndDate, clampView, + defaultTranslations, eachView, + EDITABLE_SEGMENTS, + getDefaultValidSegments, getNextView, getPreviousView, isAboveMinView, isBelowMinView, isValidDate, + processSegments, + setSegment, sortDates, + TYPE_MAPPING, } from "./date-picker.utils" const { and } = createGuards() @@ -72,10 +79,37 @@ export const machine = createMachine({ props.focusedValue || props.defaultFocusedValue || value?.[0] || defaultValue?.[0] || getTodayDate(timeZone) focusedValue = constrainValue(focusedValue, props.min, props.max) + // get initial placeholder value + let placeholderValue = + props.placeholderValue || + props.defaultPlaceholderValue || + value?.[0] || + defaultValue?.[0] || + getTodayDate(timeZone) + placeholderValue = constrainValue(placeholderValue, props.min, props.max) + // get the initial view const minView: DateView = "day" const maxView: DateView = "year" const defaultView = clampView(props.view || minView, minView, maxView) + const granularity = props.granularity || "day" + const translations = { ...defaultTranslations, ...props.translations } + + const formatter = new DateFormatter(locale, { + timeZone: timeZone, + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + + const allSegments = formatter + .formatToParts(new Date()) + .filter((seg) => EDITABLE_SEGMENTS[seg.type]) + .reduce((p, seg) => { + const key = TYPE_MAPPING[seg.type as keyof typeof TYPE_MAPPING] || seg.type + p[key] = true + return p + }, {}) return { locale, @@ -87,14 +121,14 @@ export const machine = createMachine({ maxView, outsideDaySelectable: false, closeOnSelect: true, - format(date, { locale, timeZone }) { - const formatter = new DateFormatter(locale, { timeZone, day: "2-digit", month: "2-digit", year: "numeric" }) + format(date, { timeZone }) { return formatter.format(date.toDate(timeZone)) }, parse(value, { locale, timeZone }) { return parseDateString(value, locale, timeZone) }, ...props, + translations, focusedValue: typeof props.focusedValue === "undefined" ? undefined : focusedValue, defaultFocusedValue: focusedValue, value, @@ -103,6 +137,11 @@ export const machine = createMachine({ placement: "bottom", ...props.positioning, }, + granularity, + formatter, + placeholderValue: typeof props.placeholderValue === "undefined" ? undefined : placeholderValue, + defaultPlaceholderValue: placeholderValue, + allSegments, } }, @@ -114,6 +153,7 @@ export const machine = createMachine({ refs() { return { announcer: undefined, + enteredKeys: "", } }, @@ -151,6 +191,10 @@ export const machine = createMachine({ defaultValue: 0, sync: true, })), + activeSegmentIndex: bindable(() => ({ + defaultValue: -1, + sync: true, + })), hoveredValue: bindable(() => ({ defaultValue: null, isEqual: (a, b) => b !== null && a !== null && isDateEqual(a, b), @@ -176,6 +220,25 @@ export const machine = createMachine({ restoreFocus: bindable(() => ({ defaultValue: false, })), + placeholderValue: bindable(() => ({ + defaultValue: prop("defaultPlaceholderValue"), + value: prop("placeholderValue"), + isEqual: isDateEqual, + hash: (v) => v.toString(), + sync: true, + onChange(placeholderValue) { + const context = getContext() + const view = context.get("view") + const value = context.get("value") + const valueAsString = getValueAsString(value, prop) + prop("onPlaceholderChange")?.({ value, valueAsString, view, placeholderValue }) + }, + })), + validSegments: bindable(() => { + return { + defaultValue: getDefaultValidSegments(prop("value") || prop("defaultValue"), prop("allSegments")), + } + }), } }, @@ -197,6 +260,37 @@ export const machine = createMachine({ isNextVisibleRangeValid: ({ prop, computed }) => !isNextRangeInvalid(computed("endValue"), prop("min"), prop("max")), valueAsString: ({ context, prop }) => getValueAsString(context.get("value"), prop), + segments: ({ context, prop }) => { + const value = context.get("value") + const selectionMode = prop("selectionMode") + const placeholderValue = context.get("placeholderValue") + const validSegments = context.get("validSegments") + const timeZone = prop("timeZone") + const translations = prop("translations") || defaultTranslations + const granularity = prop("granularity") + const formatter = prop("formatter") + + let dates: DateValue[] = value?.length ? value : [placeholderValue] + + if (selectionMode === "range") { + dates = value?.length ? value : [placeholderValue, placeholderValue] + } + + return dates.map((date, i) => { + const displayValue = date || placeholderValue + const currentValidSegments = validSegments?.[i] || {} + + return processSegments({ + dateValue: displayValue.toDate(timeZone), + displayValue, + validSegments: currentValidSegments, + formatter, + locale: prop("locale"), + translations, + granularity, + }) + }) + }, }, effects: ["setupLiveRegion"], @@ -220,7 +314,7 @@ export const machine = createMachine({ }) track([() => context.hash("value")], () => { - action(["syncInputElement"]) + action(["syncValidSegments", "syncInputElement"]) }) track([() => computed("valueAsString").toString()], () => { @@ -234,6 +328,10 @@ export const machine = createMachine({ track([() => prop("open")], () => { action(["toggleVisibility"]) }) + + track([() => context.get("activeSegmentIndex")], () => { + action(["focusActiveSegment"]) + }) }, on: { @@ -247,7 +345,13 @@ export const machine = createMachine({ actions: ["setFocusedDate"], }, "VALUE.CLEAR": { - actions: ["clearDateValue", "clearFocusedDate", "focusFirstInputElement"], + actions: [ + "clearDateValue", + "clearFocusedDate", + "clearPlaceholderDate", + "clearEnteredKeys", + "focusFirstInputElement", + ], }, "INPUT.CHANGE": [ { @@ -339,6 +443,10 @@ export const machine = createMachine({ actions: ["focusFirstSelectedDate", "focusActiveCell", "invokeOnOpen"], }, ], + "SEGMENT.FOCUS": { + target: "focused", + actions: ["setActiveSegmentIndex"], + }, }, }, @@ -369,6 +477,36 @@ export const machine = createMachine({ actions: ["focusFirstSelectedDate", "focusActiveCell", "invokeOnOpen"], }, ], + "SEGMENT.FOCUS": { + actions: ["setActiveSegmentIndex", "clearEnteredKeys"], + }, + "SEGMENT.INPUT": { + actions: ["setSegmentValue"], + }, + "SEGMENT.ADJUST": { + actions: ["invokeOnSegmentAdjust"], + }, + "SEGMENT.ARROW_LEFT": { + actions: ["setPreviousActiveSegmentIndex", "clearEnteredKeys"], + }, + "SEGMENT.ARROW_RIGHT": { + actions: ["setNextActiveSegmentIndex", "clearEnteredKeys"], + }, + "SEGMENT.BACKSPACE": [ + { + guard: "isActiveSegmentPlaceholder", + actions: ["setPreviousActiveSegmentIndex"], + }, + { + actions: ["clearSegmentValue", "clearEnteredKeys"], + }, + ], + "SEGMENT.HOME": { + actions: ["setSegmentToLowestValue", "clearEnteredKeys"], + }, + "SEGMENT.END": { + actions: ["setSegmentToHighestValue", "clearEnteredKeys"], + }, }, }, @@ -672,6 +810,7 @@ export const machine = createMachine({ isInteractOutsideEvent: ({ event }) => event.previousEvent?.type === "INTERACT_OUTSIDE", isInputValueEmpty: ({ event }) => event.value.trim() === "", shouldFixOnBlur: ({ event }) => !!event.fixOnBlur, + isActiveSegmentPlaceholder: (ctx) => getActiveSegment(ctx)?.isPlaceholder === true, }, effects: { @@ -764,6 +903,9 @@ export const machine = createMachine({ }) }) }, + syncValidSegments({ context, prop }) { + context.set("validSegments", getDefaultValidSegments(context.get("value"), prop("allSegments"))) + }, setFocusedDate(params) { const { event } = params const value = Array.isArray(event.value) ? event.value[0] : event.value @@ -1128,6 +1270,111 @@ export const machine = createMachine({ toggleVisibility({ event, send, prop }) { send({ type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE", previousEvent: event }) }, + + // SEGMENT ACTIONS [START] ///////////////////////////////////////////////////////////////////////////// + + setActiveSegmentIndex({ context, event }) { + context.set("activeSegmentIndex", event.index) + }, + + clearPlaceholderDate(params) { + const { prop, context } = params + context.set("placeholderValue", getTodayDate(prop("timeZone"))) + }, + + clearEnteredKeys({ refs }) { + refs.set("enteredKeys", "") + }, + + setPreviousActiveSegmentIndex({ context, computed }) { + const index = context.get("activeIndex") + const activeSegmentIndex = context.get("activeSegmentIndex") + const segments = computed("segments")[index] + const previousActiveSegmentIndex = segments.findLastIndex( + (segment, i) => i < activeSegmentIndex && segment.isEditable, + ) + if (previousActiveSegmentIndex === -1) return + context.set("activeSegmentIndex", previousActiveSegmentIndex) + }, + + setNextActiveSegmentIndex({ context, computed }) { + const index = context.get("activeIndex") + const activeSegmentIndex = context.get("activeSegmentIndex") + const segments = computed("segments")[index] + const nextActiveSegmentIndex = segments.findIndex((segment, i) => i > activeSegmentIndex && segment.isEditable) + if (nextActiveSegmentIndex === -1) return + context.set("activeSegmentIndex", nextActiveSegmentIndex) + }, + + focusActiveSegment({ scope, context }) { + raf(() => { + const segmentEls = dom.getSegmentEls(scope) + const activeSegmentEl = segmentEls[context.get("activeSegmentIndex")] + activeSegmentEl?.focus({ preventScroll: true }) + }) + }, + + clearSegmentValue(params) { + const { event, prop } = params + const { segment } = event + + const displayValue = getDisplayValue(params) + const formatter = prop("formatter") + + const newValue = segment.text.slice(0, -1) + + if (newValue === "" || newValue === "0") { + markSegmentInvalid(params, segment.type as DateSegment["type"]) + setValue(params, displayValue) + } else { + setValue( + params, + setSegment(displayValue, segment.type as DateSegment["type"], newValue, formatter.resolvedOptions()), + ) + } + }, + + invokeOnSegmentAdjust(params) { + const { event, context, prop } = params + const { segment, amount } = event + const type = segment.type as DateSegment["type"] + const validSegments = context.get("validSegments") + const formatter = prop("formatter") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + + const displayValue = getDisplayValue(params) + + if (!activeValidSegments?.[type]) { + markSegmentValid(params, type) + setValue(params, displayValue) + } else { + setValue(params, addSegment(displayValue, type, amount, formatter.resolvedOptions())) + } + }, + + setSegmentValue(params) { + const { event } = params + const { segment, input } = event + + updateSegmentValue(params, segment, input) + }, + + setSegmentToLowestValue(params) { + const { event } = params + const { segment } = event + + updateSegmentValue(params, segment, String(segment.minValue)) + }, + + setSegmentToHighestValue(params) { + const { event } = params + const { segment } = event + + updateSegmentValue(params, segment, String(segment.maxValue)) + }, + + // SEGMENT ACTIONS [END] /////////////////////////////////////////////////////////////////////////////// }, }, }) @@ -1169,3 +1416,190 @@ function setAdjustedValue(ctx: Params, value: AdjustDateReturn if (isDateEqual(focusedValue, value.focusedDate)) return context.set("focusedValue", value.focusedDate) } + +// SEGMENT UTILS [START] ///////////////////////////////////////////////////////////////////////////// + +/** + * If all segments are valid, use return value date, otherwise return the placeholder date. + */ +function getDisplayValue(ctx: Params) { + const { context, prop } = ctx + const index = context.get("activeIndex") + const validSegments = context.get("validSegments") + const allSegments = prop("allSegments") + const value = context.get("value")[index] + const placeholderValue = context.get("placeholderValue") + const activeValidSegments = validSegments[index] + + return value && Object.keys(activeValidSegments).length >= Object.keys(allSegments).length ? value : placeholderValue +} + +function markSegmentInvalid(ctx: Params, segmentType: SegmentType) { + const { context } = ctx + const validSegments = context.get("validSegments") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + + if (activeValidSegments?.[segmentType]) { + delete activeValidSegments[segmentType] + context.set("validSegments", validSegments) + } +} + +function markSegmentValid(ctx: Params, segmentType: SegmentType) { + const { context, prop } = ctx + const validSegments = context.get("validSegments") + const allSegments = prop("allSegments") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + + if (!activeValidSegments?.[segmentType]) { + activeValidSegments[segmentType] = true + if (segmentType === "year" && allSegments.era) { + activeValidSegments.era = true + } + context.set("validSegments", validSegments) + } +} + +// TODO: maybe move this to computed +function isAllSegmentsCompleted(ctx: Params) { + const { context, prop } = ctx + const validSegments = context.get("validSegments") + const allSegments = prop("allSegments") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + const validKeys = Object.keys(activeValidSegments) + const allKeys = Object.keys(allSegments) + + return ( + validKeys.length >= allKeys.length || + (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !activeValidSegments.dayPeriod) + ) +} + +function setValue(ctx: Params, value: DateValue) { + const { context, prop } = ctx + if (prop("disabled") || prop("readOnly")) return + const validSegments = context.get("validSegments") + const allSegments = prop("allSegments") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + const validKeys = Object.keys(activeValidSegments) + const date = constrainValue(value, prop("min"), prop("max")) + + if (isAllSegmentsCompleted(ctx)) { + if (validKeys.length === 0) { + validSegments[index] = { ...allSegments } + context.set("validSegments", validSegments) + } + + const values = Array.from(context.get("value")) + values[index] = date + context.set("value", values) + } else { + context.set("placeholderValue", date) + } +} + +function isNumberString(value: string) { + if (Number.isNaN(Number.parseInt(value))) return false + return true +} + +function getActiveSegment(ctx: Params) { + const { context, computed } = ctx + const index = context.get("activeIndex") + const activeSegmentIndex = context.get("activeSegmentIndex") + return computed("segments")[index]?.[activeSegmentIndex] +} + +function updateSegmentValue(ctx: Params, segment: DateSegment, input: string) { + const { context, computed, prop, refs } = ctx + const type = segment.type as DateSegment["type"] + const validSegments = context.get("validSegments") + const index = context.get("activeIndex") + const activeValidSegments = validSegments[index] + const formatter = prop("formatter") + const enteredKeys = refs.get("enteredKeys") + + switch (type) { + case "dayPeriod": + // TODO + break + case "era": { + // TODO + break + } + case "day": + case "hour": + case "minute": + case "second": + case "month": + case "year": { + let newValue = enteredKeys + input + let numberValue = Number.parseInt(newValue) + let segmentValue = numberValue + let allowsZero = segment.minValue === 0 + + if (!isNumberString(input)) return + + if (segment.type === "hour" && formatter.resolvedOptions().hour12) { + switch (formatter.resolvedOptions().hourCycle) { + case "h11": + if (numberValue > 11) { + segmentValue = Number.parseInt(input) + } + break + case "h12": + allowsZero = false + if (numberValue > 12) { + segmentValue = Number.parseInt(input) + } + break + } + + if (segment.value !== undefined && segment.value >= 12 && numberValue > 1) { + numberValue += 12 + } + } else if (segment.maxValue !== undefined && numberValue > segment.maxValue) { + segmentValue = Number.parseInt(input) + } + + if (isNaN(numberValue)) { + return + } + + // TODO: `segmentValue` is not used for anything? + let shouldSetValue = segmentValue !== 0 || allowsZero + if (shouldSetValue) { + if (!activeValidSegments?.[type]) { + markSegmentValid(ctx, type) + } + setValue(ctx, setSegment(getDisplayValue(ctx), type, newValue, formatter.resolvedOptions())) + } + + if ( + segment.maxValue !== undefined && + (Number(numberValue + "0") > segment.maxValue || newValue.length >= String(segment.maxValue).length) + ) { + refs.set("enteredKeys", "") + if (shouldSetValue) { + const index = context.get("activeIndex") + const activeSegmentIndex = context.get("activeSegmentIndex") + const segments = computed("segments")[index] + const nextActiveSegmentIndex = segments.findIndex( + (segment, i) => i > activeSegmentIndex && segment.isEditable, + ) + if (nextActiveSegmentIndex === -1) return + context.set("activeSegmentIndex", nextActiveSegmentIndex) + } + } else { + refs.set("enteredKeys", newValue) + } + break + } + } +} + +// SEGMENT UTILS [END] ///////////////////////////////////////////////////////////////////////////////// diff --git a/packages/machines/date-picker/src/date-picker.props.ts b/packages/machines/date-picker/src/date-picker.props.ts index 35eead8593..a0744d3012 100644 --- a/packages/machines/date-picker/src/date-picker.props.ts +++ b/packages/machines/date-picker/src/date-picker.props.ts @@ -29,6 +29,7 @@ export const props = createProps()([ "name", "numOfMonths", "onFocusChange", + "onPlaceholderChange", "onOpenChange", "onValueChange", "onViewChange", @@ -48,6 +49,11 @@ export const props = createProps()([ "outsideDaySelectable", "minView", "maxView", + "granularity", + "allSegments", + "formatter", + "placeholderValue", + "defaultPlaceholderValue", ]) export const splitProps = createSplitProps>(props) diff --git a/packages/machines/date-picker/src/date-picker.types.ts b/packages/machines/date-picker/src/date-picker.types.ts index fba739b21e..62a27ba37e 100644 --- a/packages/machines/date-picker/src/date-picker.types.ts +++ b/packages/machines/date-picker/src/date-picker.types.ts @@ -8,10 +8,11 @@ import type { ZonedDateTime, } from "@internationalized/date" import type { Machine, Service } from "@zag-js/core" -import type { DateRangePreset } from "@zag-js/date-utils" +import type { DateRangePreset, DateGranularity } from "@zag-js/date-utils" import type { LiveRegion } from "@zag-js/live-region" import type { Placement, PositioningOptions } from "@zag-js/popper" import type { CommonProperties, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types" +import type { EDITABLE_SEGMENTS } from "./date-picker.utils" /* ----------------------------------------------------------------------------- * Callback details @@ -30,6 +31,10 @@ export interface FocusChangeDetails extends ValueChangeDetails { view: DateView } +export interface PlaceholderChangeDetails extends ValueChangeDetails { + placeholderValue: DateValue +} + export interface ViewChangeDetails { view: DateView } @@ -60,7 +65,7 @@ export interface IntlTranslations { clearTrigger: string trigger: (open: boolean) => string content: string - placeholder: (locale: string) => { year: string; month: string; day: string } + placeholder: (locale: string) => Record } export type ElementIds = Partial<{ @@ -152,6 +157,15 @@ export interface DatePickerProps extends DirectionProperty, CommonProperties { * Use when you don't need to control the focused date of the date picker. */ defaultFocusedValue?: DateValue | undefined + /** + * The controlled placeholder date. + */ + placeholderValue?: DateValue | undefined + /** + * The initial placeholder date when rendered. + * The date that is used when the date picker is empty to determine what point in time the calendar should start at. + */ + defaultPlaceholderValue?: DateValue | undefined /** * The number of months to display. */ @@ -180,6 +194,10 @@ export interface DatePickerProps extends DirectionProperty, CommonProperties { * Function called when the focused date changes. */ onFocusChange?: ((details: FocusChangeDetails) => void) | undefined + /** + * A function called when the placeholder value changes. + */ + onPlaceholderChange?: ((details: PlaceholderChangeDetails) => void) | undefined /** * Function called when the view changes. */ @@ -249,6 +267,15 @@ export interface DatePickerProps extends DirectionProperty, CommonProperties { * Whether to render the date picker inline */ inline?: boolean | undefined + /** + * Determines the smallest unit that is displayed in the date picker. By default, this is `"day"`. + */ + granularity?: DateGranularity | undefined + formatter?: DateFormatter | undefined + /** + * + */ + allSegments?: Segments | undefined } type PropsWithDefault = @@ -265,6 +292,10 @@ type PropsWithDefault = | "parse" | "defaultFocusedValue" | "outsideDaySelectable" + | "granularity" + | "translations" + | "formatter" + | "allSegments" interface PrivateContext { /** @@ -288,6 +319,10 @@ interface PrivateContext { * Used in range selection mode. */ activeIndex: number + /** + * The index of the currently active segment. + */ + activeSegmentIndex: number /** * The computed placement (maybe different from initial placement) */ @@ -308,6 +343,14 @@ interface PrivateContext { * The focused date. */ focusedValue: DateValue + /** + * The placeholder date. + */ + placeholderValue: DateValue + /** + * The valid segments for each date value (tracks which segments have been filled). + */ + validSegments: Segments[] } type ComputedContext = Readonly<{ @@ -343,6 +386,10 @@ type ComputedContext = Readonly<{ * The value text to display in the input. */ valueAsString: string[] + /** + * A list of segments for the selected date(s). + */ + segments: DateSegment[][] }> type Refs = { @@ -350,6 +397,10 @@ type Refs = { * The live region to announce changes */ announcer?: LiveRegion | undefined + /** + * Accumulated keys entered in the focused segment + */ + enteredKeys: string } export interface DatePickerSchema { @@ -402,6 +453,77 @@ export interface TableCellState { readonly disabled: boolean } +export interface SegmentGroupProps { + index?: number | undefined +} + +export interface SegmentsProps { + index?: number | undefined +} + +export type SegmentType = + | "era" + | "year" + | "month" + | "day" + | "hour" + | "minute" + | "second" + | "dayPeriod" + | "literal" + | "timeZoneName" + +export type Segments = Partial<{ + -readonly [K in keyof typeof EDITABLE_SEGMENTS]: boolean +}> + +export type EditableSegmentType = { + [K in keyof typeof EDITABLE_SEGMENTS]: (typeof EDITABLE_SEGMENTS)[K] extends true ? K : never +}[keyof typeof EDITABLE_SEGMENTS] + +export interface DateSegment { + /** + * The type of segment. + */ + type: SegmentType + /** + * The formatted text for the segment. + */ + text: string + /** + * The numeric value for the segment, if applicable. + */ + value?: number + /** + * The minimum numeric value for the segment, if applicable. + */ + minValue?: number + /** + * The maximum numeric value for the segment, if applicable. + */ + maxValue?: number + /** + * Whether the value is a placeholder. + */ + isPlaceholder: boolean + /** + * A placeholder string for the segment. + */ + placeholder: string + /** + * Whether the segment is editable. + */ + isEditable: boolean +} + +export interface SegmentProps { + segment: DateSegment + index?: number | undefined +} + +export interface SegmentState { + editable: boolean +} export interface DayTableCellProps { value: DateValue disabled?: boolean | undefined @@ -514,7 +636,7 @@ export interface DatePickerApi { /** * Returns an array of days in the week index counted from the provided start date, or the first visible date if not given. */ - getDaysInWeek: (week: number, from?: DateValue) => DateValue[] + getDaysInWeek: (week: number, from?: DateValue | undefined) => DateValue[] /** * Returns the offset of the month based on the provided number of months. */ @@ -526,7 +648,7 @@ export interface DatePickerApi { /** * Returns the weeks of the month from the provided date. Represented as an array of arrays of dates. */ - getMonthWeeks: (from?: DateValue) => DateValue[][] + getMonthWeeks: (from?: DateValue | undefined) => DateValue[][] /** * Returns whether the provided date is available (or can be selected) */ @@ -571,6 +693,18 @@ export interface DatePickerApi { * The focused date as a string. */ focusedValueAsString: string + /** + * The placeholder date. + */ + placeholderValue: DateValue + /** + * The placeholder date as a Date object. + */ + placeholderValueAsDate: Date + /** + * The placeholder date as a string. + */ + placeholderValueAsString: string /** * Sets the selected date to today. */ @@ -607,7 +741,7 @@ export interface DatePickerApi { * Returns the years of the decade based on the columns. * Represented as an array of arrays of years. */ - getYearsGrid: (props?: YearGridProps) => YearGridValue + getYearsGrid: (props?: YearGridProps | undefined) => YearGridValue /** * Returns the start and end years of the decade. */ @@ -615,16 +749,16 @@ export interface DatePickerApi { /** * Returns the months of the year */ - getMonths: (props?: MonthFormatOptions) => Cell[] + getMonths: (props?: MonthFormatOptions | undefined) => Cell[] /** * Returns the months of the year based on the columns. * Represented as an array of arrays of months. */ - getMonthsGrid: (props?: MonthGridProps) => MonthGridValue + getMonthsGrid: (props?: MonthGridProps | undefined) => MonthGridValue /** * Formats the given date value based on the provided options. */ - format: (value: DateValue, opts?: Intl.DateTimeFormatOptions) => string + format: (value: DateValue, opts?: Intl.DateTimeFormatOptions | undefined) => string /** * Sets the view of the date picker. */ @@ -649,6 +783,18 @@ export interface DatePickerApi { * Returns the state details for a given year cell. */ getYearTableCellState: (props: TableCellProps) => TableCellState + /** + * Returns the props for the segment group container. + */ + getSegmentGroupProps: (props?: SegmentGroupProps | undefined) => T["element"] + /** + * Returns the props for a given segment. + */ + getSegments: (props?: SegmentsProps | undefined) => DateSegment[] + /** + * Returns the state details for a given segment. + */ + getSegmentState: (props: SegmentProps) => SegmentState getRootProps: () => T["element"] getLabelProps: (props?: LabelProps) => T["label"] @@ -683,6 +829,7 @@ export interface DatePickerApi { getViewTriggerProps: (props?: ViewProps) => T["button"] getViewControlProps: (props?: ViewProps) => T["element"] getInputProps: (props?: InputProps) => T["input"] + getSegmentProps: (props: SegmentProps) => T["element"] getMonthSelectProps: () => T["select"] getYearSelectProps: () => T["select"] } diff --git a/packages/machines/date-picker/src/date-picker.utils.ts b/packages/machines/date-picker/src/date-picker.utils.ts index 14901a7422..690fae876b 100644 --- a/packages/machines/date-picker/src/date-picker.utils.ts +++ b/packages/machines/date-picker/src/date-picker.utils.ts @@ -1,6 +1,14 @@ -import { DateFormatter, type DateValue } from "@internationalized/date" +import { DateFormatter, getMinimumDayInMonth, getMinimumMonthInYear, type DateValue } from "@internationalized/date" import { clampValue, match } from "@zag-js/utils" -import type { DateView, IntlTranslations } from "./date-picker.types" +import type { + DateSegment, + DateView, + EditableSegmentType, + IntlTranslations, + Segments, + SegmentType, +} from "./date-picker.types" +import type { DateGranularity } from "@zag-js/date-utils" export function adjustStartAndEndDate(value: DateValue[]) { const [startDate, endDate] = value @@ -95,9 +103,21 @@ export const defaultTranslations: IntlTranslations = { day: "Switch to next month", }) }, - // TODO: Revisit this placeholder() { - return { day: "dd", month: "mm", year: "yyyy" } + return { + day: "dd", + month: "mm", + year: "yyyy", + hour: "--", + minute: "--", + second: "--", + dayPeriod: "AM/PM", + era: "era", + timeZoneName: "timeZone", + weekday: "weekday", + unknown: "unknown", + fractionalSecond: "ff", + } }, content: "calendar", monthSelect: "Select month", @@ -149,3 +169,304 @@ const views: DateView[] = ["day", "month", "year"] export function eachView(cb: (view: DateView) => void) { views.forEach((view) => cb(view)) } + +// --------------------------------------------------- +// SEGMENTS +// --------------------------------------------------- + +export const EDITABLE_SEGMENTS = { + year: true, + month: true, + day: true, + hour: true, + minute: true, + second: true, + dayPeriod: true, + era: true, + literal: false, + timeZoneName: false, + weekday: false, + unknown: false, + fractionalSecond: false, +} as const satisfies Record + +export const PAGE_STEP = { + year: 5, + month: 2, + day: 7, + hour: 2, + minute: 15, + second: 15, + dayPeriod: undefined, + era: undefined, + literal: undefined, + timeZoneName: undefined, + weekday: undefined, + unknown: undefined, + fractionalSecond: undefined, +} as const satisfies Record + +export const TYPE_MAPPING = { + // Node seems to convert everything to lowercase... + dayperiod: "dayPeriod", + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts#named_years + relatedYear: "year", + yearName: "literal", // not editable + unknown: "literal", +} as const + +function getSafeType(type: TType): TType { + return (TYPE_MAPPING as any)[type] ?? type +} + +function getPlaceholder(type: EditableSegmentType, translations: IntlTranslations, locale: string): string { + return translations.placeholder(locale)[type] +} + +function isEditableSegment(type: keyof Intl.DateTimeFormatPartTypesRegistry): type is EditableSegmentType { + return EDITABLE_SEGMENTS[type] === true +} + +interface ProcessSegmentsProps { + dateValue: Date + displayValue: DateValue + validSegments: Segments + formatter: DateFormatter + locale: string + translations: IntlTranslations + granularity: DateGranularity +} + +export function processSegments({ + dateValue, + displayValue, + validSegments, + formatter, + locale, + translations, + granularity, +}: ProcessSegmentsProps): DateSegment[] { + const timeValue = ["hour", "minute", "second"] + const segments = formatter.formatToParts(dateValue) + const resolvedOptions = formatter.resolvedOptions() + const processedSegments: DateSegment[] = [] + + for (const segment of segments) { + const type = getSafeType(segment.type) + let isEditable = isEditableSegment(type) + if (type === "era" && displayValue.calendar.getEras().length === 1) { + isEditable = false + } + + const isPlaceholder = isEditable && !validSegments[type] + const placeholder = isEditableSegment(type) ? getPlaceholder(type, translations, locale) : null + + const dateSegment = { + type, + text: isPlaceholder ? placeholder : segment.value, + ...getSegmentLimits(displayValue, type, resolvedOptions), + isPlaceholder, + placeholder, + isEditable, + } as DateSegment + + // There is an issue in RTL languages where time fields render (minute:hour) instead of (hour:minute). + // To force an LTR direction on the time field since, we wrap the time segments in LRI (left-to-right) isolate unicode. See https://www.w3.org/International/questions/qa-bidi-unicode-controls. + // These unicode characters will be added to the array of processed segments as literals and will mark the start and end of the embedded direction change. + if (type === "hour") { + // This marks the start of the embedded direction change. + processedSegments.push({ + type: "literal", + text: "\u2066", + ...getSegmentLimits(displayValue, "literal", resolvedOptions), + isPlaceholder: false, + placeholder: "", + isEditable: false, + }) + processedSegments.push(dateSegment) + // This marks the end of the embedded direction change in the case that the granularity it set to "hour". + if (type === granularity) { + processedSegments.push({ + type: "literal", + text: "\u2069", + ...getSegmentLimits(displayValue, "literal", resolvedOptions), + isPlaceholder: false, + placeholder: "", + isEditable: false, + }) + } + } else if (timeValue.includes(type) && type === granularity) { + processedSegments.push(dateSegment) + // This marks the end of the embedded direction change. + processedSegments.push({ + type: "literal", + text: "\u2069", + ...getSegmentLimits(displayValue, "literal", resolvedOptions), + isPlaceholder: false, + placeholder: "", + isEditable: false, + }) + } else { + // We only want to "wrap" the unicode around segments that are hour, minute, or second. If they aren't, just process as normal. + processedSegments.push(dateSegment) + } + } + + return processedSegments +} + +function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedDateTimeFormatOptions) { + switch (type) { + case "era": { + const eras = date.calendar.getEras() + return { + value: eras.indexOf(date.era), + minValue: 0, + maxValue: eras.length - 1, + } + } + case "year": + return { + value: date.year, + minValue: 1, + maxValue: date.calendar.getYearsInEra(date), + } + case "month": + return { + value: date.month, + minValue: getMinimumMonthInYear(date), + maxValue: date.calendar.getMonthsInYear(date), + } + case "day": + return { + value: date.day, + minValue: getMinimumDayInMonth(date), + maxValue: date.calendar.getDaysInMonth(date), + } + } + + if ("hour" in date) { + switch (type) { + case "dayPeriod": + return { + value: date.hour >= 12 ? 12 : 0, + minValue: 0, + maxValue: 12, + } + case "hour": + if (options.hour12) { + const isPM = date.hour >= 12 + return { + value: date.hour, + minValue: isPM ? 12 : 0, + maxValue: isPM ? 23 : 11, + } + } + + return { + value: date.hour, + minValue: 0, + maxValue: 23, + } + case "minute": + return { + value: date.minute, + minValue: 0, + maxValue: 59, + } + case "second": + return { + value: date.second, + minValue: 0, + maxValue: 59, + } + } + } + + return {} +} + +export function addSegment( + value: DateValue, + type: SegmentType, + amount: number, + options: Intl.ResolvedDateTimeFormatOptions, +) { + switch (type) { + case "era": + case "year": + case "month": + case "day": + return value.cycle(type, amount, { round: type === "year" }) + } + + if ("hour" in value) { + switch (type) { + case "dayPeriod": { + let hours = value.hour + let isPM = hours >= 12 + return value.set({ hour: isPM ? hours - 12 : hours + 12 }) + } + case "hour": + case "minute": + case "second": + return value.cycle(type, amount, { + round: type !== "hour", + hourCycle: options.hour12 ? 12 : 24, + }) + } + } + + throw new Error("Unknown segment: " + type) +} + +export function setSegment( + value: DateValue, + part: string, + segmentValue: number | string, + options: Intl.ResolvedDateTimeFormatOptions, +) { + switch (part) { + case "day": + case "month": + case "year": + case "era": + return value.set({ [part]: segmentValue }) + } + + if ("hour" in value && typeof segmentValue === "number") { + switch (part) { + case "dayPeriod": { + let hours = value.hour + let wasPM = hours >= 12 + let isPM = segmentValue >= 12 + if (isPM === wasPM) { + return value + } + return value.set({ hour: wasPM ? hours - 12 : hours + 12 }) + } + case "hour": + // In 12 hour time, ensure that AM/PM does not change + if (options.hour12) { + let hours = value.hour + let wasPM = hours >= 12 + if (!wasPM && segmentValue === 12) { + segmentValue = 0 + } + if (wasPM && segmentValue < 12) { + segmentValue += 12 + } + } + // fallthrough + case "minute": + case "second": + return value.set({ [part]: segmentValue }) + } + } + + throw new Error("Unknown segment: " + part) +} + +export function getDefaultValidSegments(value: DateValue[] | undefined, allSegments: Segments) { + return value?.length ? value.map((date) => (date ? { ...allSegments } : {})) : [{}] +} diff --git a/shared/src/css/date-picker.css b/shared/src/css/date-picker.css index c6e37a1d0d..03f9b7e0dd 100644 --- a/shared/src/css/date-picker.css +++ b/shared/src/css/date-picker.css @@ -37,6 +37,30 @@ margin-bottom: 16px; } +[data-scope="date-picker"][data-part="segment-group"] { + display: inline-flex; + align-items: center; + padding-block: 2px; + padding-inline: 3px; + border: 1px solid rgb(118, 118, 118); + border-radius: 2px; + background-color: #fff; + color: #333; + min-width: 154px; +} + +[data-scope="date-picker"][data-part="segment"] { + font-variant-numeric: tabular-nums; + text-align: end; + font-size: 13.3333px; + border-radius: 2px; + + &:focus { + background-color: rgba(102, 175, 233, 0.6); + outline: none; + } +} + [data-scope="date-picker"][data-part="table-cell-trigger"][data-today] { color: purple; } diff --git a/shared/src/routes.ts b/shared/src/routes.ts index fac22eb4d6..1c05c67d87 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -40,6 +40,7 @@ export const routesData: RouteData[] = [ { label: "Date Picker (Inline)", path: "/date-picker-inline" }, { label: "Date Picker (Month + Range)", path: "/date-picker-month-range" }, { label: "Date Picker (Year + Range)", path: "/date-picker-year-range" }, + { label: "Date Picker (Segment Single)", path: "/date-picker-segment-single" }, { label: "Select", path: "/select" }, { label: "Accordion", path: "/accordion" }, { label: "Checkbox", path: "/checkbox" },