Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
88c58d4
feat: implement segmets getter
isBatak Aug 23, 2025
c00b050
feat: wip on segments
isBatak Aug 26, 2025
5738bac
Merge branch 'main' into feat/date-picker-segments
isBatak Aug 27, 2025
53f04c7
feat: implement placeholder
isBatak Aug 27, 2025
f75ca3a
feat: fill in segments on value select
isBatak Aug 28, 2025
e7af282
feat: working on key events
isBatak Aug 28, 2025
aa7df7a
feat: replace local date formatter with global reference
isBatak Aug 29, 2025
23ec242
Merge branch 'main' into feat/date-picker-segments
isBatak Aug 29, 2025
1cd6fe2
chore: fix comments
isBatak Aug 29, 2025
bd18470
feat: complete segment value increment/decrement functionality
isBatak Aug 30, 2025
53215e9
chore: some fixes
isBatak Aug 31, 2025
ed687a3
feat: implement placeholderValue and reset segments on value clear
isBatak Sep 2, 2025
1d1840c
feat: fix markValid
isBatak Sep 8, 2025
cf81627
feat: wip on backspace
isBatak Sep 8, 2025
4111e34
Merge branch 'main' into feat/date-picker-segments
isBatak Sep 10, 2025
6bb7dc7
chore: remove extra pages
isBatak Sep 10, 2025
3fb2682
feat: wip on segment delete
isBatak Sep 10, 2025
41b7307
chore: remove console log
isBatak Sep 10, 2025
c622441
Merge branch 'main' into feat/date-picker-segments
isBatak Sep 13, 2025
7ff3396
refactor(date-picker): simplify key handling and segment validation l…
isBatak Sep 13, 2025
8730fd3
feat: initial segment input support
isBatak Sep 13, 2025
0333c8c
Merge branch 'main' into feat/date-picker-segments
isBatak Sep 18, 2025
2c2a990
Merge branch 'main' into feat/date-picker-segments
isBatak Sep 20, 2025
f553a3d
feat: improve focus management
isBatak Sep 20, 2025
ca53ef0
chore: updates
isBatak Sep 20, 2025
5fb170c
feat: fix input issures
isBatak Sep 20, 2025
9d4cc93
feat: clear placeholder data
isBatak Sep 22, 2025
3b2138f
Merge branch 'main' into feat/date-picker-segments
isBatak Sep 27, 2025
e693147
feat: implement home and end key actions
isBatak Sep 27, 2025
b2658c6
feat: prevent default behavior for paste events in date picker input
isBatak Sep 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions examples/next-ts/pages/date-picker-segment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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(),
locale: "en",
selectionMode: "single",
...controls.context,
})

const api = datePicker.connect(service, normalizeProps)

return (
<>
<main className="date-picker">
<div>
<button>Outside Element</button>
</div>
<p>{`Visible range: ${api.visibleRangeText.formatted}`}</p>

<output className="date-output">
<div>Selected: {api.valueAsString ?? "-"}</div>
<div>Focused: {api.focusedValueAsString}</div>
</output>

<div {...api.getControlProps()}>
<div {...api.getSegmentInputProps()}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's rename this part to segmentGroup instead of segmentInput (since it's not technically an input)

{api.getSegments().map((segment, i) => (
<span key={i} {...api.getSegmentProps({ segment })}>
{segment.text}
</span>
))}
</div>
<button {...api.getClearTriggerProps()}></button>
<button {...api.getTriggerProps()}>🗓</button>
</div>

<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<div style={{ marginBottom: "20px" }}>
<select {...api.getMonthSelectProps()}>
{api.getMonths().map((month, i) => (
<option key={i} value={month.value} disabled={month.disabled}>
{month.label}
</option>
))}
</select>

<select {...api.getYearSelectProps()}>
{api.getYears().map((year, i) => (
<option key={i} value={year.value} disabled={year.disabled}>
{year.label}
</option>
))}
</select>
</div>

