From 303427e0d8bcf92fb5f0b66bf9f1c797225bc53f Mon Sep 17 00:00:00 2001 From: Harun Aksu Date: Wed, 25 Mar 2026 01:59:21 +0300 Subject: [PATCH] Add DatePicker component to @geajs/ui - Powered by @zag-js/date-picker with day, month, and year views - Defines DatePicker props from Zag's DatePicker contract plus local UI props - Keeps the wrapper state synced with typed Zag value and open callbacks - Forwards supported Zag DatePicker props through to the machine - Regression tests cover value updates, view switching, and DOM state - Updated showcase, README, and interactive-components docs - Showcase footer count corrected to 39 (was 35) See also: #8 --- .changeset/add-date-picker.md | 7 + docs/gea-ui/interactive-components.md | 52 ++++ docs/gea-ui/overview.md | 1 + examples/showcase/app.tsx | 13 +- package-lock.json | 40 +++ packages/gea-ui/README.md | 11 + packages/gea-ui/package.json | 1 + .../gea-ui/src/components/date-picker.tsx | 267 ++++++++++++++++++ packages/gea-ui/src/index.ts | 1 + packages/gea-ui/tests/zag-bindings.test.ts | 182 ++++++++++++ 10 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-date-picker.md create mode 100644 packages/gea-ui/src/components/date-picker.tsx diff --git a/.changeset/add-date-picker.md b/.changeset/add-date-picker.md new file mode 100644 index 0000000..584d15b --- /dev/null +++ b/.changeset/add-date-picker.md @@ -0,0 +1,7 @@ +--- +"@geajs/ui": minor +--- + +### @geajs/ui (minor) + +- **DatePicker**: Add new Date Picker component powered by @zag-js/date-picker with day, month, and year views, keyboard navigation, and full accessibility support diff --git a/docs/gea-ui/interactive-components.md b/docs/gea-ui/interactive-components.md index 8e92354..5a3c293 100644 --- a/docs/gea-ui/interactive-components.md +++ b/docs/gea-ui/interactive-components.md @@ -641,6 +641,58 @@ ToastStore.loading({ title: 'Loading...', description: 'Please wait.' }) | `type` | `'success' \| 'error' \| 'info' \| 'loading'` | Toast variant (set automatically by convenience methods) | | `duration` | `number` | Auto-dismiss delay in milliseconds (default: 5000) | +## Date Picker + +Calendar date selection with day, month, and year views, keyboard navigation, and full accessibility support. Powered by `@zag-js/date-picker`. + +```tsx +import { DatePicker } from '@geajs/ui' + + console.log(details.valueAsString)} +/> +``` + +### Props + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `label` | `string` | — | Label text above the input | +| `value` | `DateValue[]` | — | Controlled selected date(s) | +| `defaultValue` | `DateValue[]` | — | Initial selected date(s) | +| `focusedValue` | `DateValue` | — | Controlled focused date | +| `defaultFocusedValue` | `DateValue` | — | Initial focused date | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | — | Initial open state | +| `disabled` | `boolean` | — | Disable the date picker | +| `readOnly` | `boolean` | — | Make the date picker read-only | +| `required` | `boolean` | — | Mark as required for form validation | +| `invalid` | `boolean` | — | Mark the input as invalid | +| `min` | `DateValue` | — | Minimum selectable date | +| `max` | `DateValue` | — | Maximum selectable date | +| `locale` | `string` | `'en-US'` | Locale for formatting | +| `timeZone` | `string` | `'UTC'` | IANA time zone | +| `selectionMode` | `'single' \| 'multiple' \| 'range'` | `'single'` | How dates are selected | +| `closeOnSelect` | `boolean` | `true` | Close the popover after selecting a date | +| `name` | `string` | — | Name attribute for form submission | +| `placeholder` | `string` | — | Placeholder text for the input | +| `inline` | `boolean` | — | Render the calendar inline (no popover) | +| `startOfWeek` | `number` | — | First day of the week (0 = Sunday) | +| `fixedWeeks` | `boolean` | — | Always show 6 weeks in the day view | +| `view` | `'day' \| 'month' \| 'year'` | — | Controlled calendar view | +| `defaultView` | `'day' \| 'month' \| 'year'` | `'day'` | Initial calendar view | +| `isDateUnavailable` | `(date: DateValue) => boolean` | — | Mark specific dates as unavailable | +| `positioning` | `PositioningOptions` | — | Popover positioning options | +| `format` | `(date: DateValue) => string` | — | Custom date formatting function | +| `parse` | `(value: string) => DateValue[]` | — | Custom date parsing function | +| `onValueChange` | `(details: { value: DateValue[], valueAsString: string[] }) => void` | — | Called when the selected date changes | +| `onOpenChange` | `(details: { open: boolean }) => void` | — | Called when the popover opens or closes | +| `onViewChange` | `(details: { view: string }) => void` | — | Called when the calendar view changes | +| `onFocusChange` | `(details: { focusedValue: DateValue, view: string }) => void` | — | Called when the focused date changes | + +**Note:** `DateValue` types come from `@internationalized/date`, a peer dependency of `@zag-js/date-picker`. + ## Tree View A hierarchical tree for displaying nested data. Accepts a Zag tree `collection` for full control over the data structure. diff --git a/docs/gea-ui/overview.md b/docs/gea-ui/overview.md index aadb4bc..3e72390 100644 --- a/docs/gea-ui/overview.md +++ b/docs/gea-ui/overview.md @@ -66,6 +66,7 @@ Backed by Zag.js state machines — full keyboard navigation, ARIA attributes, a | [Menu](interactive-components.md#menu) | Dropdown menu with typeahead | | [Select](interactive-components.md#select) | Dropdown select with keyboard navigation | | [Combobox](interactive-components.md#combobox) | Searchable dropdown | +| [Date Picker](interactive-components.md#date-picker) | Calendar date selection with day, month, and year views | | [Switch](interactive-components.md#switch) | Toggle switch | | [Checkbox](interactive-components.md#checkbox) | Checkbox with indeterminate support | | [Radio Group](interactive-components.md#radio-group) | Radio button group | diff --git a/examples/showcase/app.tsx b/examples/showcase/app.tsx index f8dfd34..e084b4f 100644 --- a/examples/showcase/app.tsx +++ b/examples/showcase/app.tsx @@ -9,6 +9,7 @@ import Checkbox from '@geajs/ui/checkbox' import Clipboard from '@geajs/ui/clipboard' import Collapsible from '@geajs/ui/collapsible' import Combobox from '@geajs/ui/combobox' +import DatePicker from '@geajs/ui/date-picker' import Dialog from '@geajs/ui/dialog' import FileUpload from '@geajs/ui/file-upload' import HoverCard from '@geajs/ui/hover-card' @@ -279,6 +280,16 @@ export default class App extends Component { +
+
+

