diff --git a/.changeset/olive-pandas-cheer.md b/.changeset/olive-pandas-cheer.md new file mode 100644 index 00000000..cdf3d11b --- /dev/null +++ b/.changeset/olive-pandas-cheer.md @@ -0,0 +1,14 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `DateRangePicker` and `RangeCalendar` components with a `{ start, end }` range value (exported as `DateRange`). Selection follows the react-aria model: the first calendar pick anchors the range and keeps the popover open, the highlight live-extends to the hovered/focused day, and the second pick completes it — picking backwards swaps the endpoints, while a range typed in reverse is flagged invalid instead. + +```tsx +import { DateRangePicker, type DateRange } from "@tailor-platform/app-shell"; + +const [range, setRange] = useState(null); +; +``` + +DataTable date filters now use a single `DateRangePicker` for the `between` operator (one shared range calendar instead of two separate date pickers), and a reversed `between` range can no longer be applied. diff --git a/docs/components/date-picker.md b/docs/components/date-picker.md index a91d84f0..b718a9db 100644 --- a/docs/components/date-picker.md +++ b/docs/components/date-picker.md @@ -5,7 +5,7 @@ description: Accessible date input components (@internationalized/date + Base UI # DatePicker -Three related components for date input — a segmented field, a field with a calendar popover, and a standalone calendar grid. Built on [`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/) (the value layer) and Base UI (`Popover`), with the segmented input and calendar grid implemented to the [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/) date-picker/grid patterns. They integrate automatically with AppShell's locale and timezone context. +Five related components for date input — a segmented field, a field with a calendar popover, a standalone calendar grid, and their range counterparts (`DateRangePicker`, `RangeCalendar`). Built on [`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/) (the value layer) and Base UI (`Popover`), with the segmented input and calendar grid implemented to the [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/) date-picker/grid patterns. They integrate automatically with AppShell's locale and timezone context. > **Implementation note.** This is the `@internationalized/date` + Base UI variant. The public API and accessibility contract are identical to the react-aria variant; only the internals differ. @@ -15,12 +15,15 @@ Three related components for date input — a segmented field, a field with a ca import { DateField, DatePicker, + DateRangePicker, Calendar, + RangeCalendar, // Date value helpers (re-exported from @internationalized/date) parseDate, getLocalTimeZone, type CalendarDate, type DateValue, + type DateRange, } from "@tailor-platform/app-shell"; ``` @@ -86,6 +89,27 @@ A standalone calendar grid for custom date-selection UIs (e.g. reporting filters console.log(date)} /> ``` +## DateRangePicker + +Start and end segmented inputs sharing one calendar popover, with a `{ start, end }` value — a **separate component** from `DatePicker`, not a mode. Selection follows the react-aria DateRangePicker model: the first calendar pick anchors the range and keeps the popover open, the highlight follows the pointer (or arrow keys), and the second pick completes the range and closes the popover. Picking a day _before_ the anchor swaps the endpoints, so a picked range is always ordered; a range **typed** in reverse is flagged invalid (with a built-in message) rather than silently swapped. + +```tsx +const [range, setRange] = useState(null); +; +``` + +`onChange` fires with a complete `{ start, end }` range, or `null` when the range is cleared or an end is removed — never with a half-typed intermediate. For form submission, `startName` / `endName` emit two hidden inputs. + +The `DataTable` date filters use this component for the `between` operator. + +## RangeCalendar + +The standalone inline range calendar (the grid inside `DateRangePicker`), for custom layouts. + +```tsx + console.log(range)} /> +``` + ## Localization Locale and timezone come from AppShell automatically. Override per field with `locale` / `timeZone`: @@ -100,7 +124,8 @@ Segment order, first-day-of-week, and month/weekday names all follow the resolve - **Segments:** `↑`/`↓` increment/decrement, digits type-to-fill (auto-advance), `←`/`→` move between segments, `Backspace` clears, `/` commits the current segment and advances (so a single `1` means January, not the start of `1x`). - **Whole-date shortcuts** (QuickBooks Online-style, case-insensitive): `t` today · `m`/`h` start/end of the entered month (current month when empty) · `y`/`r` start/end of the year · `w`/`k` start/end of the week (locale-aware) · `-` previous day · `=`/`+` next day (both step across month **and** year boundaries; `+` needs no Shift). A 1–2 digit year expands to the 2000s on blur (`26` → `2026`). These work **from a focused date segment** (they set the field value, clamped to `minValue`/`maxValue`) **and while the calendar popover is open** (they move the highlighted day like the arrow keys — press `Enter` to confirm; `minValue`/`maxValue` clamp and unavailable days can't be confirmed). -- **Calendar grid:** arrows move by day/week, `Home`/`End` to week start/end, `PageUp`/`PageDown` by month, `Shift`+`PageUp`/`PageDown` by year, `Enter`/`Space` selects. `Alt`+`↓` opens the calendar from the field (`DatePicker`). +- **Calendar grid:** arrows move by day/week, `Home`/`End` to week start/end, `PageUp`/`PageDown` by month, `Shift`+`PageUp`/`PageDown` by year, `Enter`/`Space` selects. `Alt`+`↓` opens the calendar from the field (`DatePicker` / `DateRangePicker`). +- **Range selection** (`RangeCalendar` / `DateRangePicker` popover): `Enter`/`Space` anchors the range and moves focus one day forward; plain arrows then extend the highlight (no `Shift` chord needed); a second `Enter`/`Space` commits. `Escape` cancels an in-progress selection (inside the popover, the same keypress also dismisses it — react-aria behaviour). In the range field, `←`/`→` run through the start segments into the end segments as one sequence. ## Accessibility @@ -164,6 +189,23 @@ The standalone calendar grid. It has no segmented input, so its surface is liste | `aria-label` / `aria-labelledby` | `string` | Accessible name for the grid | | `className` | `string` | Root element class | +### DateRangePickerProps + +The `DateFieldProps` surface (minus `name`), plus the `DatePickerProps` calendar props, with the range-specific differences: + +| Prop | Type | Description | +| ------------------------ | -------------------------------- | ------------------------------------------------------------------------------- | +| `value` / `defaultValue` | `DateRange \| null` | Controlled / uncontrolled `{ start, end }` range | +| `onChange` | `(v: DateRange \| null) => void` | Fires with a complete range, or `null` when cleared/incomplete | +| `startName` / `endName` | `string` | Emit hidden ``s with the ISO start/end values for form submission | +| `isDateUnavailable` | `(date: DateValue) => boolean` | Also constrains selection: an in-progress range can't cross an unavailable date | + +A typed range with `end` before `start` sets the invalid state and shows a built-in localized error (unless `errorMessage` overrides it). + +### RangeCalendarProps + +Same surface as `CalendarProps`, with `value` / `defaultValue`: `DateRange | null` and `onChange`: `(v: DateRange) => void` (fired once per selection, when the second date completes the range). + ### Proposed / not yet implemented Accepted by the prop types (for parity with the react-aria variant) but **not acted on** in this variant yet: diff --git a/examples/vite-app/src/mock-orders.ts b/examples/vite-app/src/mock-orders.ts new file mode 100644 index 00000000..11e7957b --- /dev/null +++ b/examples/vite-app/src/mock-orders.ts @@ -0,0 +1,394 @@ +export type OrderStatus = "Processing" | "Shipped" | "Delivered" | "Cancelled"; +export type OrderChannel = "Web" | "Retail" | "Phone"; + +export type Order = { + id: string; + customer: string; + status: OrderStatus; + channel: OrderChannel; + items: number; + total: number; + /** Calendar date (`YYYY-MM-DD`) — rendered and filtered as a `date` column. */ + placedOn: string; +}; + +export const allOrders: Order[] = [ + { + id: "ord-1001", + customer: "Acme Corporation", + status: "Delivered", + channel: "Web", + items: 12, + total: 8900.0, + placedOn: "2026-01-04", + }, + { + id: "ord-1002", + customer: "Globex Industries", + status: "Shipped", + channel: "Phone", + items: 3, + total: 1200.5, + placedOn: "2026-01-09", + }, + { + id: "ord-1003", + customer: "Initech LLC", + status: "Processing", + channel: "Web", + items: 7, + total: 3450.0, + placedOn: "2026-01-15", + }, + { + id: "ord-1004", + customer: "Umbrella Co", + status: "Cancelled", + channel: "Retail", + items: 1, + total: 249.99, + placedOn: "2026-01-19", + }, + { + id: "ord-1005", + customer: "Stark Enterprises", + status: "Delivered", + channel: "Web", + items: 24, + total: 15200.0, + placedOn: "2026-01-23", + }, + { + id: "ord-1006", + customer: "Wayne Holdings", + status: "Shipped", + channel: "Phone", + items: 5, + total: 2199.0, + placedOn: "2026-02-02", + }, + { + id: "ord-1007", + customer: "Wonka Industries", + status: "Processing", + channel: "Retail", + items: 9, + total: 4780.25, + placedOn: "2026-02-06", + }, + { + id: "ord-1008", + customer: "Cyberdyne Systems", + status: "Delivered", + channel: "Web", + items: 2, + total: 649.0, + placedOn: "2026-02-11", + }, + { + id: "ord-1009", + customer: "Soylent Corp", + status: "Shipped", + channel: "Web", + items: 18, + total: 9975.5, + placedOn: "2026-02-14", + }, + { + id: "ord-1010", + customer: "Hooli", + status: "Processing", + channel: "Phone", + items: 4, + total: 1580.0, + placedOn: "2026-02-20", + }, + { + id: "ord-1011", + customer: "Pied Piper", + status: "Delivered", + channel: "Web", + items: 1, + total: 129.99, + placedOn: "2026-02-24", + }, + { + id: "ord-1012", + customer: "Vehement Capital", + status: "Cancelled", + channel: "Retail", + items: 6, + total: 2340.0, + placedOn: "2026-02-27", + }, + { + id: "ord-1013", + customer: "Massive Dynamic", + status: "Shipped", + channel: "Web", + items: 33, + total: 21450.0, + placedOn: "2026-03-03", + }, + { + id: "ord-1014", + customer: "Gringotts Bank", + status: "Processing", + channel: "Phone", + items: 8, + total: 5120.75, + placedOn: "2026-03-07", + }, + { + id: "ord-1015", + customer: "Oscorp", + status: "Delivered", + channel: "Web", + items: 14, + total: 7300.0, + placedOn: "2026-03-10", + }, + { + id: "ord-1016", + customer: "Tyrell Corporation", + status: "Shipped", + channel: "Retail", + items: 2, + total: 899.0, + placedOn: "2026-03-14", + }, + { + id: "ord-1017", + customer: "Nakatomi Trading", + status: "Processing", + channel: "Web", + items: 11, + total: 6250.0, + placedOn: "2026-03-18", + }, + { + id: "ord-1018", + customer: "Wernham Hogg", + status: "Delivered", + channel: "Phone", + items: 5, + total: 1875.5, + placedOn: "2026-03-21", + }, + { + id: "ord-1019", + customer: "Bluth Company", + status: "Cancelled", + channel: "Retail", + items: 1, + total: 349.0, + placedOn: "2026-03-25", + }, + { + id: "ord-1020", + customer: "Dunder Mifflin", + status: "Shipped", + channel: "Web", + items: 40, + total: 3200.0, + placedOn: "2026-03-28", + }, + { + id: "ord-1021", + customer: "Prestige Worldwide", + status: "Processing", + channel: "Web", + items: 16, + total: 8640.0, + placedOn: "2026-04-01", + }, + { + id: "ord-1022", + customer: "Sterling Cooper", + status: "Delivered", + channel: "Phone", + items: 3, + total: 1120.0, + placedOn: "2026-04-05", + }, +]; + +// --------------------------------------------------------------------------- +// Mock query hook (simulates a real remote `useQuery` call, latency included) +// --------------------------------------------------------------------------- + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { CollectionVariables } from "@tailor-platform/app-shell"; + +const MOCK_LATENCY_MS = 800; + +function compareValues(left: unknown, right: unknown): number | null { + if (typeof left === "number" && typeof right === "number") { + return left < right ? -1 : left > right ? 1 : 0; + } + if (typeof left === "string" && typeof right === "string") { + return left < right ? -1 : left > right ? 1 : 0; + } + return null; +} + +function matchStringOperator(fieldValue: unknown, operator: string, expected: unknown): boolean { + const value = String(fieldValue ?? ""); + const needle = String(expected ?? ""); + + switch (operator) { + case "contains": + return value.includes(needle); + case "notContains": + return !value.includes(needle); + case "hasPrefix": + return value.startsWith(needle); + case "hasSuffix": + return value.endsWith(needle); + case "notHasPrefix": + return !value.startsWith(needle); + case "notHasSuffix": + return !value.endsWith(needle); + default: + return false; + } +} + +function matchOperator(fieldValue: unknown, operator: string, expected: unknown): boolean { + switch (operator) { + case "eq": + return fieldValue === expected; + case "ne": + return fieldValue !== expected; + case "in": + return Array.isArray(expected) && expected.some((item) => item === fieldValue); + case "nin": + return Array.isArray(expected) && !expected.some((item) => item === fieldValue); + case "regex": { + const pattern = String(expected ?? ""); + const caseInsensitive = pattern.startsWith("(?i)"); + const regexBody = caseInsensitive ? pattern.slice(4) : pattern; + const re = new RegExp(regexBody, caseInsensitive ? "i" : ""); + return re.test(String(fieldValue ?? "")); + } + case "contains": + case "notContains": + case "hasPrefix": + case "hasSuffix": + case "notHasPrefix": + case "notHasSuffix": + return matchStringOperator(fieldValue, operator, expected); + case "gt": { + const compared = compareValues(fieldValue, expected); + return compared != null && compared > 0; + } + case "gte": { + const compared = compareValues(fieldValue, expected); + return compared != null && compared >= 0; + } + case "lt": { + const compared = compareValues(fieldValue, expected); + return compared != null && compared < 0; + } + case "lte": { + const compared = compareValues(fieldValue, expected); + return compared != null && compared <= 0; + } + case "between": { + if (!expected || typeof expected !== "object") return false; + const range = expected as { min?: unknown; max?: unknown }; + const minCompared = + range.min === undefined || range.min === null ? 0 : compareValues(fieldValue, range.min); + const maxCompared = + range.max === undefined || range.max === null ? 0 : compareValues(fieldValue, range.max); + const passMin = minCompared != null && minCompared >= 0; + const passMax = maxCompared != null && maxCompared <= 0; + return passMin && passMax; + } + default: + // Ignore unsupported operators in mock mode. + return true; + } +} + +function applyQueryFilters(orders: Order[], variables: CollectionVariables): Order[] { + if (!variables.query) return [...orders]; + + return orders.filter((order) => { + return Object.entries(variables.query ?? {}).every(([field, operators]) => { + const fieldValue = order[field as keyof Order]; + return Object.entries(operators ?? {}).every(([operator, expected]) => { + return matchOperator(fieldValue, operator, expected); + }); + }); + }); +} + +export function useOrdersQuery(variables: CollectionVariables) { + const result = useMemo(() => { + // Filter + const rows = applyQueryFilters(allOrders, variables); + + // Sort + if (variables.order && variables.order.length > 0) { + rows.sort((a, b) => { + for (const { field, direction } of variables.order!) { + const aVal = a[field as keyof Order]; + const bVal = b[field as keyof Order]; + if (aVal == null || bVal == null) continue; + const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + if (cmp !== 0) return direction === "Desc" ? -cmp : cmp; + } + return 0; + }); + } + + // Paginate + const pageSize = variables.pagination.first ?? variables.pagination.last ?? rows.length; + let page = 1; + if (variables.pagination.after) { + page = Number(variables.pagination.after); + } else if (variables.pagination.before) { + page = Number(variables.pagination.before); + } else if (variables.pagination.last && !variables.pagination.before) { + // last without before = last page + page = Math.max(1, Math.ceil(rows.length / pageSize)); + } + const start = (page - 1) * pageSize; + const end = Math.min(start + pageSize, rows.length); + const pageRows = rows.slice(start, end); + const hasNextPage = end < rows.length; + const hasPreviousPage = page > 1; + + return { + edges: pageRows.map((node) => ({ node })), + pageInfo: { + hasNextPage, + endCursor: hasNextPage ? String(page + 1) : null, + hasPreviousPage, + startCursor: hasPreviousPage ? String(page - 1) : null, + }, + total: rows.length, + }; + }, [variables]); + + // Re-fetch on every variables change, exposing a `loading` window so the + // DataTable's loading state is exercised (skeleton rows, disabled controls). + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const timerRef = useRef | null>(null); + + useEffect(() => { + setLoading(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setData(result); + setLoading(false); + }, MOCK_LATENCY_MS); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [result]); + + return { data, loading }; +} diff --git a/examples/vite-app/src/pages/dashboard/orders/page.tsx b/examples/vite-app/src/pages/dashboard/orders/page.tsx index d291c049..5f58c297 100644 --- a/examples/vite-app/src/pages/dashboard/orders/page.tsx +++ b/examples/vite-app/src/pages/dashboard/orders/page.tsx @@ -1,96 +1,144 @@ import { - Link, - Button, + DataTable, Layout, - Table, - Badge, + useDataTable, + useNavigate, + useURLCollectionVariables, + createColumnHelper, type AppShellPageProps, + type RowAction, } from "@tailor-platform/app-shell"; +import { ReceiptText } from "lucide-react"; import { paths } from "../../../routes.generated"; import { labels } from "../../../i18n-labels"; -import { ReceiptText } from "lucide-react"; - -const statusVariant = (status: string) => { - switch (status) { - case "Shipped": - return "outline-info" as const; - case "Processing": - return "outline-warning" as const; - case "Delivered": - return "success" as const; - default: - return "neutral" as const; - } -}; +import { type Order, useOrdersQuery } from "../../../mock-orders"; -const OrdersPage = () => { - const orders = [ +const orderMetadata = { + name: "order", + pluralForm: "orders", + fields: [ + { name: "id", type: "uuid", required: true }, + { name: "customer", type: "string", required: true }, + { + name: "status", + type: "enum", + required: true, + enumValues: ["Processing", "Shipped", "Delivered", "Cancelled"], + }, { - id: "order-001", - name: "Office Supplies", - status: "Shipped", - amount: "$1,200.00", + name: "channel", + type: "enum", + required: true, + enumValues: ["Web", "Retail", "Phone"], + }, + { name: "items", type: "number", required: true }, + { name: "total", type: "number", required: true }, + { name: "placedOn", type: "date", required: true }, + ], +} as const; + +const { column, inferColumns } = createColumnHelper(); +const infer = inferColumns(orderMetadata); + +const columns = [ + column({ + ...infer("id"), + label: "Order", + render: (row) => {row.id}, + }), + column({ + ...infer("customer"), + label: "Customer", + render: (row) => {row.customer}, + }), + column({ + ...infer("status"), + label: "Status", + type: "badge", + typeOptions: { + badgeVariantMap: { + Processing: "outline-warning", + Shipped: "outline-info", + Delivered: "success", + Cancelled: "neutral", + }, }, + }), + column({ ...infer("channel"), label: "Channel" }), + column({ ...infer("items"), label: "Items", type: "number" }), + column({ ...infer("total"), label: "Total", type: "money" }), + // `type: "date"` → the between filter renders the DateRangePicker (a single + // shared range calendar). Try Status → in, or Placed on → between. + column({ + ...infer("placedOn"), + label: "Placed on", + type: "date", + typeOptions: { dateFormat: "long" }, + }), +]; + +const OrdersPage = () => { + const navigate = useNavigate(); + + // Filter/sort/pagination state persists to the URL and seeds the initial + // fetch synchronously — bookmarkable and back-button friendly. + const { variables, control } = useURLCollectionVariables({ + params: { pageSize: 5 }, + tableMetadata: orderMetadata, + }); + + const { data, loading } = useOrdersQuery(variables); + + const rowActions: RowAction[] = [ { - id: "order-002", - name: "Electronics", - status: "Processing", - amount: "$3,450.00", + id: "view", + label: "View details", + onClick: (row) => navigate(paths.for("/dashboard/orders/:id", { id: row.id })), }, { - id: "order-003", - name: "Furniture", - status: "Delivered", - amount: "$8,900.00", + id: "cancel", + label: "Cancel order", + variant: "destructive", + isDisabled: (row) => row.status === "Delivered" || row.status === "Cancelled", + onClick: (row) => alert(`Cancel ${row.id}`), }, ]; + const table = useDataTable({ + columns, + data: data + ? { + rows: data.edges.map((e) => e.node), + pageInfo: data.pageInfo, + total: data.total, + } + : undefined, + loading, + control, + rowActions, + onClickRow: (row) => navigate(paths.for("/dashboard/orders/:id", { id: row.id })), + }); + return ( -

- This page is at{" "} - src/pages/dashboard/orders/page.tsx +

+ DataTable backed by a mock remote query ( + useOrdersQuery, ~800 ms latency) with + filters, sorting, and pagination synced to the URL. Every fetch shows a loading state. + Filter Placed on with the between operator to use the range + calendar. Click a row to open the order.

- - - - Order ID - Name - Status - Amount - - - - - {orders.map((order) => ( - - {order.id} - {order.name} - - {order.status} - - {order.amount} - - - - - ))} - - + + + + + + + + +
); diff --git a/examples/vite-app/src/pages/date-picker/page.tsx b/examples/vite-app/src/pages/date-picker/page.tsx index b421d4e5..afd61a53 100644 --- a/examples/vite-app/src/pages/date-picker/page.tsx +++ b/examples/vite-app/src/pages/date-picker/page.tsx @@ -3,13 +3,16 @@ import { Layout, DateField, DatePicker, + DateRangePicker, Calendar, + RangeCalendar, Form, Button, useTimeZone, parseDate, type CalendarDate, type DateValue, + type DateRange, type AppShellPageProps, } from "@tailor-platform/app-shell"; import { CalendarDays } from "lucide-react"; @@ -20,6 +23,8 @@ const DatePickerPage = () => { const [pickerValue, setPickerValue] = useState(null); const [calendarValue, setCalendarValue] = useState(null); const [weekendValue, setWeekendValue] = useState(null); + const [rangeValue, setRangeValue] = useState(null); + const [inlineRange, setInlineRange] = useState(null); // Form-validation demo state. const [deliveryDate, setDeliveryDate] = useState(null); @@ -132,6 +137,38 @@ const DatePickerPage = () => { )} + {/* ── DateRangePicker ────────────────────────────────────── */} +
+

DateRangePicker

+

+ One shared calendar: the first pick anchors the range (the popover stays open and the + highlight follows the pointer/arrows), the second pick completes it. Picking backwards + swaps the endpoints; a range typed in reverse is flagged invalid instead. +

+
+ + + { + const day = d.toDate(tz.value).getDay(); + return day === 0 || day === 6; + }} + /> +
+ {rangeValue && ( +

+ Selected: {rangeValue.start.toString()} →{" "} + {rangeValue.end.toString()} +

+ )} +
+ {/* ── In a form (submit validation) ───────────────────────── */}

