Skip to content

Commit

Permalink
feat(date-picker): add positioner (#770)
Browse files Browse the repository at this point in the history
Co-authored-by: Segun Adebayo <[email protected]>
  • Loading branch information
cschroeter and segunadebayo authored Jul 28, 2023
1 parent f2531d0 commit da81683
Show file tree
Hide file tree
Showing 10 changed files with 205 additions and 165 deletions.
5 changes: 5 additions & 0 deletions .changeset/breezy-peaches-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zag-js/date-picker": minor
---

Add positioner part to date picker machine to allow dynamic positioning.
2 changes: 1 addition & 1 deletion .xstate/date-picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const fetchMachine = createMachine({
},
open: {
tags: "open",
activities: ["trackDismissableElement"],
activities: ["trackDismissableElement", "trackPositioning"],
entry: ctx.inline ? undefined : ["focusActiveCell"],
exit: ["clearHoveredDate", "resetView"],
on: {
Expand Down
189 changes: 99 additions & 90 deletions examples/next-ts/pages/date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,63 +40,27 @@ export default function Page() {
<button {...api.clearTriggerProps}></button>
<button {...api.triggerProps}>🗓</button>
</div>
<div {...api.positionerProps}>
<div {...api.contentProps}>
<div style={{ marginBlock: "20px" }}>
<select {...api.monthSelectProps}>
{api.getMonths().map((month, i) => (
<option key={i} value={month.value}>
{month.label}
</option>
))}
</select>

<div {...api.contentProps}>
<div style={{ marginBlock: "20px" }}>
<select {...api.monthSelectProps}>
{api.getMonths().map((month, i) => (
<option key={i} value={month.value}>
{month.label}
</option>
))}
</select>

<select {...api.yearSelectProps}>
{getYearsRange({ from: 1_000, to: 4_000 }).map((year, i) => (
<option key={i} value={year}>
{year}
</option>
))}
</select>
</div>

<div hidden={api.view !== "day"} style={{ maxWidth: "230px" }}>
<div
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBlock: "10px" }}
>
<button {...api.getPrevTriggerProps()}>Prev</button>
<button {...api.getViewTriggerProps()} style={{ border: "0", padding: "4px 20px", borderRadius: "4px" }}>
{api.visibleRangeText.start}
</button>
<button {...api.getNextTriggerProps()}>Next</button>
</div>

<table {...api.getGridProps()}>
<thead {...api.getHeaderProps()}>
<tr>
{api.weekDays.map((day, i) => (
<th scope="col" key={i} aria-label={day.long}>
{day.narrow}
</th>
))}
</tr>
</thead>
<tbody>
{api.weeks.map((week, i) => (
<tr key={i}>
{week.map((value, i) => (
<td key={i} {...api.getDayCellProps({ value })}>
<div {...api.getDayCellTriggerProps({ value })}>{value.day}</div>
</td>
))}
</tr>
<select {...api.yearSelectProps}>
{getYearsRange({ from: 1_000, to: 4_000 }).map((year, i) => (
<option key={i} value={year}>
{year}
</option>
))}
</tbody>
</table>
</div>
</select>
</div>

<div style={{ display: "flex", gap: "40px", marginTop: "24px" }}>
<div hidden={api.view !== "month"}>
<div hidden={api.view !== "day"} style={{ maxWidth: "230px" }}>
<div
style={{
display: "flex",
Expand All @@ -105,18 +69,32 @@ export default function Page() {
marginBlock: "10px",
}}
>
<button {...api.getPrevTriggerProps({ view: "month" })}>Prev</button>
<span {...api.getViewTriggerProps({ view: "month" })}>{api.visibleRange.start.year}</span>
<button {...api.getNextTriggerProps({ view: "month" })}>Next</button>
<button {...api.getPrevTriggerProps()}>Prev</button>
<button
{...api.getViewTriggerProps()}
style={{ border: "0", padding: "4px 20px", borderRadius: "4px" }}
>
{api.visibleRangeText.start}
</button>
<button {...api.getNextTriggerProps()}>Next</button>
</div>

<table {...api.getGridProps({ view: "month", columns: 4 })}>
<table {...api.getGridProps()}>
<thead {...api.getHeaderProps()}>
<tr>
{api.weekDays.map((day, i) => (
<th scope="col" key={i} aria-label={day.long}>
{day.narrow}
</th>
))}
</tr>
</thead>
<tbody>
{api.getMonthsGrid({ columns: 4, format: "short" }).map((months, row) => (
<tr key={row}>
{months.map((month, index) => (
<td key={index} {...api.getMonthCellProps(month)}>
<div {...api.getMonthCellTriggerProps(month)}>{month.label}</div>
{api.weeks.map((week, i) => (
<tr key={i}>
{week.map((value, i) => (
<td key={i} {...api.getDayCellProps({ value })}>
<div {...api.getDayCellTriggerProps({ value })}>{value.day}</div>
</td>
))}
</tr>
Expand All @@ -125,35 +103,66 @@ export default function Page() {
</table>
</div>

<div hidden={api.view !== "year"}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBlock: "10px",
}}
>
<button {...api.getPrevTriggerProps({ view: "year" })}>Prev</button>
<span>
{api.getDecade().start} - {api.getDecade().end}
</span>
<button {...api.getNextTriggerProps({ view: "year" })}>Next</button>
<div style={{ display: "flex", gap: "40px", marginTop: "24px" }}>
<div hidden={api.view !== "month"}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBlock: "10px",
}}
>
<button {...api.getPrevTriggerProps({ view: "month" })}>Prev</button>
<span {...api.getViewTriggerProps({ view: "month" })}>{api.visibleRange.start.year}</span>
<button {...api.getNextTriggerProps({ view: "month" })}>Next</button>
</div>

<table {...api.getGridProps({ view: "month", columns: 4 })}>
<tbody>
{api.getMonthsGrid({ columns: 4, format: "short" }).map((months, row) => (
<tr key={row}>
{months.map((month, index) => (
<td key={index} {...api.getMonthCellProps(month)}>
<div {...api.getMonthCellTriggerProps(month)}>{month.label}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>

<table {...api.getGridProps({ view: "year", columns: 4 })}>
<tbody>
{api.getYearsGrid({ columns: 4 }).map((years, row) => (
<tr key={row}>
{years.map((year, index) => (
<td colSpan={4} key={index} {...api.getYearCellProps(year)}>
<div {...api.getYearCellTriggerProps(year)}>{year.label}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
<div hidden={api.view !== "year"}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBlock: "10px",
}}
>
<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.getGridProps({ view: "year", columns: 4 })}>
<tbody>
{api.getYearsGrid({ columns: 4 }).map((years, row) => (
<tr key={row}>
{years.map((year, index) => (
<td colSpan={4} key={index} {...api.getYearCellProps(year)}>
<div {...api.getYearCellTriggerProps(year)}>{year.label}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/machines/date-picker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@zag-js/dom-query": "workspace:*",
"@zag-js/dom-event": "workspace:*",
"@zag-js/form-utils": "workspace:*",
"@zag-js/popper": "workspace:*",
"@zag-js/utils": "workspace:*"
},
"devDependencies": {
Expand Down
19 changes: 10 additions & 9 deletions packages/machines/date-picker/src/date-picker.anatomy.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { createAnatomy } from "@zag-js/anatomy"

export const anatomy = createAnatomy("date-picker").parts(
"label",
"control",
"trigger",
"content",
"input",
"header",
"viewTrigger",
"cellTrigger",
"nextTrigger",
"prevTrigger",
"clearTrigger",
"content",
"control",
"grid",
"header",
"input",
"label",
"monthSelect",
"nextTrigger",
"positioner",
"prevTrigger",
"trigger",
"viewTrigger",
"yearSelect",
)

Expand Down
13 changes: 13 additions & 0 deletions packages/machines/date-picker/src/date-picker.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "@zag-js/date-utils"
import { getEventKey, getNativeEvent, type EventKeyMap } from "@zag-js/dom-event"
import { ariaAttr, dataAttr } from "@zag-js/dom-query"
import { getPlacementStyles } from "@zag-js/popper"
import type { NormalizeProps, PropTypes } from "@zag-js/types"
import { chunk } from "@zag-js/utils"
import { parts } from "./date-picker.anatomy"
Expand Down Expand Up @@ -75,6 +76,12 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
const isRangePicker = state.context.selectionMode === "range"
const isDateUnavailableFn = state.context.isDateUnavailable

const currentPlacement = state.context.currentPlacement
const popperStyles = getPlacementStyles({
...state.context.positioning,
placement: currentPlacement,
})

const defaultOffset: Offset = {
amount: 0,
visibleRange: state.context.visibleRange,
Expand Down Expand Up @@ -621,6 +628,12 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
api.focusYear(Number(event.currentTarget.value))
},
}),

positionerProps: normalize.element({
id: dom.getPositionerId(state.context),
...parts.positioner.attrs,
style: popperStyles.floating,
}),
}

return api
Expand Down
3 changes: 3 additions & 0 deletions packages/machines/date-picker/src/date-picker.dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const dom = createScope({
getControlId: (ctx: Ctx) => ctx.ids?.control ?? `datepicker:${ctx.id}:control`,
getInputId: (ctx: Ctx) => ctx.ids?.input ?? `datepicker:${ctx.id}:input`,
getTriggerId: (ctx: Ctx) => ctx.ids?.trigger ?? `datepicker:${ctx.id}:trigger`,
getPositionerId: (ctx: Ctx) => ctx.ids?.positioner ?? `datepicker:${ctx.id}:positioner`,
getMonthSelectId: (ctx: Ctx) => ctx.ids?.monthSelect ?? `datepicker:${ctx.id}:month-select`,
getYearSelectId: (ctx: Ctx) => ctx.ids?.yearSelect ?? `datepicker:${ctx.id}:year-select`,

Expand All @@ -24,4 +25,6 @@ export const dom = createScope({
getYearSelectEl: (ctx: Ctx) => dom.getById<HTMLSelectElement>(ctx, dom.getYearSelectId(ctx)),
getMonthSelectEl: (ctx: Ctx) => dom.getById<HTMLSelectElement>(ctx, dom.getMonthSelectId(ctx)),
getClearTriggerEl: (ctx: Ctx) => dom.getById<HTMLButtonElement>(ctx, dom.getClearTriggerId(ctx)),
getPositionerEl: (ctx: Ctx) => dom.getById(ctx, dom.getPositionerId(ctx)),
getControlEl: (ctx: Ctx) => dom.getById(ctx, dom.getControlId(ctx)),
})
22 changes: 21 additions & 1 deletion packages/machines/date-picker/src/date-picker.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { trackDismissableElement } from "@zag-js/dismissable"
import { raf } from "@zag-js/dom-query"
import { createLiveRegion } from "@zag-js/live-region"
import { getPlacement } from "@zag-js/popper"
import { disableTextSelection, restoreTextSelection } from "@zag-js/text-selection"
import { compact } from "@zag-js/utils"
import { dom } from "./date-picker.dom"
Expand Down Expand Up @@ -62,6 +63,10 @@ const getInitialContext = (ctx: Partial<MachineContext>): MachineContext => {
valueText: "",
hoveredValue: null,
...ctx,
positioning: {
placement: "bottom",
...ctx.positioning,
},
} as MachineContext
}

Expand Down Expand Up @@ -170,7 +175,7 @@ export function machine(userContext: UserDefinedContext) {

open: {
tags: "open",
activities: ["trackDismissableElement"],
activities: ["trackDismissableElement", "trackPositioning"],
entry: ctx.inline ? undefined : ["focusActiveCell"],
exit: ["clearHoveredDate", "resetView"],
on: {
Expand Down Expand Up @@ -367,6 +372,21 @@ export function machine(userContext: UserDefinedContext) {
isInline: (ctx) => !!ctx.inline,
},
activities: {
trackPositioning(ctx) {
ctx.currentPlacement = ctx.positioning.placement
const anchorEl = dom.getControlEl(ctx)
const getPositionerEl = () => dom.getPositionerEl(ctx)
return getPlacement(anchorEl, getPositionerEl, {
...ctx.positioning,
defer: true,
onComplete(data) {
ctx.currentPlacement = data.placement
},
onCleanup() {
ctx.currentPlacement = undefined
},
})
},
setupLiveRegion(ctx) {
const doc = dom.getDoc(ctx)
ctx.announcer = createLiveRegion({ level: "assertive", document: doc })
Expand Down
Loading

4 comments on commit da81683

@vercel
Copy link

@vercel vercel bot commented on da81683 Jul 28, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

zag-nextjs – ./examples/next-ts

zag-nextjs-chakra-ui.vercel.app
zag-nextjs-git-main-chakra-ui.vercel.app
zag-two.vercel.app

@vercel
Copy link

@vercel vercel bot commented on da81683 Jul 28, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

zag-solid – ./examples/solid-ts

zag-solid.vercel.app
zag-solid-chakra-ui.vercel.app
zag-solid-git-main-chakra-ui.vercel.app

@vercel
Copy link

@vercel vercel bot commented on da81683 Jul 28, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

zag-vue – ./examples/vue-ts

zag-vue.vercel.app
zag-vue-git-main-chakra-ui.vercel.app
zag-vue-chakra-ui.vercel.app

@vercel
Copy link

@vercel vercel bot commented on da81683 Jul 28, 2023

Choose a reason for hiding this comment

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

Please sign in to comment.