Date Picker

+

Calendar date selection with day, month, and year views.

+
+
+ +
+
+

Switch

@@ -591,7 +602,7 @@ export default class App extends Component { - +
diff --git a/package-lock.json b/package-lock.json index 913c075..b05244f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1839,6 +1839,16 @@ } } }, + "node_modules/@internationalized/date": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@internationalized/number": { "version": "3.6.5", "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz", @@ -4059,6 +4069,35 @@ "@zag-js/utils": "1.40.0" } }, + "node_modules/@zag-js/date-picker": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@zag-js/date-picker/-/date-picker-1.37.0.tgz", + "integrity": "sha512-LJWWrb9A4XFQfYWeWxbXwAGE4nN4KGr055X3eX5pYU1tGc412Mk1KMKGeBZsB6znecMyrqj877rByVbNaVemwQ==", + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.37.0", + "@zag-js/core": "1.37.0", + "@zag-js/date-utils": "1.37.0", + "@zag-js/dismissable": "1.37.0", + "@zag-js/dom-query": "1.37.0", + "@zag-js/live-region": "1.37.0", + "@zag-js/popper": "1.37.0", + "@zag-js/types": "1.37.0", + "@zag-js/utils": "1.37.0" + }, + "peerDependencies": { + "@internationalized/date": ">=3.0.0" + } + }, + "node_modules/@zag-js/date-utils": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@zag-js/date-utils/-/date-utils-1.37.0.tgz", + "integrity": "sha512-hs+w91uIUnMe2AaTYM1nxA6KY6x2EjbJARxpq33wqjwCHC1NK4y/i58kLh1bPG2HBbLb2YlQDwX44t8aTkMUpA==", + "license": "MIT", + "peerDependencies": { + "@internationalized/date": ">=3.0.0" + } + }, "node_modules/@zag-js/dialog": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/@zag-js/dialog/-/dialog-1.40.0.tgz", @@ -12232,6 +12271,7 @@ "@zag-js/clipboard": "^1.37.0", "@zag-js/collapsible": "^1.37.0", "@zag-js/combobox": "^1.37.0", + "@zag-js/date-picker": "^1.37.0", "@zag-js/dialog": "^1.37.0", "@zag-js/file-upload": "^1.37.0", "@zag-js/hover-card": "^1.37.0", diff --git a/packages/gea-ui/README.md b/packages/gea-ui/README.md index a5aae07..66eaa80 100644 --- a/packages/gea-ui/README.md +++ b/packages/gea-ui/README.md @@ -406,6 +406,17 @@ import { Combobox } from '@geajs/ui' /> ``` +#### Date Picker + +```tsx +import { DatePicker } from '@geajs/ui' + + console.log(details.valueAsString)} +/> +``` + #### Collapsible ```tsx diff --git a/packages/gea-ui/package.json b/packages/gea-ui/package.json index 7dc67a6..067a8d6 100644 --- a/packages/gea-ui/package.json +++ b/packages/gea-ui/package.json @@ -60,6 +60,7 @@ "@zag-js/clipboard": "^1.37.0", "@zag-js/collapsible": "^1.37.0", "@zag-js/combobox": "^1.37.0", + "@zag-js/date-picker": "^1.37.0", "@zag-js/dialog": "^1.37.0", "@zag-js/file-upload": "^1.37.0", "@zag-js/hover-card": "^1.37.0", diff --git a/packages/gea-ui/src/components/date-picker.tsx b/packages/gea-ui/src/components/date-picker.tsx new file mode 100644 index 0000000..5e2409d --- /dev/null +++ b/packages/gea-ui/src/components/date-picker.tsx @@ -0,0 +1,267 @@ +import * as datepicker from '@zag-js/date-picker' +import { normalizeProps, spreadProps } from '@zag-js/vanilla' +import ZagComponent from '../primitives/zag-component' + +const CHEVRON_LEFT = `` +const CHEVRON_RIGHT = `` + +export interface DatePickerProps extends datepicker.Props { + class?: string + label?: string +} + +export default class DatePicker extends ZagComponent { + declare open: boolean + declare value: datepicker.DateValue[] + declare valueAsString: string[] + declare _gridCleanups: Array<() => void> + + createMachine(_props: DatePickerProps): datepicker.Machine { + return datepicker.machine + } + + getMachineProps(props: DatePickerProps): datepicker.Props { + const { class: _class, label: _label, ...machineProps } = props + + return { + ...machineProps, + id: this.id, + closeOnSelect: props.closeOnSelect ?? true, + onValueChange: (details: datepicker.ValueChangeDetails) => { + this.value = details.value + this.valueAsString = details.valueAsString + props.onValueChange?.(details) + }, + onOpenChange: (details: datepicker.OpenChangeDetails) => { + this.open = details.open + props.onOpenChange?.(details) + }, + onViewChange: props.onViewChange, + onFocusChange: props.onFocusChange, + onVisibleRangeChange: props.onVisibleRangeChange, + } + } + + connectApi(service: datepicker.Service): datepicker.Api { + return datepicker.connect(service, normalizeProps) + } + + getSpreadMap() { + return { + '[data-part="root"]': 'getRootProps', + '[data-part="label"]': 'getLabelProps', + '[data-part="control"]': 'getControlProps', + '[data-part="input"]': 'getInputProps', + '[data-part="trigger"]': 'getTriggerProps', + + '[data-part="positioner"]': 'getPositionerProps', + '[data-part="content"]': 'getContentProps', + } + } + + syncState(api: datepicker.Api): void { + this.open = api.open + this.value = api.value + this.valueAsString = api.valueAsString + } + + _applyAllSpreads() { + if (this._api) { + this.syncState(this._api) + this._renderGrid(this._api) + } + super._applyAllSpreads() + } + + _renderViewControl(api: datepicker.Api, view: datepicker.DateView, label: string): HTMLElement { + const vc = document.createElement('div') + vc.className = 'date-picker-view-control flex items-center justify-between mb-2' + vc.setAttribute('data-part', 'view-control') + this._gridCleanups.push(spreadProps(vc, api.getViewControlProps({ view }))) + + const prev = document.createElement('button') + prev.className = 'date-picker-prev-trigger inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent' + prev.innerHTML = CHEVRON_LEFT + this._gridCleanups.push(spreadProps(prev, api.getPrevTriggerProps({ view }))) + + const labelEl = document.createElement('button') + labelEl.className = 'date-picker-view-trigger text-sm font-medium hover:bg-accent rounded-md px-2 py-1' + labelEl.textContent = label + this._gridCleanups.push(spreadProps(labelEl, api.getViewTriggerProps({ view }))) + + const next = document.createElement('button') + next.className = 'date-picker-next-trigger inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent' + next.innerHTML = CHEVRON_RIGHT + this._gridCleanups.push(spreadProps(next, api.getNextTriggerProps({ view }))) + + vc.appendChild(prev) + vc.appendChild(labelEl) + vc.appendChild(next) + return vc + } + + _renderDayView(api: datepicker.Api): HTMLElement { + const wrap = document.createElement('div') + + wrap.appendChild(this._renderViewControl(api, 'day', api.visibleRangeText?.start ?? '')) + + const table = document.createElement('table') + table.className = 'date-picker-table w-full border-collapse' + this._gridCleanups.push(spreadProps(table, api.getTableProps({ view: 'day' }))) + + const thead = document.createElement('thead') + this._gridCleanups.push(spreadProps(thead, api.getTableHeaderProps({ view: 'day' }))) + const headerRow = document.createElement('tr') + this._gridCleanups.push(spreadProps(headerRow, api.getTableRowProps({ view: 'day' }))) + for (const day of api.weekDays) { + const th = document.createElement('th') + th.scope = 'col' + th.className = 'date-picker-weekday text-muted-foreground text-xs font-normal h-8 w-8 text-center' + th.setAttribute('aria-label', day.long) + th.textContent = day.narrow + headerRow.appendChild(th) + } + thead.appendChild(headerRow) + table.appendChild(thead) + + const tbody = document.createElement('tbody') + this._gridCleanups.push(spreadProps(tbody, api.getTableBodyProps({ view: 'day' }))) + for (const week of api.weeks) { + const tr = document.createElement('tr') + this._gridCleanups.push(spreadProps(tr, api.getTableRowProps({ view: 'day' }))) + for (const day of week) { + const td = document.createElement('td') + td.className = 'date-picker-day-cell text-center p-0' + this._gridCleanups.push(spreadProps(td, api.getDayTableCellProps({ value: day }))) + + const div = document.createElement('div') + div.className = 'date-picker-day-cell-trigger inline-flex h-8 w-8 items-center justify-center rounded-md text-sm cursor-pointer hover:bg-accent data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[today]:border data-[today]:border-input data-[outside-range]:text-muted-foreground data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed' + div.textContent = String(day.day) + this._gridCleanups.push(spreadProps(div, api.getDayTableCellTriggerProps({ value: day }))) + + td.appendChild(div) + tr.appendChild(td) + } + tbody.appendChild(tr) + } + table.appendChild(tbody) + wrap.appendChild(table) + return wrap + } + + _renderGridView(api: datepicker.Api, view: 'month' | 'year'): HTMLElement { + const isMonth = view === 'month' + const wrap = document.createElement('div') + let viewLabel: string + if (isMonth) { + viewLabel = String(api.visibleRange?.start?.year ?? '') + } else { + const decade = api.getDecade() + viewLabel = `${decade.start} – ${decade.end}` + } + + wrap.appendChild(this._renderViewControl(api, view, viewLabel)) + + const table = document.createElement('table') + table.className = 'date-picker-table w-full border-collapse' + this._gridCleanups.push(spreadProps(table, api.getTableProps({ view, columns: 4 }))) + + const tbody = document.createElement('tbody') + this._gridCleanups.push(spreadProps(tbody, api.getTableBodyProps({ view }))) + const grid = isMonth + ? api.getMonthsGrid({ columns: 4, format: 'short' }) + : api.getYearsGrid({ columns: 4 }) + for (const row of grid) { + const tr = document.createElement('tr') + this._gridCleanups.push(spreadProps(tr, api.getTableRowProps({ view }))) + for (const cell of row) { + const td = document.createElement('td') + td.className = `date-picker-${view}-cell text-center p-0` + const cellProps = isMonth + ? api.getMonthTableCellProps({ ...cell, columns: 4 }) + : api.getYearTableCellProps({ ...cell, columns: 4 }) + this._gridCleanups.push(spreadProps(td, cellProps)) + + const div = document.createElement('div') + div.className = `date-picker-${view}-cell-trigger inline-flex items-center justify-center rounded-md text-sm py-1.5 px-2 w-full cursor-pointer hover:bg-accent data-[selected]:bg-primary data-[selected]:text-primary-foreground` + div.textContent = cell.label + const triggerProps = isMonth + ? api.getMonthTableCellTriggerProps({ ...cell, columns: 4 }) + : api.getYearTableCellTriggerProps({ ...cell, columns: 4 }) + this._gridCleanups.push(spreadProps(div, triggerProps)) + + td.appendChild(div) + tr.appendChild(td) + } + tbody.appendChild(tr) + } + table.appendChild(tbody) + wrap.appendChild(table) + return wrap + } + + _renderGrid(api: datepicker.Api): void { + const content = this.$$('[data-part="content"]')[0] as HTMLElement | undefined + if (!content) return + + if (!this._gridCleanups) this._gridCleanups = [] + for (const cleanup of this._gridCleanups) cleanup() + this._gridCleanups = [] + + content.innerHTML = '' + + if (api.view === 'day') { + content.appendChild(this._renderDayView(api)) + } else if (api.view === 'month') { + content.appendChild(this._renderGridView(api, 'month')) + } else { + content.appendChild(this._renderGridView(api, 'year')) + } + } + + dispose() { + if (this._gridCleanups) { + for (const cleanup of this._gridCleanups) cleanup() + this._gridCleanups = [] + } + super.dispose() + } + + template(props: DatePickerProps) { + return ( +
+ {props.label && ( + + )} +
+ + +
+
+
+
+
+ ) + } +} diff --git a/packages/gea-ui/src/index.ts b/packages/gea-ui/src/index.ts index c621550..44284d9 100644 --- a/packages/gea-ui/src/index.ts +++ b/packages/gea-ui/src/index.ts @@ -10,6 +10,7 @@ export { default as Checkbox } from './components/checkbox' export { default as Clipboard } from './components/clipboard' export { default as Collapsible } from './components/collapsible' export { default as Combobox } from './components/combobox' +export { default as DatePicker } from './components/date-picker' export { default as Dialog } from './components/dialog' export type { DialogProps } from './components/dialog' export { default as FileUpload } from './components/file-upload' diff --git a/packages/gea-ui/tests/zag-bindings.test.ts b/packages/gea-ui/tests/zag-bindings.test.ts index e6cc194..df9efb8 100644 --- a/packages/gea-ui/tests/zag-bindings.test.ts +++ b/packages/gea-ui/tests/zag-bindings.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { createRequire } from 'node:module' import test from 'node:test' import { GEA_DOM_COMPONENT, GEA_ON_PROP_CHANGE } from '@geajs/core' @@ -12,6 +13,8 @@ import { readGeaUiSource, } from '../../vite-plugin-gea/tests/helpers/compile' +const requireModule = createRequire(import.meta.url) + function installDom() { const dom = new JSDOMWithVisuals('') @@ -541,6 +544,185 @@ test('Dialog: spread queries preserve nested child Zag subtrees with overlapping assert.ok(comboRoot.isConnected, 'child subtree remains connected after dialog spread updates') assert.equal(comboTrigger.getAttribute('aria-haspopup'), 'listbox', 'dialog updates do not clobber child trigger') + view.dispose() + } finally { + restoreDom() + } +}) + +// ── DatePicker ──────────────────────────────────────────────────────────── + +test('DatePicker: value changes sync to parent and rendered calendar views update', async () => { + const restoreDom = installDom() + try { + const { Component, ZagComponent } = await loadHarness() + const datepicker = requireModule('@zag-js/date-picker') + const { spreadProps } = await import('@zag-js/vanilla') + const { CalendarDate } = requireModule('@internationalized/date') + + const DatePicker = await loadRealComponent('date-picker.tsx', 'DatePicker', ZagComponent, { + datepicker, + spreadProps, + }) + + const Parent = await compileJsxComponent( + ` + import { Component } from '@geajs/core' + import DatePicker from './DatePicker' + export default class Parent extends Component { + dateVal = '' + template() { + return ( +
+ { this.dateVal = d.valueAsString[0] || '' }} + /> + {this.dateVal || '(none)'} +
+ ) + } + } + `, + '/virtual/DatePickerParent.jsx', + 'Parent', + { Component, DatePicker }, + ) + + const root = document.createElement('div') + document.body.appendChild(root) + const view = new Parent({ focusedDate: new CalendarDate(2025, 6, 1) }) + view.render(root) + await flushMicrotasks() + + assert.equal(view.el!.querySelector('.display')?.textContent, '(none)', 'initial display is empty') + + const child = componentFromRoot(view.el!.querySelector('[data-part="root"]')) + assert.ok(child._api, 'Zag API is connected') + + let content = view.el!.querySelector('[data-part="content"]') as HTMLElement + assert.ok(content, 'content element found') + assert.equal(content.querySelectorAll('[data-part="table-cell-trigger"]').length, 42, 'day view renders a day grid') + + const targetDate = new CalendarDate(2025, 6, 15) + child._api.setValue([targetDate]) + await flushMicrotasks() + + assert.equal(child.value[0]?.toString(), targetDate.toString(), 'child value updates') + assert.equal((view as any).dateVal, child.valueAsString[0], 'parent callback receives selected date') + assert.equal(view.el!.querySelector('.display')?.textContent, child.valueAsString[0], 'parent DOM updates') + + child._api.setView('month') + await flushMicrotasks() + + assert.equal(child._api.view, 'month', 'view is month') + content = view.el!.querySelector('[data-part="content"]') as HTMLElement + assert.equal(content.querySelectorAll('[data-part="table-cell-trigger"]').length, 12, 'month view renders months') + + child._api.setView('year') + await flushMicrotasks() + + assert.equal(child._api.view, 'year', 'view is year') + content = view.el!.querySelector('[data-part="content"]') as HTMLElement + assert.equal(content.querySelectorAll('[data-part="table-cell-trigger"]').length, 10, 'year view renders years') + + view.dispose() + await flushMicrotasks() + } finally { + restoreDom() + } +}) + +test('DatePicker: day trigger state tracks selection and focus', async () => { + const restoreDom = installDom() + try { + const { Component, ZagComponent } = await loadHarness() + const datepicker = requireModule('@zag-js/date-picker') + const { spreadProps } = await import('@zag-js/vanilla') + const { CalendarDate } = requireModule('@internationalized/date') + + const DatePicker = await loadRealComponent('date-picker.tsx', 'DatePicker', ZagComponent, { + datepicker, + spreadProps, + }) + + const Parent = await compileJsxComponent( + ` + import { Component } from '@geajs/core' + import DatePicker from './DatePicker' + export default class Parent extends Component { + template() { + return ( +
+ +
+ ) + } + } + `, + '/virtual/DatePickerStateParent.jsx', + 'Parent', + { Component, DatePicker }, + ) + + const root = document.createElement('div') + document.body.appendChild(root) + const initialFocus = new CalendarDate(2025, 6, 1) + const view = new Parent({ focusedDate: initialFocus }) + view.render(root) + await flushMicrotasks() + + const child = componentFromRoot(view.el!.querySelector('[data-part="root"]')) + assert.ok(child._api, 'Zag API is connected') + + child._api.setFocusedValue(initialFocus) + await flushMicrotasks() + + const content = view.el!.querySelector('[data-part="content"]') as HTMLElement + assert.ok(content, 'content found') + + const dayTriggers = Array.from(content.querySelectorAll('[data-part="table-cell-trigger"]')) as HTMLElement[] + const initialTrigger = dayTriggers.find( + (trigger) => trigger.textContent === '1' && trigger.hasAttribute('data-focus'), + ) + assert.ok(initialTrigger, 'initial focused day trigger found') + assert.equal(initialTrigger.getAttribute('data-focus'), '', 'initial day starts focused') + assert.equal(initialTrigger.getAttribute('tabindex'), '0', 'initial day starts tabbable') + + const selectedDate = new CalendarDate(2025, 6, 15) + child._api.setValue([selectedDate]) + await flushMicrotasks() + + const selectedTriggers = Array.from(content.querySelectorAll('[data-part="table-cell-trigger"]')) as HTMLElement[] + const selectedTrigger = selectedTriggers.find((trigger) => trigger.textContent === '15') + const unselectedTrigger = selectedTriggers.find((trigger) => trigger.textContent === '1') + assert.ok(selectedTrigger, 'selected day trigger found') + assert.ok(unselectedTrigger, 'unselected day trigger found') + assert.equal(selectedTrigger.getAttribute('data-selected'), '', 'selected day is marked selected') + assert.equal(unselectedTrigger.hasAttribute('data-selected'), false, 'unselected day is not marked selected') + + const nextFocus = new CalendarDate(2025, 6, 20) + child._api.setFocusedValue(nextFocus) + await flushMicrotasks() + + const focusedTriggers = Array.from(content.querySelectorAll('[data-part="table-cell-trigger"]')) as HTMLElement[] + const focusedTrigger = focusedTriggers.find((trigger) => trigger.textContent === '20') + const previousFocusedTrigger = focusedTriggers.find((trigger) => trigger.textContent === '1') + assert.ok(focusedTrigger, 'new focused day trigger found') + assert.ok(previousFocusedTrigger, 'previous focused day trigger found') + assert.equal(focusedTrigger.getAttribute('data-focus'), '', 'new focused day is marked focused') + assert.equal(focusedTrigger.getAttribute('tabindex'), '0', 'new focused day is tabbable') + assert.equal(previousFocusedTrigger.hasAttribute('data-focus'), false, 'previous focused day loses focus state') + assert.equal( + previousFocusedTrigger.getAttribute('tabindex'), + '-1', + 'previous focused day is removed from tab order', + ) + view.dispose() await flushMicrotasks() } finally {