In a form (submit validation)

@@ -236,6 +273,21 @@ const DatePickerPage = () => { onChange={(v) => setWeekendValue(v as CalendarDate)} /> + +
+

RangeCalendar

+ + {inlineRange && ( +

+ Selected: {inlineRange.start.toString()} →{" "} + {inlineRange.end.toString()} +

+ )} +
diff --git a/packages/core/__snapshots__/src__components__calendar__calendar.test.tsx.snap b/packages/core/__snapshots__/src__components__calendar__calendar.test.tsx.snap index 99bd521b..992ad9a8 100644 --- a/packages/core/__snapshots__/src__components__calendar__calendar.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__calendar__calendar.test.tsx.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`snapshots > Calendar — pre-selected 1`] = `"
June 2025
"`; +exports[`snapshots > Calendar — pre-selected 1`] = `"
June 2025
"`; diff --git a/packages/core/__snapshots__/src__components__calendar__range-calendar.test.tsx.snap b/packages/core/__snapshots__/src__components__calendar__range-calendar.test.tsx.snap new file mode 100644 index 00000000..717ac1ba --- /dev/null +++ b/packages/core/__snapshots__/src__components__calendar__range-calendar.test.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`snapshots > RangeCalendar — pre-selected range 1`] = `"
June 2025
"`; diff --git a/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap b/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap index 856ed2c5..66ee5627 100644 --- a/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`snapshots > DateField 1`] = `"
Invoice date
mm
dd
yyyy
"`; +exports[`snapshots > DateField 1`] = `"
Invoice date
mm
dd
yyyy
"`; -exports[`snapshots > DateField — invalid with error 1`] = `"
Date
mm
dd
yyyy

Pick a date

"`; +exports[`snapshots > DateField — invalid with error 1`] = `"
Date
mm
dd
yyyy

Pick a date

"`; -exports[`snapshots > DatePicker — closed 1`] = `"
Ship date
mm
dd
yyyy
"`; +exports[`snapshots > DatePicker — closed 1`] = `"
Ship date
mm
dd
yyyy
"`; diff --git a/packages/core/__snapshots__/src__components__date-field__date-range-picker.test.tsx.snap b/packages/core/__snapshots__/src__components__date-field__date-range-picker.test.tsx.snap new file mode 100644 index 00000000..50aef5a7 --- /dev/null +++ b/packages/core/__snapshots__/src__components__date-field__date-range-picker.test.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`snapshots > DateRangePicker — closed 1`] = `"
Billing period
mm
dd
yyyy
mm
dd
yyyy
"`; diff --git a/packages/core/src/components/calendar/calendar-view.tsx b/packages/core/src/components/calendar/calendar-view.tsx index b7800c72..3f7d3df3 100644 --- a/packages/core/src/components/calendar/calendar-view.tsx +++ b/packages/core/src/components/calendar/calendar-view.tsx @@ -3,12 +3,13 @@ import { cva } from "class-variance-authority"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { useCalendarT } from "./i18n"; -import type { CalendarDay, useCalendarState } from "./use-calendar-state"; +import type { CalendarDay, CalendarViewState } from "./use-calendar-base-state"; /** * Calendar-grid presentation — the APG date-grid markup, driven by our own - * `useCalendarState` engine. Shared by the standalone `Calendar` and the - * `DatePicker` popover. Not exported from the package. + * calendar state engines (`useCalendarState` / `useRangeCalendarState`). + * Shared by the standalone `Calendar`/`RangeCalendar` and the + * `DatePicker`/`DateRangePicker` popovers. Not exported from the package. * * Styling mirrors the rest of the library (`astw:` tokens, dark mode). */ @@ -36,8 +37,10 @@ const calendarCellVariants = cva( "astw:data-[outside-month]:pointer-events-none astw:data-[outside-month]:opacity-40", "astw:data-[unavailable]:pointer-events-none astw:data-[unavailable]:text-muted-foreground astw:data-[unavailable]:line-through", "astw:data-[disabled]:pointer-events-none astw:data-[disabled]:opacity-50", - // Range states — wired now so a future DateRangePicker is purely additive + // Range states: endpoints keep the selected pill; the days in between + // read from the band painted on the underneath. "astw:data-[selection-start]:rounded-l-md astw:data-[selection-end]:rounded-r-md", + "astw:data-[in-range]:text-accent-foreground astw:data-[in-range]:data-[selected]:text-primary-foreground", "astw:data-[today]:font-semibold astw:data-[today]:underline astw:data-[today]:underline-offset-2", ], }, @@ -46,6 +49,14 @@ const calendarCellVariants = cva( }, ); +// The continuous range band, painted on the so it runs edge-to-edge +// between the rounded endpoint pills (the buttons stay `size-9 rounded-md`). +const cellTdClasses = cn( + "astw:p-0", + "astw:data-[in-range]:bg-accent", + "astw:data-[selection-start]:rounded-l-md astw:data-[selection-end]:rounded-r-md", +); + // Keyboard focus ring — the same `ring` treatment used by Button / inputs. const navButtonClasses = cn( "astw:flex astw:size-7 astw:items-center astw:justify-center astw:rounded-sm astw:outline-none", @@ -55,10 +66,8 @@ const navButtonClasses = cn( "astw:disabled:pointer-events-none astw:disabled:opacity-50", ); -type CalendarState = ReturnType; - interface CalendarViewProps { - state: CalendarState; + state: CalendarViewState; ariaLabel?: string; ariaLabelledBy?: string; className?: string; @@ -80,6 +89,7 @@ export function CalendarView({ }: CalendarViewProps) { const t = useCalendarT(); const headingId = React.useId(); + const rangePromptId = React.useId(); const cellRefs = React.useRef>(new Map()); const prevBtnRef = React.useRef(null); const nextBtnRef = React.useRef(null); @@ -109,7 +119,14 @@ export function CalendarView({ // Contain Tab within the prev → next → grid loop while in the popover. const handleContainerKeyDown = (e: React.KeyboardEvent) => { if (!inPopover || e.key !== "Tab") return; - const gridCell = cellRefs.current.get(state.focusedDate.toString()) ?? null; + // The grid stop is whichever cell actually holds DOM focus — it can lag + // the roving focusedDate (e.g. a click that didn't focus the button, as + // Safari's do) and Tab must still cycle rather than escape the dialog. + const active = document.activeElement as HTMLButtonElement | null; + const activeIsCell = active != null && [...cellRefs.current.values()].includes(active); + const gridCell = activeIsCell + ? active + : (cellRefs.current.get(state.focusedDate.toString()) ?? null); const stops = [ state.prevDisabled ? null : prevBtnRef.current, state.nextDisabled ? null : nextBtnRef.current, @@ -163,12 +180,20 @@ export function CalendarView({ + {/* Announced from the focused cell in range mode: tells the user whether + the next confirm starts or finishes the range selection. */} + {state.isRange && ( + + )} {/* APG calendar-grid pattern: role="grid" upgrades the table's cell/row semantics so arrow-key navigation is announced correctly. */} @@ -193,8 +218,10 @@ export function CalendarView({ key={day.date.toString()} day={day} label={state.cellLabel(day.date)} + describedById={state.isRange && day.isFocused ? rangePromptId : undefined} onSelect={() => state.selectDate(day.date)} onKeyDown={(e) => state.onCellKeyDown(e, day.date)} + onHover={state.onCellHover} registerRef={(el) => cellRefs.current.set(day.date.toString(), el)} onFocus={() => { state.isFocusedRef.current = true; @@ -212,8 +239,10 @@ export function CalendarView({ interface CalendarCellProps { day: CalendarDay; label: string; + describedById?: string; onSelect: () => void; onKeyDown: (e: React.KeyboardEvent) => void; + onHover?: (date: CalendarDay["date"]) => void; registerRef: (el: HTMLButtonElement | null) => void; onFocus: () => void; } @@ -221,8 +250,10 @@ interface CalendarCellProps { function CalendarCell({ day, label, + describedById, onSelect, onKeyDown, + onHover, registerRef, onFocus, }: CalendarCellProps) { @@ -235,20 +266,31 @@ function CalendarCell({ const focusable = !day.isOutsideMonth; // `
` inside `role="grid"` is implicitly a gridcell — no explicit role needed. return ( - +