<div hidden={api.view !== "day"}>
<div {...api.getViewControlProps({ view: "year" })}>
<button {...api.getPrevTriggerProps()}>Prev</button>
<button {...api.getViewTriggerProps()}>{api.visibleRangeText.start}</button>
<button {...api.getNextTriggerProps()}>Next</button>
</div>

<table {...api.getTableProps({ view: "day" })}>
<thead {...api.getTableHeaderProps({ view: "day" })}>
<tr {...api.getTableRowProps({ view: "day" })}>
{api.weekDays.map((day, i) => (
<th scope="col" key={i} aria-label={day.long}>
{day.narrow}
</th>
))}
</tr>
</thead>
<tbody {...api.getTableBodyProps({ view: "day" })}>
{api.weeks.map((week, i) => (
<tr key={i} {...api.getTableRowProps({ view: "day" })}>
{week.map((value, i) => (
<td key={i} {...api.getDayTableCellProps({ value })}>
<div {...api.getDayTableCellTriggerProps({ value })}>{value.day}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>

<div style={{ display: "flex", gap: "40px" }}>
<div hidden={api.view !== "month"} style={{ width: "100%" }}>
<div {...api.getViewControlProps({ view: "month" })}>
<button {...api.getPrevTriggerProps({ view: "month" })}>Prev</button>
<button {...api.getViewTriggerProps({ view: "month" })}>{api.visibleRange.start.year}</button>
<button {...api.getNextTriggerProps({ view: "month" })}>Next</button>
</div>

<table {...api.getTableProps({ view: "month", columns: 4 })}>
<tbody {...api.getTableBodyProps({ view: "month" })}>
{api.getMonthsGrid({ columns: 4, format: "short" }).map((months, row) => (
<tr key={row} {...api.getTableRowProps()}>
{months.map((month, index) => (
<td key={index} {...api.getMonthTableCellProps({ ...month, columns: 4 })}>
<div {...api.getMonthTableCellTriggerProps({ ...month, columns: 4 })}>{month.label}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>

<div hidden={api.view !== "year"} style={{ width: "100%" }}>
<div {...api.getViewControlProps({ view: "year" })}>
<button {...api.getPrevTriggerProps({ view: "year" })}>Prev</button>
<span>
{api.getDecade().start} - {api.getDecade().end}
</span>
<button {...api.getNextTriggerProps({ view: "year" })}>Next</button>
</div>

<table {...api.getTableProps({ view: "year", columns: 4 })}>
<tbody {...api.getTableBodyProps()}>
{api.getYearsGrid({ columns: 4 }).map((years, row) => (
<tr key={row} {...api.getTableRowProps({ view: "year" })}>
{years.map((year, index) => (
<td key={index} {...api.getYearTableCellProps({ ...year, columns: 4 })}>
<div {...api.getYearTableCellTriggerProps({ ...year, columns: 4 })}>{year.label}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>

<Toolbar viz controls={controls.ui}>
<StateVisualizer state={service} omit={["weeks"]} />
</Toolbar>
</>
)
}
2 changes: 2 additions & 0 deletions packages/machines/date-picker/src/date-picker.anatomy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const anatomy = createAnatomy("date-picker").parts(
"content",
"control",
"input",
"segmentInput",
"segment",
"label",
"monthSelect",
"nextTrigger",
Expand Down
143 changes: 141 additions & 2 deletions packages/machines/date-picker/src/date-picker.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ import type {
TableCellProps,
TableCellState,
TableProps,
SegmentProps,
SegmentState,
} from "./date-picker.types"
import {
adjustStartAndEndDate,
defaultTranslations,
ensureValidCharacters,
getInputPlaceholder,
getLocaleSeparator,
Expand Down Expand Up @@ -79,7 +80,7 @@ export function connect<T extends PropTypes>(
})

const separator = getLocaleSeparator(locale)
const translations = { ...defaultTranslations, ...prop("translations") }
const translations = prop("translations")

function getMonthWeeks(from = startValue) {
const numOfWeeks = prop("fixedWeeks") ? 6 : undefined
Expand Down Expand Up @@ -223,6 +224,11 @@ export function connect<T extends PropTypes>(
return [view, id].filter(Boolean).join(" ")
}

function getSegmentState(props: SegmentProps): SegmentState {
const {} = props
return {}
}

return {
focused,
open,
Expand Down Expand Up @@ -703,6 +709,7 @@ export function connect<T extends PropTypes>(
"data-state": open ? "open" : "closed",
"aria-haspopup": "grid",
disabled,
"data-readonly": dataAttr(readOnly),
onClick(event) {
if (event.defaultPrevented) return
if (!interactive) return
Expand Down Expand Up @@ -803,6 +810,138 @@ export function connect<T extends PropTypes>(
})
},

getSegmentInputProps() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can pass the index as props here

return normalize.element({
...parts.segmentInput.attrs,
id: dom.getInputId(scope, 0), // FIXIT: figure out the index
dir: prop("dir"),
"data-state": open ? "open" : "closed",
role: "presentation",
readOnly,
disabled,
style: {
unicodeBidi: "isolate",
},
})
},

getSegments(props = {}) {
const { index = 0 } = props
return computed("segments")[index] ?? []
},

getSegmentState,

getSegmentProps(props) {
const { segment } = props
const isEditable = !disabled && !readOnly && segment.isEditable

if (segment.type === "literal") {
return normalize.element({
...parts.segment.attrs,
dir: prop("dir"),
"aria-hidden": true,
"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: isEditable ? "false" : undefined,
autoCorrect: isEditable ? "off" : undefined,
contentEditable: isEditable,
suppressContentEditableWarning: isEditable,
inputMode:
disabled || segment.type === "dayPeriod" || segment.type === "era" || !isEditable ? undefined : "numeric",
enterKeyHint: "next",
"aria-labelledby": dom.getInputId(scope, 0), // FIXIT: figure out the index
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also pass the index from props. set the default to 0

// "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: {
"caret-color": "transparent",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be caretColor?

},
onKeyDown(event) {
if (
event.defaultPrevented ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey ||
readOnly ||
event.nativeEvent.isComposing
) {
return
}

const keyMap: EventKeyMap = {
Enter() {
send({ type: "SEGMENT.ENTER", focus: true })
},
ArrowLeft() {
send({ type: "SEGMENT.ARROW_LEFT", focus: true })
},
ArrowRight() {
send({ type: "SEGMENT.ARROW_RIGHT", focus: true })
},
ArrowUp() {
send({ type: "SEGMENT.ARROW_UP", segment, focus: true })
},
ArrowDown() {
send({ type: "SEGMENT.ARROW_DOWN", segment, focus: true })
},
PageUp(event) {
send({ type: "SEGMENT.PAGE_UP", larger: event.shiftKey, focus: true })
},
PageDown(event) {
send({ type: "SEGMENT.PAGE_DOWN", larger: event.shiftKey, focus: true })
},
Home() {
send({ type: "SEGMENT.HOME", focus: true })
},
End() {
send({ type: "SEGMENT.END", focus: true })
},
Comment on lines 942 to 979
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After you get this working, I wonder if we can bundle these into a SEGMENT.CYCLE or SEGMENT.INC/DEC event to reduce the repetition here and in the machine.

}

const exec =
keyMap[
getEventKey(event, {
dir: prop("dir"),
})
]

if (exec) {
exec(event)
event.preventDefault()
event.stopPropagation()
}
},
onPointerDown(event) {
event.stopPropagation()
},
onMouseDown(event) {
event.stopPropagation()
},
})
},

getMonthSelectProps() {
return normalize.select({
...parts.monthSelect.attrs,
Expand Down
Loading
Loading