@twoux/ui 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +61 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/NOTICE.md +64 -0
- package/README.md +211 -2
- package/SHADCN-DIFF.md +228 -0
- package/agent/AGENTS.md +358 -0
- package/agent/docs-md/accessibility.md +49 -0
- package/agent/docs-md/action-catalogue.md +81 -0
- package/agent/docs-md/actions.md +74 -0
- package/agent/docs-md/agent-activity-log.md +70 -0
- package/agent/docs-md/agent-permissions.md +82 -0
- package/agent/docs-md/agent-run.md +90 -0
- package/agent/docs-md/agents-on-behalf.md +74 -0
- package/agent/docs-md/ai-column.md +85 -0
- package/agent/docs-md/ai-origin-label.md +70 -0
- package/agent/docs-md/announcing-features.md +73 -0
- package/agent/docs-md/answer-actions.md +81 -0
- package/agent/docs-md/app-shell.md +114 -0
- package/agent/docs-md/artifact-card.md +89 -0
- package/agent/docs-md/ask-on-screen.md +72 -0
- package/agent/docs-md/assistant-page.md +100 -0
- package/agent/docs-md/assistant-panel.md +142 -0
- package/agent/docs-md/avatar.md +82 -0
- package/agent/docs-md/banner.md +106 -0
- package/agent/docs-md/board.md +163 -0
- package/agent/docs-md/breadcrumb.md +89 -0
- package/agent/docs-md/breakdown-table.md +97 -0
- package/agent/docs-md/bulk-action-bar.md +112 -0
- package/agent/docs-md/button.md +293 -0
- package/agent/docs-md/card.md +115 -0
- package/agent/docs-md/change-list.md +99 -0
- package/agent/docs-md/change-request.md +166 -0
- package/agent/docs-md/chart-area.md +87 -0
- package/agent/docs-md/chart-bar.md +100 -0
- package/agent/docs-md/chart-box-plot.md +88 -0
- package/agent/docs-md/chart-bullet.md +89 -0
- package/agent/docs-md/chart-candlestick.md +112 -0
- package/agent/docs-md/chart-card-line.md +120 -0
- package/agent/docs-md/chart-donut.md +90 -0
- package/agent/docs-md/chart-drill-down.md +72 -0
- package/agent/docs-md/chart-funnel.md +71 -0
- package/agent/docs-md/chart-heatmap.md +76 -0
- package/agent/docs-md/chart-histogram.md +81 -0
- package/agent/docs-md/chart-map.md +165 -0
- package/agent/docs-md/chart-mixed.md +88 -0
- package/agent/docs-md/chart-price.md +92 -0
- package/agent/docs-md/chart-radial-bars.md +109 -0
- package/agent/docs-md/chart-radial-lines.md +111 -0
- package/agent/docs-md/chart-sankey.md +104 -0
- package/agent/docs-md/chart-scatter.md +87 -0
- package/agent/docs-md/chart-share-bar.md +89 -0
- package/agent/docs-md/chart-treemap.md +98 -0
- package/agent/docs-md/chart-waterfall.md +93 -0
- package/agent/docs-md/checkbox.md +131 -0
- package/agent/docs-md/choosing-selection-control.md +71 -0
- package/agent/docs-md/citations-sources.md +79 -0
- package/agent/docs-md/citing-sources.md +63 -0
- package/agent/docs-md/clarify-before-acting.md +69 -0
- package/agent/docs-md/clarifying-question.md +92 -0
- package/agent/docs-md/code-block.md +174 -0
- package/agent/docs-md/colours.md +87 -0
- package/agent/docs-md/columns-menu.md +140 -0
- package/agent/docs-md/combobox.md +161 -0
- package/agent/docs-md/command-palette.md +126 -0
- package/agent/docs-md/confidence-note.md +87 -0
- package/agent/docs-md/configurable-dashboard.md +76 -0
- package/agent/docs-md/confirmation-dialog.md +93 -0
- package/agent/docs-md/connected-tools.md +80 -0
- package/agent/docs-md/connector-picker.md +127 -0
- package/agent/docs-md/consistency.md +53 -0
- package/agent/docs-md/content-density.md +61 -0
- package/agent/docs-md/conversation-history.md +78 -0
- package/agent/docs-md/create-record.md +80 -0
- package/agent/docs-md/csv-import.md +88 -0
- package/agent/docs-md/dashboard-items.md +78 -0
- package/agent/docs-md/data-export.md +61 -0
- package/agent/docs-md/data-grid.md +137 -0
- package/agent/docs-md/data-vis-colours.md +76 -0
- package/agent/docs-md/data-visualisation.md +76 -0
- package/agent/docs-md/date-picker.md +178 -0
- package/agent/docs-md/delete-record.md +75 -0
- package/agent/docs-md/dense-table.md +495 -0
- package/agent/docs-md/design-tokens.md +53 -0
- package/agent/docs-md/disabled-read-only.md +75 -0
- package/agent/docs-md/drag-and-drop.md +74 -0
- package/agent/docs-md/dropdown-menu.md +137 -0
- package/agent/docs-md/edit-record.md +73 -0
- package/agent/docs-md/empty-loading-error.md +78 -0
- package/agent/docs-md/empty-state.md +128 -0
- package/agent/docs-md/error-messages.md +75 -0
- package/agent/docs-md/error-state.md +123 -0
- package/agent/docs-md/export-menu.md +86 -0
- package/agent/docs-md/feedback-prompt.md +88 -0
- package/agent/docs-md/field-mapping.md +78 -0
- package/agent/docs-md/file-drop.md +110 -0
- package/agent/docs-md/filter-bar.md +138 -0
- package/agent/docs-md/filter-panel.md +103 -0
- package/agent/docs-md/filtering-search.md +79 -0
- package/agent/docs-md/follow-ups-starters.md +79 -0
- package/agent/docs-md/form-validation.md +95 -0
- package/agent/docs-md/generated-columns.md +65 -0
- package/agent/docs-md/generated-drafts-files.md +71 -0
- package/agent/docs-md/grouped-list.md +89 -0
- package/agent/docs-md/guide-cue.md +170 -0
- package/agent/docs-md/help-panel.md +103 -0
- package/agent/docs-md/help-system.md +74 -0
- package/agent/docs-md/iconography.md +49 -0
- package/agent/docs-md/inbox.md +108 -0
- package/agent/docs-md/inline-editing-validation.md +80 -0
- package/agent/docs-md/inline-input-in-answer.md +70 -0
- package/agent/docs-md/items-palette.md +116 -0
- package/agent/docs-md/keyboard-model.md +47 -0
- package/agent/docs-md/keyboard-navigation.md +68 -0
- package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
- package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
- package/agent/docs-md/labelling-ai-output.md +66 -0
- package/agent/docs-md/layout.md +47 -0
- package/agent/docs-md/list-table.md +254 -0
- package/agent/docs-md/loading-skeleton.md +91 -0
- package/agent/docs-md/managing-agents.md +71 -0
- package/agent/docs-md/message-composer.md +105 -0
- package/agent/docs-md/migrating.md +49 -0
- package/agent/docs-md/motion.md +93 -0
- package/agent/docs-md/navigation.md +76 -0
- package/agent/docs-md/notification-channels.md +65 -0
- package/agent/docs-md/notifications-decision.md +100 -0
- package/agent/docs-md/onboarding-step.md +114 -0
- package/agent/docs-md/order-book.md +108 -0
- package/agent/docs-md/page-header.md +96 -0
- package/agent/docs-md/pagination.md +88 -0
- package/agent/docs-md/pane-layout.md +105 -0
- package/agent/docs-md/period-comparison.md +66 -0
- package/agent/docs-md/plan-card.md +120 -0
- package/agent/docs-md/popover-rules.md +88 -0
- package/agent/docs-md/prompt-editor.md +90 -0
- package/agent/docs-md/propose-apply.md +65 -0
- package/agent/docs-md/provider-logo.md +97 -0
- package/agent/docs-md/radio.md +83 -0
- package/agent/docs-md/ranked-list.md +102 -0
- package/agent/docs-md/reasoning-progress.md +72 -0
- package/agent/docs-md/record-detail.md +71 -0
- package/agent/docs-md/regenerating-answers.md +70 -0
- package/agent/docs-md/run-status-bar.md +94 -0
- package/agent/docs-md/saved-tasks-schedules.md +63 -0
- package/agent/docs-md/saved-views.md +149 -0
- package/agent/docs-md/saving-views.md +68 -0
- package/agent/docs-md/scheduler.md +258 -0
- package/agent/docs-md/secondary-panels.md +72 -0
- package/agent/docs-md/segmented-control.md +88 -0
- package/agent/docs-md/selection-bulk-actions.md +70 -0
- package/agent/docs-md/separator.md +75 -0
- package/agent/docs-md/setup-checklist.md +92 -0
- package/agent/docs-md/shape-elevation.md +55 -0
- package/agent/docs-md/sheet-grid.md +211 -0
- package/agent/docs-md/side-panel.md +134 -0
- package/agent/docs-md/sign-in.md +114 -0
- package/agent/docs-md/spacing.md +55 -0
- package/agent/docs-md/split-button.md +100 -0
- package/agent/docs-md/stat-compare.md +82 -0
- package/agent/docs-md/static-dashboard.md +75 -0
- package/agent/docs-md/status-badge.md +140 -0
- package/agent/docs-md/step-card.md +75 -0
- package/agent/docs-md/stepper.md +92 -0
- package/agent/docs-md/suggested-changes.md +91 -0
- package/agent/docs-md/switch.md +78 -0
- package/agent/docs-md/table-settings.md +81 -0
- package/agent/docs-md/tabs.md +85 -0
- package/agent/docs-md/tag-input.md +108 -0
- package/agent/docs-md/tag.md +79 -0
- package/agent/docs-md/task-builder.md +87 -0
- package/agent/docs-md/team-instructions.md +72 -0
- package/agent/docs-md/templates-gallery.md +85 -0
- package/agent/docs-md/text-input.md +160 -0
- package/agent/docs-md/thinking-steps.md +89 -0
- package/agent/docs-md/timeline.md +189 -0
- package/agent/docs-md/timestamp.md +84 -0
- package/agent/docs-md/timestamps.md +70 -0
- package/agent/docs-md/toast.md +117 -0
- package/agent/docs-md/tooltip.md +90 -0
- package/agent/docs-md/typography.md +62 -0
- package/agent/docs-md/undo-destructive.md +66 -0
- package/agent/docs-md/unsaved-changes.md +71 -0
- package/agent/docs-md/usage-meter.md +80 -0
- package/agent/docs-md/user-feedback.md +71 -0
- package/agent/docs-md/voice-and-tone.md +60 -0
- package/agent/docs-md/why-choice.md +93 -0
- package/agent/docs-md/workspace-onboarding.md +74 -0
- package/agent/docs-md/writing.md +48 -0
- package/agent/llms-full.txt +18369 -0
- package/agent/llms.txt +198 -0
- package/agent/skills/twoux-audit/SKILL.md +49 -0
- package/agent/skills/twoux-build/SKILL.md +39 -0
- package/agent/skills/twoux-design/SKILL.md +54 -0
- package/agent/skills/twoux-write/SKILL.md +32 -0
- package/bin/check.mjs +123 -0
- package/bin/docs.mjs +24 -0
- package/bin/guard.mjs +27 -0
- package/bin/init.mjs +113 -0
- package/bin/knowledge.mjs +87 -0
- package/bin/mcp.mjs +116 -0
- package/bin/theme.mjs +168 -0
- package/bin/twoux.mjs +25 -0
- package/package.json +80 -3
- package/registry/jobs.json +1028 -0
- package/registry/palette.json +770 -0
- package/registry/rules.json +913 -0
- package/src/components/agent-review.tsx +364 -0
- package/src/components/ai-basics.tsx +212 -0
- package/src/components/ai-work.tsx +304 -0
- package/src/components/ai.tsx +227 -0
- package/src/components/alert-dialog.tsx +203 -0
- package/src/components/alert.tsx +86 -0
- package/src/components/avatar.tsx +114 -0
- package/src/components/badge.tsx +59 -0
- package/src/components/board.tsx +397 -0
- package/src/components/breadcrumb.tsx +114 -0
- package/src/components/button-group.tsx +90 -0
- package/src/components/button.tsx +109 -0
- package/src/components/calendar.tsx +247 -0
- package/src/components/card.tsx +102 -0
- package/src/components/chart-map.tsx +336 -0
- package/src/components/charts-more.tsx +1331 -0
- package/src/components/charts-radial.tsx +328 -0
- package/src/components/charts.tsx +390 -0
- package/src/components/checkbox.tsx +40 -0
- package/src/components/code.tsx +139 -0
- package/src/components/collapsible.tsx +37 -0
- package/src/components/combobox.tsx +339 -0
- package/src/components/command-extras.tsx +11 -0
- package/src/components/command.tsx +196 -0
- package/src/components/data-grid.tsx +213 -0
- package/src/components/data-table.tsx +334 -0
- package/src/components/data-views.tsx +740 -0
- package/src/components/date-picker.tsx +686 -0
- package/src/components/dense-table.tsx +207 -0
- package/src/components/dialog.tsx +162 -0
- package/src/components/dropdown-menu.tsx +264 -0
- package/src/components/empty.tsx +114 -0
- package/src/components/feedback-prompt.tsx +80 -0
- package/src/components/field.tsx +254 -0
- package/src/components/fields.tsx +112 -0
- package/src/components/flow.tsx +840 -0
- package/src/components/guide-cue.tsx +273 -0
- package/src/components/inbox.tsx +113 -0
- package/src/components/input-group.tsx +176 -0
- package/src/components/input.tsx +26 -0
- package/src/components/kbd.tsx +34 -0
- package/src/components/label.tsx +27 -0
- package/src/components/labels.tsx +176 -0
- package/src/components/motion.tsx +136 -0
- package/src/components/onboarding.tsx +454 -0
- package/src/components/order-book.tsx +185 -0
- package/src/components/pane-layout.tsx +417 -0
- package/src/components/popover.tsx +96 -0
- package/src/components/provider-logos.data.ts +49 -0
- package/src/components/radio-group.tsx +52 -0
- package/src/components/scheduler.tsx +997 -0
- package/src/components/segmented-control.tsx +28 -0
- package/src/components/select.tsx +195 -0
- package/src/components/separator.tsx +31 -0
- package/src/components/sheet.tsx +148 -0
- package/src/components/shell.tsx +426 -0
- package/src/components/shortcut-sheet.tsx +83 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/sonner.tsx +107 -0
- package/src/components/spinner.tsx +13 -0
- package/src/components/split-button.tsx +58 -0
- package/src/components/stepper.tsx +78 -0
- package/src/components/switch.tsx +43 -0
- package/src/components/table.tsx +126 -0
- package/src/components/tabs.tsx +118 -0
- package/src/components/tag-input.tsx +253 -0
- package/src/components/textarea.tsx +21 -0
- package/src/components/theme.tsx +80 -0
- package/src/components/timeline.tsx +408 -0
- package/src/components/toggle-group.tsx +87 -0
- package/src/components/toggle.tsx +54 -0
- package/src/components/tooltip.tsx +65 -0
- package/src/components/workspace-actions.tsx +294 -0
- package/src/components/workspace-import.tsx +465 -0
- package/src/components/workspace.tsx +1884 -0
- package/src/index.ts +80 -0
- package/src/lib/active-indicator.tsx +53 -0
- package/src/lib/chart-kit.tsx +72 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/shortcuts.tsx +61 -0
- package/src/lib/workspace.tsx +10 -0
- package/src/shadcn/alert-dialog.tsx +196 -0
- package/src/shadcn/alert.tsx +65 -0
- package/src/shadcn/avatar.tsx +108 -0
- package/src/shadcn/badge.tsx +47 -0
- package/src/shadcn/breadcrumb.tsx +108 -0
- package/src/shadcn/button-group.tsx +83 -0
- package/src/shadcn/button.tsx +63 -0
- package/src/shadcn/calendar.tsx +220 -0
- package/src/shadcn/card.tsx +91 -0
- package/src/shadcn/checkbox.tsx +31 -0
- package/src/shadcn/collapsible.tsx +33 -0
- package/src/shadcn/command.tsx +184 -0
- package/src/shadcn/dialog.tsx +158 -0
- package/src/shadcn/dropdown-menu.tsx +256 -0
- package/src/shadcn/empty.tsx +103 -0
- package/src/shadcn/field.tsx +248 -0
- package/src/shadcn/input-group.tsx +170 -0
- package/src/shadcn/input.tsx +20 -0
- package/src/shadcn/kbd.tsx +28 -0
- package/src/shadcn/label.tsx +23 -0
- package/src/shadcn/popover.tsx +88 -0
- package/src/shadcn/radio-group.tsx +44 -0
- package/src/shadcn/select.tsx +189 -0
- package/src/shadcn/separator.tsx +27 -0
- package/src/shadcn/sheet.tsx +142 -0
- package/src/shadcn/skeleton.tsx +13 -0
- package/src/shadcn/sonner.tsx +40 -0
- package/src/shadcn/switch.tsx +34 -0
- package/src/shadcn/table.tsx +115 -0
- package/src/shadcn/tabs.tsx +90 -0
- package/src/shadcn/textarea.tsx +17 -0
- package/src/shadcn/toggle-group.tsx +83 -0
- package/src/shadcn/toggle.tsx +46 -0
- package/src/shadcn/tooltip.tsx +56 -0
- package/src/styles/palette.css +127 -0
- package/src/styles/theme.css +35 -0
- package/src/styles/twoux.css +236 -0
- package/theme.json +14 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DatePicker, DateRangePicker and DateFilterPill: TwoUX compositions on the generated shadcn Calendar (react-day-picker), Popover and InputGroup.
|
|
3
|
+
* Typing is the main path; the calendar is the second. Values are ISO calendar dates ("2026-03-12"), never a Date at
|
|
4
|
+
* midnight UTC, so they match Timestamp, which reads that form as a local day. Errors sit under the field.
|
|
5
|
+
* Proposal: proposals/date-picker.md.
|
|
6
|
+
*/
|
|
7
|
+
"use client"
|
|
8
|
+
|
|
9
|
+
import { AlertCircle, CalendarDays, Plus, X } from "lucide-react"
|
|
10
|
+
import * as React from "react"
|
|
11
|
+
import type { DateRange as DayRange, Matcher } from "react-day-picker"
|
|
12
|
+
import { cn } from "../lib/cn"
|
|
13
|
+
import { Button } from "./button"
|
|
14
|
+
import { Calendar } from "./calendar"
|
|
15
|
+
import { Field, FieldDescription, FieldLabel } from "./field"
|
|
16
|
+
import { Input } from "./input"
|
|
17
|
+
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText } from "./input-group"
|
|
18
|
+
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "./popover"
|
|
19
|
+
|
|
20
|
+
/* ---------------------------------------------------------------- dates as ISO strings */
|
|
21
|
+
|
|
22
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
23
|
+
/** A local Date as an ISO calendar date: 2026-03-12. */
|
|
24
|
+
const isoOf = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
25
|
+
/** An ISO calendar date as a local Date at midnight (not UTC, which lands on the day before west of London). */
|
|
26
|
+
export const fromIsoDate = (iso: string) => {
|
|
27
|
+
const [y, m, d] = iso.split("-").map(Number)
|
|
28
|
+
return new Date(y, m - 1, d)
|
|
29
|
+
}
|
|
30
|
+
const valid = (y: number, m: number, d: number) => {
|
|
31
|
+
const t = new Date(y, m - 1, d)
|
|
32
|
+
return t.getFullYear() === y && t.getMonth() === m - 1 && t.getDate() === d ? isoOf(t) : null
|
|
33
|
+
}
|
|
34
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]
|
|
35
|
+
const year = (y: string) => (y.length === 2 ? 2000 + Number(y) : Number(y))
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reads a typed date the way people in the UK write it: 12/03/2026, 12/3/26, 12-03-2026, 12.03.2026, 12 Mar 2026,
|
|
39
|
+
* 12 March 2026 and ISO 2026-03-12. Returns the ISO date, or null when it isn't a real date.
|
|
40
|
+
*/
|
|
41
|
+
export function parseDate(text: string): string | null {
|
|
42
|
+
const s = text.trim().toLowerCase().replace(/,/g, " ").replace(/\s+/g, " ")
|
|
43
|
+
if (!s) return null
|
|
44
|
+
let m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(s)
|
|
45
|
+
if (m) return valid(+m[1], +m[2], +m[3])
|
|
46
|
+
m = /^(\d{1,2})[/.\-](\d{1,2})[/.\-](\d{2}|\d{4})$/.exec(s)
|
|
47
|
+
if (m) return valid(year(m[3]), +m[2], +m[1])
|
|
48
|
+
m = /^(\d{1,2})(?:st|nd|rd|th)? ([a-z]+)\.? (\d{2}|\d{4})$/.exec(s)
|
|
49
|
+
if (m) {
|
|
50
|
+
const month = MONTHS.indexOf(m[2].slice(0, 3))
|
|
51
|
+
return month < 0 || (m[2].length > 3 && !["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"][month].startsWith(m[2])) ? null : valid(year(m[3]), month + 1, +m[1])
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
/** The ISO date as it appears in the field: 12/03/2026. */
|
|
56
|
+
export const formatDateInput = (iso: string | null | undefined) => (iso ? iso.split("-").reverse().join("/") : "")
|
|
57
|
+
/** The ISO date in words, for messages: 7 December 2026. */
|
|
58
|
+
export const formatDateLong = (iso: string) => new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" }).format(fromIsoDate(iso))
|
|
59
|
+
|
|
60
|
+
/* ---------------------------------------------------------------- presets */
|
|
61
|
+
|
|
62
|
+
export type DatePreset = {
|
|
63
|
+
id: string
|
|
64
|
+
label: string
|
|
65
|
+
/** The range on a given day. Stored by id in filters, so a saved view still means "last 30 days" next month. */
|
|
66
|
+
range: (today: Date) => { from: string; to: string }
|
|
67
|
+
}
|
|
68
|
+
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
|
|
69
|
+
const quarterStart = (d: Date) => new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1)
|
|
70
|
+
/** Last 7 days, last 30 days, this month, last month, this quarter and last quarter, each ending today or at the period's end. */
|
|
71
|
+
export const DATE_PRESETS: DatePreset[] = [
|
|
72
|
+
{ id: "last-7-days", label: "Last 7 days", range: (t) => ({ from: isoOf(addDays(t, -6)), to: isoOf(t) }) },
|
|
73
|
+
{ id: "last-30-days", label: "Last 30 days", range: (t) => ({ from: isoOf(addDays(t, -29)), to: isoOf(t) }) },
|
|
74
|
+
{ id: "this-month", label: "This month", range: (t) => ({ from: isoOf(new Date(t.getFullYear(), t.getMonth(), 1)), to: isoOf(t) }) },
|
|
75
|
+
{ id: "last-month", label: "Last month", range: (t) => ({ from: isoOf(new Date(t.getFullYear(), t.getMonth() - 1, 1)), to: isoOf(new Date(t.getFullYear(), t.getMonth(), 0)) }) },
|
|
76
|
+
{ id: "this-quarter", label: "This quarter", range: (t) => ({ from: isoOf(quarterStart(t)), to: isoOf(t) }) },
|
|
77
|
+
{
|
|
78
|
+
id: "last-quarter",
|
|
79
|
+
label: "Last quarter",
|
|
80
|
+
range: (t) => {
|
|
81
|
+
const start = quarterStart(t)
|
|
82
|
+
return { from: isoOf(new Date(start.getFullYear(), start.getMonth() - 3, 1)), to: isoOf(addDays(start, -1)) }
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
/** The dates a range value stands for today: a preset is worked out afresh, fixed dates are returned as they are. */
|
|
87
|
+
export function resolveDateRange(value: DateRangeValue, presets: DatePreset[] = DATE_PRESETS, today = new Date()): { from: string | null; to: string | null } {
|
|
88
|
+
const p = value.preset ? presets.find((x) => x.id === value.preset) : undefined
|
|
89
|
+
return p ? p.range(today) : { from: value.from ?? null, to: value.to ?? null }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ---------------------------------------------------------------- shared parts */
|
|
93
|
+
|
|
94
|
+
type Unavailable = {
|
|
95
|
+
/** Earliest date people can choose, as an ISO date. */
|
|
96
|
+
min?: string
|
|
97
|
+
/** Latest date people can choose, as an ISO date. */
|
|
98
|
+
max?: string
|
|
99
|
+
/** Other dates people can't choose, such as weekends: true means unavailable. */
|
|
100
|
+
isDateUnavailable?: (iso: string) => boolean
|
|
101
|
+
/** One sentence under the calendar, and the error when such a date is typed: "Closing dates must be on a weekday." */
|
|
102
|
+
unavailableReason?: string
|
|
103
|
+
}
|
|
104
|
+
function checkDate(iso: string, { min, max, isDateUnavailable, unavailableReason }: Unavailable): string | undefined {
|
|
105
|
+
if (min && iso < min) return `Choose a date on or after ${formatDateLong(min)}.`
|
|
106
|
+
if (max && iso > max) return `Choose a date on or before ${formatDateLong(max)}.`
|
|
107
|
+
if (isDateUnavailable?.(iso)) return unavailableReason ?? "That date isn't available. Choose another."
|
|
108
|
+
}
|
|
109
|
+
function matchers({ min, max, isDateUnavailable }: Unavailable): Matcher[] {
|
|
110
|
+
const m: Matcher[] = []
|
|
111
|
+
if (min) m.push({ before: fromIsoDate(min) })
|
|
112
|
+
if (max) m.push({ after: fromIsoDate(max) })
|
|
113
|
+
if (isDateUnavailable) m.push((d: Date) => isDateUnavailable(isoOf(d)))
|
|
114
|
+
return m
|
|
115
|
+
}
|
|
116
|
+
const formatHelp = (what: string) => `Enter the ${what.charAt(0).toLowerCase() + what.slice(1)} as dd/mm/yyyy, such as 12/03/2026.`
|
|
117
|
+
|
|
118
|
+
function Message({ id, error, hint }: { id: string; error?: string; hint?: React.ReactNode }) {
|
|
119
|
+
if (error)
|
|
120
|
+
return (
|
|
121
|
+
<p id={id} className="flex items-start gap-control text-sm text-bad">
|
|
122
|
+
<AlertCircle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
|
123
|
+
{error}
|
|
124
|
+
</p>
|
|
125
|
+
)
|
|
126
|
+
return hint ? <FieldDescription id={id}>{hint}</FieldDescription> : null
|
|
127
|
+
}
|
|
128
|
+
function Label({ htmlFor, id, label, required, optional }: { htmlFor?: string; id: string; label: string; required?: boolean; optional?: boolean }) {
|
|
129
|
+
return (
|
|
130
|
+
<FieldLabel htmlFor={htmlFor} id={id}>
|
|
131
|
+
{label}
|
|
132
|
+
{required && <span aria-hidden="true" className="text-bad">*</span>}
|
|
133
|
+
{optional && <span className="font-normal text-ink-3">(optional)</span>}
|
|
134
|
+
</FieldLabel>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
function useWide() {
|
|
138
|
+
const [wide, setWide] = React.useState(false)
|
|
139
|
+
React.useEffect(() => {
|
|
140
|
+
const q = window.matchMedia("(min-width: 768px)")
|
|
141
|
+
const on = () => setWide(q.matches)
|
|
142
|
+
on()
|
|
143
|
+
q.addEventListener("change", on)
|
|
144
|
+
return () => q.removeEventListener("change", on)
|
|
145
|
+
}, [])
|
|
146
|
+
return wide
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* ---------------------------------------------------------------- DatePicker */
|
|
150
|
+
|
|
151
|
+
type DatePickerProps = Unavailable & {
|
|
152
|
+
label: string
|
|
153
|
+
/** ISO date, or null when empty. */
|
|
154
|
+
value: string | null
|
|
155
|
+
onValueChange: (value: string | null) => void
|
|
156
|
+
/** Shown under the field. Defaults to the format, "dd/mm/yyyy". */
|
|
157
|
+
hint?: string
|
|
158
|
+
/** An error from the form's own checks, such as "Enter a closing date." Typing errors are shown without it. */
|
|
159
|
+
error?: string
|
|
160
|
+
required?: boolean
|
|
161
|
+
optional?: boolean
|
|
162
|
+
disabled?: boolean
|
|
163
|
+
/** Shows the value as text that can be selected and copied, without the calendar button. */
|
|
164
|
+
readOnly?: boolean
|
|
165
|
+
id?: string
|
|
166
|
+
/** Submitted with the form as the ISO date. */
|
|
167
|
+
name?: string
|
|
168
|
+
className?: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** One calendar date: type it as dd/mm/yyyy or choose it on the calendar (Alt+↓). */
|
|
172
|
+
export function DatePicker({ label, value, onValueChange, hint = "dd/mm/yyyy", error, required, optional, disabled, readOnly, id, name, className, ...rules }: DatePickerProps) {
|
|
173
|
+
const auto = React.useId()
|
|
174
|
+
const fid = id ?? auto
|
|
175
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
176
|
+
const [text, setText] = React.useState(formatDateInput(value))
|
|
177
|
+
const [typingError, setTypingError] = React.useState<string>()
|
|
178
|
+
const [open, setOpen] = React.useState(false)
|
|
179
|
+
React.useEffect(() => setText(formatDateInput(value)), [value])
|
|
180
|
+
|
|
181
|
+
const commit = () => {
|
|
182
|
+
if (!text.trim()) {
|
|
183
|
+
setTypingError(undefined)
|
|
184
|
+
if (value !== null) onValueChange(null)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
const iso = parseDate(text)
|
|
188
|
+
if (!iso) {
|
|
189
|
+
setTypingError(formatHelp(label))
|
|
190
|
+
if (value !== null) onValueChange(null)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
setTypingError(checkDate(iso, rules))
|
|
194
|
+
setText(formatDateInput(iso))
|
|
195
|
+
if (iso !== value) onValueChange(iso)
|
|
196
|
+
}
|
|
197
|
+
const shown = typingError ?? error
|
|
198
|
+
const msgId = `${fid}-msg`
|
|
199
|
+
|
|
200
|
+
if (readOnly)
|
|
201
|
+
return (
|
|
202
|
+
<Field className={cn("gap-2", className)}>
|
|
203
|
+
<Label id={`${fid}-label`} htmlFor={fid} label={label} />
|
|
204
|
+
<InputGroup>
|
|
205
|
+
<InputGroupInput id={fid} readOnly value={formatDateInput(value)} name={name} />
|
|
206
|
+
</InputGroup>
|
|
207
|
+
</Field>
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<Field data-invalid={!!shown || undefined} className={cn("gap-2", className)}>
|
|
212
|
+
<Label id={`${fid}-label`} htmlFor={fid} label={label} required={required} optional={optional} />
|
|
213
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
214
|
+
<PopoverAnchor asChild>
|
|
215
|
+
<InputGroup data-disabled={disabled || undefined}>
|
|
216
|
+
<InputGroupInput
|
|
217
|
+
ref={inputRef}
|
|
218
|
+
id={fid}
|
|
219
|
+
inputMode="numeric"
|
|
220
|
+
autoComplete="off"
|
|
221
|
+
placeholder="dd/mm/yyyy"
|
|
222
|
+
value={text}
|
|
223
|
+
disabled={disabled}
|
|
224
|
+
required={required}
|
|
225
|
+
aria-invalid={shown ? true : undefined}
|
|
226
|
+
aria-describedby={msgId}
|
|
227
|
+
onChange={(e) => setText(e.target.value)}
|
|
228
|
+
onBlur={commit}
|
|
229
|
+
onKeyDown={(e) => {
|
|
230
|
+
if (e.key === "Enter") commit()
|
|
231
|
+
if (e.key === "ArrowDown" && e.altKey) {
|
|
232
|
+
e.preventDefault()
|
|
233
|
+
commit()
|
|
234
|
+
setOpen(true)
|
|
235
|
+
}
|
|
236
|
+
}}
|
|
237
|
+
/>
|
|
238
|
+
<InputGroupAddon align="inline-end">
|
|
239
|
+
<InputGroupButton size="icon-xs" aria-label="Choose date" aria-haspopup="dialog" aria-expanded={open} disabled={disabled} onClick={() => setOpen((o) => !o)}>
|
|
240
|
+
<CalendarDays />
|
|
241
|
+
</InputGroupButton>
|
|
242
|
+
</InputGroupAddon>
|
|
243
|
+
</InputGroup>
|
|
244
|
+
</PopoverAnchor>
|
|
245
|
+
<PopoverContent
|
|
246
|
+
align="end"
|
|
247
|
+
className="w-auto p-0"
|
|
248
|
+
role="dialog"
|
|
249
|
+
aria-label="Choose date"
|
|
250
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
251
|
+
onCloseAutoFocus={(e) => {
|
|
252
|
+
e.preventDefault()
|
|
253
|
+
inputRef.current?.focus()
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
<Calendar
|
|
257
|
+
mode="single"
|
|
258
|
+
autoFocus
|
|
259
|
+
selected={value ? fromIsoDate(value) : undefined}
|
|
260
|
+
defaultMonth={value ? fromIsoDate(value) : rules.min && new Date() < fromIsoDate(rules.min) ? fromIsoDate(rules.min) : undefined}
|
|
261
|
+
disabled={matchers(rules)}
|
|
262
|
+
footer={rules.unavailableReason ? <p className="max-w-60 pt-2 text-sm text-ink-2">{rules.unavailableReason}</p> : undefined}
|
|
263
|
+
onSelect={(d) => {
|
|
264
|
+
if (!d) return
|
|
265
|
+
const iso = isoOf(d)
|
|
266
|
+
setTypingError(undefined)
|
|
267
|
+
setText(formatDateInput(iso))
|
|
268
|
+
onValueChange(iso)
|
|
269
|
+
setOpen(false)
|
|
270
|
+
}}
|
|
271
|
+
/>
|
|
272
|
+
</PopoverContent>
|
|
273
|
+
</Popover>
|
|
274
|
+
{name && <input type="hidden" name={name} value={value ?? ""} />}
|
|
275
|
+
<Message id={msgId} error={shown} hint={hint} />
|
|
276
|
+
</Field>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ---------------------------------------------------------------- ranges: shared value, checks and calendar body */
|
|
281
|
+
|
|
282
|
+
export type DateRangeValue = {
|
|
283
|
+
from?: string | null
|
|
284
|
+
to?: string | null
|
|
285
|
+
/** A preset id ("last-30-days"): the range is worked out from today each time it is read. */
|
|
286
|
+
preset?: string
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const dayCount = (from: string, to: string) => Math.round((fromIsoDate(to).getTime() - fromIsoDate(from).getTime()) / 86400000) + 1
|
|
290
|
+
function checkRange(from: string | null | undefined, to: string | null | undefined, rules: Unavailable, maxDays?: number) {
|
|
291
|
+
for (const d of [from, to]) if (d) { const e = checkDate(d, rules); if (e) return e }
|
|
292
|
+
if (from && to && to < from) return "The end date is before the start date. Change one of them."
|
|
293
|
+
if (from && to && maxDays && dayCount(from, to) > maxDays) return `Choose a range of up to ${maxDays} days.`
|
|
294
|
+
}
|
|
295
|
+
/** Reads typed From and To text: the ISO dates, or which end isn't a date. */
|
|
296
|
+
function readTyped(fromText: string, toText: string): { from: string | null; to: string | null } | { bad: "start" | "end" } {
|
|
297
|
+
const from = fromText.trim() ? parseDate(fromText) : null
|
|
298
|
+
const to = toText.trim() ? parseDate(toText) : null
|
|
299
|
+
if (fromText.trim() && !from) return { bad: "start" }
|
|
300
|
+
if (toText.trim() && !to) return { bad: "end" }
|
|
301
|
+
return { from, to }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Presets beside a range calendar. The first day picked starts the range, the second ends it; days between fill as people point or move. */
|
|
305
|
+
function RangeBody({ value, presets, maxDays, months = 2, rules, onStart, onRange, onPreset }: {
|
|
306
|
+
value: DateRangeValue
|
|
307
|
+
months?: 1 | 2
|
|
308
|
+
presets: DatePreset[]
|
|
309
|
+
maxDays?: number
|
|
310
|
+
rules: Unavailable
|
|
311
|
+
onStart?: (iso: string) => void
|
|
312
|
+
onRange: (from: string, to: string) => void
|
|
313
|
+
onPreset: (p: DatePreset) => void
|
|
314
|
+
}) {
|
|
315
|
+
const resolved = resolveDateRange(value, presets.length ? presets : DATE_PRESETS)
|
|
316
|
+
const [picking, setPicking] = React.useState<string | null>(null)
|
|
317
|
+
const [hover, setHover] = React.useState<string | null>(null)
|
|
318
|
+
const wide = useWide() && months === 2
|
|
319
|
+
const choose = (d: Date) => {
|
|
320
|
+
const iso = isoOf(d)
|
|
321
|
+
if (!picking) {
|
|
322
|
+
setPicking(iso)
|
|
323
|
+
onStart?.(iso)
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
const [from, to] = iso < picking ? [iso, picking] : [picking, iso]
|
|
327
|
+
setPicking(null)
|
|
328
|
+
setHover(null)
|
|
329
|
+
onRange(from, to)
|
|
330
|
+
}
|
|
331
|
+
const lo = picking && hover ? (hover < picking ? hover : picking) : null
|
|
332
|
+
const hi = picking && hover ? (hover < picking ? picking : hover) : null
|
|
333
|
+
const selected: DayRange | undefined = picking
|
|
334
|
+
? { from: fromIsoDate(picking), to: undefined }
|
|
335
|
+
: resolved.from
|
|
336
|
+
? { from: fromIsoDate(resolved.from), to: resolved.to ? fromIsoDate(resolved.to) : undefined }
|
|
337
|
+
: undefined
|
|
338
|
+
return (
|
|
339
|
+
<div className="flex flex-col md:flex-row">
|
|
340
|
+
{presets.length > 0 && (
|
|
341
|
+
<ul aria-label="Preset periods" className="flex flex-wrap gap-1 border-b border-line p-2 md:w-36 md:flex-col md:flex-nowrap md:border-r md:border-b-0">
|
|
342
|
+
{presets.map((p) => (
|
|
343
|
+
<li key={p.id}>
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
aria-pressed={value.preset === p.id}
|
|
347
|
+
onClick={() => onPreset(p)}
|
|
348
|
+
className="w-full rounded-xs px-2 py-1 text-left text-base text-ink-2 transition-colors duration-(--duration-fast) hover:bg-hover hover:text-ink focus-visible:outline-2 focus-visible:outline-ring aria-pressed:bg-selected aria-pressed:text-ink"
|
|
349
|
+
>
|
|
350
|
+
{p.label}
|
|
351
|
+
</button>
|
|
352
|
+
</li>
|
|
353
|
+
))}
|
|
354
|
+
</ul>
|
|
355
|
+
)}
|
|
356
|
+
<Calendar
|
|
357
|
+
mode="range"
|
|
358
|
+
autoFocus
|
|
359
|
+
numberOfMonths={wide ? 2 : 1}
|
|
360
|
+
showOutsideDays={!wide}
|
|
361
|
+
selected={selected}
|
|
362
|
+
// A no-op onSelect makes the calendar show this component's range; without it react-day-picker keeps its own.
|
|
363
|
+
onSelect={() => {}}
|
|
364
|
+
defaultMonth={resolved.from ? fromIsoDate(resolved.from) : undefined}
|
|
365
|
+
disabled={matchers(rules)}
|
|
366
|
+
modifiers={{ preview: lo && hi ? { from: fromIsoDate(lo), to: fromIsoDate(hi) } : [] }}
|
|
367
|
+
modifiersClassNames={{ preview: "bg-selected" }}
|
|
368
|
+
onDayMouseEnter={(d) => picking && setHover(isoOf(d))}
|
|
369
|
+
onDayFocus={(d) => picking && setHover(isoOf(d))}
|
|
370
|
+
onDayClick={(d, m) => !m.disabled && choose(d)}
|
|
371
|
+
footer={
|
|
372
|
+
<p className="max-w-72 pt-2 text-sm text-ink-2">
|
|
373
|
+
{picking ? "Choose the end date." : rules.unavailableReason ?? (maxDays ? `Up to ${maxDays} days.` : "Choose the start date.")}
|
|
374
|
+
</p>
|
|
375
|
+
}
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* ---------------------------------------------------------------- DateRangePicker */
|
|
382
|
+
|
|
383
|
+
type DateRangePickerProps = Unavailable & {
|
|
384
|
+
label: string
|
|
385
|
+
value: DateRangeValue
|
|
386
|
+
onValueChange: (value: DateRangeValue) => void
|
|
387
|
+
/** Preset periods beside the calendar. true uses DATE_PRESETS. */
|
|
388
|
+
presets?: DatePreset[] | boolean
|
|
389
|
+
/** Longest range people can choose, in days, counting both ends. */
|
|
390
|
+
maxDays?: number
|
|
391
|
+
hint?: string
|
|
392
|
+
error?: string
|
|
393
|
+
required?: boolean
|
|
394
|
+
optional?: boolean
|
|
395
|
+
disabled?: boolean
|
|
396
|
+
id?: string
|
|
397
|
+
className?: string
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A from–to range of dates: type both ends, choose them on the calendar, or pick a preset such as Last 30 days. */
|
|
401
|
+
export function DateRangePicker({ label, value, onValueChange, presets, maxDays, hint, error, required, optional, disabled, id, className, ...rules }: DateRangePickerProps) {
|
|
402
|
+
const auto = React.useId()
|
|
403
|
+
const fid = id ?? auto
|
|
404
|
+
const list = presets === true ? DATE_PRESETS : presets || []
|
|
405
|
+
const resolved = resolveDateRange(value, list.length ? list : DATE_PRESETS)
|
|
406
|
+
const preset = value.preset ? list.find((p) => p.id === value.preset) : undefined
|
|
407
|
+
const fromRef = React.useRef<HTMLInputElement>(null)
|
|
408
|
+
const [fromText, setFromText] = React.useState(formatDateInput(resolved.from))
|
|
409
|
+
const [toText, setToText] = React.useState(formatDateInput(resolved.to))
|
|
410
|
+
const [typingError, setTypingError] = React.useState<string>()
|
|
411
|
+
const [open, setOpen] = React.useState(false)
|
|
412
|
+
React.useEffect(() => {
|
|
413
|
+
setFromText(formatDateInput(resolved.from))
|
|
414
|
+
setToText(formatDateInput(resolved.to))
|
|
415
|
+
}, [resolved.from, resolved.to])
|
|
416
|
+
|
|
417
|
+
const commit = () => {
|
|
418
|
+
const r = readTyped(fromText, toText)
|
|
419
|
+
if ("bad" in r) return setTypingError(formatHelp(`${r.bad} date`))
|
|
420
|
+
setTypingError(checkRange(r.from, r.to, rules, maxDays))
|
|
421
|
+
setFromText(formatDateInput(r.from))
|
|
422
|
+
setToText(formatDateInput(r.to))
|
|
423
|
+
if (r.from !== (resolved.from ?? null) || r.to !== (resolved.to ?? null) || value.preset) onValueChange({ from: r.from, to: r.to })
|
|
424
|
+
}
|
|
425
|
+
const shown = typingError ?? error
|
|
426
|
+
const msgId = `${fid}-msg`
|
|
427
|
+
const labelId = `${fid}-label`
|
|
428
|
+
const keys = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
429
|
+
if (e.key === "Enter") commit()
|
|
430
|
+
if (e.key === "ArrowDown" && e.altKey) {
|
|
431
|
+
e.preventDefault()
|
|
432
|
+
commit()
|
|
433
|
+
setOpen(true)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const inputClass = "min-w-0 flex-1 tabular"
|
|
437
|
+
|
|
438
|
+
return (
|
|
439
|
+
<Field data-invalid={!!shown || undefined} className={cn("gap-2", className)}>
|
|
440
|
+
<Label id={labelId} htmlFor={`${fid}-from`} label={label} required={required} optional={optional} />
|
|
441
|
+
<Popover
|
|
442
|
+
open={open}
|
|
443
|
+
onOpenChange={(o) => {
|
|
444
|
+
setOpen(o)
|
|
445
|
+
if (!o) {
|
|
446
|
+
setFromText(formatDateInput(resolved.from))
|
|
447
|
+
setToText(formatDateInput(resolved.to))
|
|
448
|
+
}
|
|
449
|
+
}}
|
|
450
|
+
>
|
|
451
|
+
<PopoverAnchor asChild>
|
|
452
|
+
<InputGroup data-disabled={disabled || undefined} aria-labelledby={labelId}>
|
|
453
|
+
<InputGroupInput ref={fromRef} id={`${fid}-from`} aria-label={`${label}, from`} inputMode="numeric" autoComplete="off" placeholder="dd/mm/yyyy" value={fromText} disabled={disabled} required={required} aria-invalid={shown ? true : undefined} aria-describedby={msgId} onChange={(e) => setFromText(e.target.value)} onBlur={commit} onKeyDown={keys} className={inputClass} />
|
|
454
|
+
<InputGroupText aria-hidden="true" className="px-1 text-ink-3">to</InputGroupText>
|
|
455
|
+
<InputGroupInput id={`${fid}-to`} aria-label={`${label}, to`} inputMode="numeric" autoComplete="off" placeholder="dd/mm/yyyy" value={toText} disabled={disabled} required={required} aria-invalid={shown ? true : undefined} aria-describedby={msgId} onChange={(e) => setToText(e.target.value)} onBlur={commit} onKeyDown={keys} className={inputClass} />
|
|
456
|
+
<InputGroupAddon align="inline-end">
|
|
457
|
+
<InputGroupButton size="icon-xs" aria-label="Choose dates" aria-haspopup="dialog" aria-expanded={open} disabled={disabled} onClick={() => setOpen((o) => !o)}>
|
|
458
|
+
<CalendarDays />
|
|
459
|
+
</InputGroupButton>
|
|
460
|
+
</InputGroupAddon>
|
|
461
|
+
</InputGroup>
|
|
462
|
+
</PopoverAnchor>
|
|
463
|
+
<PopoverContent
|
|
464
|
+
align="end"
|
|
465
|
+
className="w-auto max-w-(--radix-popover-content-available-width) p-0"
|
|
466
|
+
role="dialog"
|
|
467
|
+
aria-label="Choose dates"
|
|
468
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
469
|
+
onCloseAutoFocus={(e) => {
|
|
470
|
+
e.preventDefault()
|
|
471
|
+
fromRef.current?.focus()
|
|
472
|
+
}}
|
|
473
|
+
>
|
|
474
|
+
<RangeBody
|
|
475
|
+
value={value}
|
|
476
|
+
presets={list}
|
|
477
|
+
maxDays={maxDays}
|
|
478
|
+
rules={rules}
|
|
479
|
+
onStart={(iso) => {
|
|
480
|
+
setFromText(formatDateInput(iso))
|
|
481
|
+
setToText("")
|
|
482
|
+
}}
|
|
483
|
+
onRange={(from, to) => {
|
|
484
|
+
setTypingError(checkRange(from, to, rules, maxDays))
|
|
485
|
+
onValueChange({ from, to })
|
|
486
|
+
setOpen(false)
|
|
487
|
+
}}
|
|
488
|
+
onPreset={(p) => {
|
|
489
|
+
const r = p.range(new Date())
|
|
490
|
+
setTypingError(checkRange(r.from, r.to, rules, maxDays))
|
|
491
|
+
onValueChange({ ...r, preset: p.id })
|
|
492
|
+
setOpen(false)
|
|
493
|
+
}}
|
|
494
|
+
/>
|
|
495
|
+
</PopoverContent>
|
|
496
|
+
</Popover>
|
|
497
|
+
<Message id={msgId} error={shown} hint={preset ? `${preset.label}, worked out from today each time.` : hint} />
|
|
498
|
+
</Field>
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/* ---------------------------------------------------------------- DateFilterPill */
|
|
503
|
+
|
|
504
|
+
const shortDate = (iso: string, withYear: boolean) => new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", ...(withYear && { year: "numeric" }) }).format(fromIsoDate(iso))
|
|
505
|
+
/** The operator and value a date filter reads as: "is in" "Last 30 days", "is between" "3 Oct – 12 Oct 2026", "is on or after" "3 Oct 2026". */
|
|
506
|
+
export function describeDateFilter(value: DateRangeValue, presets: DatePreset[] = DATE_PRESETS): { operator: string; text: string; spoken: string } | null {
|
|
507
|
+
const p = value.preset ? presets.find((x) => x.id === value.preset) : undefined
|
|
508
|
+
if (p) return { operator: "is in", text: p.label, spoken: p.label }
|
|
509
|
+
const { from, to } = value
|
|
510
|
+
if (from && to) {
|
|
511
|
+
const sameYear = from.slice(0, 4) === to.slice(0, 4)
|
|
512
|
+
return { operator: "is between", text: `${shortDate(from, !sameYear)} – ${shortDate(to, true)}`, spoken: `${formatDateLong(from)} and ${formatDateLong(to)}` }
|
|
513
|
+
}
|
|
514
|
+
if (from) return { operator: "is on or after", text: shortDate(from, true), spoken: formatDateLong(from) }
|
|
515
|
+
if (to) return { operator: "is on or before", text: shortDate(to, true), spoken: formatDateLong(to) }
|
|
516
|
+
return null
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* A date filter as URL-safe strings, for FilterValue records and the address bar: ["last-30-days"] for a preset,
|
|
520
|
+
* ["2026-10-01", "2026-10-31"] for dates, with "" for an open end.
|
|
521
|
+
*/
|
|
522
|
+
export const dateFilterToValues = (value: DateRangeValue): string[] =>
|
|
523
|
+
value.preset ? [value.preset] : value.from || value.to ? [value.from ?? "", value.to ?? ""] : []
|
|
524
|
+
/** Reads dateFilterToValues back. Anything that isn't a known preset or an ISO date is dropped, so a hand-edited URL can't break the page. */
|
|
525
|
+
export function dateFilterFromValues(values: string[] | undefined, presets: DatePreset[] = DATE_PRESETS): DateRangeValue {
|
|
526
|
+
if (!values?.length) return {}
|
|
527
|
+
if (values.length === 1 && presets.some((p) => p.id === values[0])) return { preset: values[0] }
|
|
528
|
+
const ok = (s?: string) => (s && /^\d{4}-\d{2}-\d{2}$/.test(s) && parseDate(s) ? s : null)
|
|
529
|
+
return { from: ok(values[0]), to: ok(values[1]) }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
type DateFilterPillProps = Unavailable & {
|
|
533
|
+
/** The field filtered, as on the chip: Applied, Closing date. */
|
|
534
|
+
field: string
|
|
535
|
+
value: DateRangeValue
|
|
536
|
+
/** Called when a preset or a range is chosen, or typed dates are applied with Enter. */
|
|
537
|
+
onChange: (value: DateRangeValue) => void
|
|
538
|
+
/** Removes the pill. Without it, a set pill clears its dates instead. */
|
|
539
|
+
onRemove?: () => void
|
|
540
|
+
/** Preset periods. Defaults to DATE_PRESETS; [] shows the calendar only. */
|
|
541
|
+
presets?: DatePreset[]
|
|
542
|
+
maxDays?: number
|
|
543
|
+
defaultOpen?: boolean
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* A filter pill for a date field, in a FilterBar beside FilterPill. Reads as a sentence, "Applied is in Last 30 days";
|
|
548
|
+
* dashed while empty. Presets and calendar ranges apply at once; typed dates apply on Enter.
|
|
549
|
+
*/
|
|
550
|
+
export function DateFilterPill({ field, value, onChange, onRemove, presets = DATE_PRESETS, maxDays, defaultOpen = false, ...rules }: DateFilterPillProps) {
|
|
551
|
+
const [open, setOpen] = React.useState(defaultOpen)
|
|
552
|
+
const d = describeDateFilter(value, presets)
|
|
553
|
+
const remove = onRemove ?? (() => onChange({}))
|
|
554
|
+
const segment = "flex items-center px-2 whitespace-nowrap"
|
|
555
|
+
const focus = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
|
556
|
+
|
|
557
|
+
return (
|
|
558
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
559
|
+
{/* The same segmented chip as FilterPill: field | operator | value | remove. */}
|
|
560
|
+
<div
|
|
561
|
+
data-slot="filter-pill"
|
|
562
|
+
data-state={d ? "set" : "empty"}
|
|
563
|
+
className={cn(
|
|
564
|
+
"inline-flex h-7 items-stretch overflow-hidden rounded-sm border text-base transition-colors duration-(--duration-fast) ease-(--ease-out)",
|
|
565
|
+
d ? "divide-x divide-line border-line-strong bg-surface text-ink shadow-xs" : "border-dashed border-line-strong text-ink-2 hover:border-input-hover hover:text-ink"
|
|
566
|
+
)}
|
|
567
|
+
>
|
|
568
|
+
{d ? (
|
|
569
|
+
<>
|
|
570
|
+
<span className={cn(segment, "text-ink-2")}>{field}</span>
|
|
571
|
+
<span className={cn(segment, "text-ink-3")}>{d.operator}</span>
|
|
572
|
+
<PopoverTrigger asChild>
|
|
573
|
+
<button
|
|
574
|
+
type="button"
|
|
575
|
+
aria-label={`${field} ${d.operator} ${d.spoken}. Change dates`}
|
|
576
|
+
onKeyDown={(e) => { if (e.key === "Backspace" || e.key === "Delete") { e.preventDefault(); remove() } }}
|
|
577
|
+
className={cn(segment, "tabular max-w-56 font-medium transition-colors hover:bg-hover", focus)}
|
|
578
|
+
>
|
|
579
|
+
<span className="truncate">{d.text}</span>
|
|
580
|
+
</button>
|
|
581
|
+
</PopoverTrigger>
|
|
582
|
+
<button type="button" onClick={remove} aria-label={`Remove ${field} filter`} className={cn("grid w-7 place-items-center text-ink-3 transition-colors hover:bg-hover hover:text-ink", focus)}>
|
|
583
|
+
<X className="size-3.5" aria-hidden="true" />
|
|
584
|
+
</button>
|
|
585
|
+
</>
|
|
586
|
+
) : (
|
|
587
|
+
<PopoverTrigger asChild>
|
|
588
|
+
<button type="button" className={cn("inline-flex items-center gap-1 px-2 whitespace-nowrap", focus)}>
|
|
589
|
+
<Plus className="size-3.5 text-ink-3" aria-hidden="true" />
|
|
590
|
+
{field}
|
|
591
|
+
</button>
|
|
592
|
+
</PopoverTrigger>
|
|
593
|
+
)}
|
|
594
|
+
</div>
|
|
595
|
+
<PopoverContent align="start" role="dialog" aria-label={`${field} dates`} className="w-auto max-w-(--radix-popover-content-available-width) p-0" onOpenAutoFocus={(e) => e.preventDefault()}>
|
|
596
|
+
<DateRangeChooser
|
|
597
|
+
value={value}
|
|
598
|
+
presets={presets}
|
|
599
|
+
maxDays={maxDays}
|
|
600
|
+
{...rules}
|
|
601
|
+
onApply={(next) => {
|
|
602
|
+
onChange(next)
|
|
603
|
+
setOpen(false)
|
|
604
|
+
}}
|
|
605
|
+
/>
|
|
606
|
+
</PopoverContent>
|
|
607
|
+
</Popover>
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
type DateRangeChooserProps = Unavailable & {
|
|
612
|
+
value: DateRangeValue
|
|
613
|
+
/** Called with a preset, a calendar range or typed dates, once they pass the checks. */
|
|
614
|
+
onApply: (value: DateRangeValue) => void
|
|
615
|
+
presets?: DatePreset[]
|
|
616
|
+
maxDays?: number
|
|
617
|
+
/** Months shown side by side from 768px. One beside a menu; two in a pill. */
|
|
618
|
+
months?: 1 | 2
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The body of a date filter: From and To (applied on Enter or Apply), preset periods and a range calendar.
|
|
623
|
+
* Used inside DateFilterPill and beside FilterButton's field list; put it in a popover, never on the page.
|
|
624
|
+
*/
|
|
625
|
+
export function DateRangeChooser({ value, onApply, presets = DATE_PRESETS, maxDays, months = 2, ...rules }: DateRangeChooserProps) {
|
|
626
|
+
const auto = React.useId()
|
|
627
|
+
const resolved = resolveDateRange(value, presets.length ? presets : DATE_PRESETS)
|
|
628
|
+
const [fromText, setFromText] = React.useState(formatDateInput(resolved.from))
|
|
629
|
+
const [toText, setToText] = React.useState(formatDateInput(resolved.to))
|
|
630
|
+
const [error, setError] = React.useState<string>()
|
|
631
|
+
const apply = (next: DateRangeValue) => {
|
|
632
|
+
const e = checkRange(next.from, next.to, rules, maxDays)
|
|
633
|
+
if (e) return setError(e)
|
|
634
|
+
onApply(next)
|
|
635
|
+
}
|
|
636
|
+
const applyTyped = () => {
|
|
637
|
+
const r = readTyped(fromText, toText)
|
|
638
|
+
if ("bad" in r) return setError(formatHelp(`${r.bad} date`))
|
|
639
|
+
if (!r.from && !r.to) return setError("Enter a start date, an end date or both.")
|
|
640
|
+
apply({ from: r.from, to: r.to })
|
|
641
|
+
}
|
|
642
|
+
const errId = `${auto}-err`
|
|
643
|
+
return (
|
|
644
|
+
<div data-slot="date-range-chooser">
|
|
645
|
+
<form
|
|
646
|
+
noValidate
|
|
647
|
+
onSubmit={(e) => {
|
|
648
|
+
e.preventDefault()
|
|
649
|
+
applyTyped()
|
|
650
|
+
}}
|
|
651
|
+
className="grid gap-2 border-b border-line p-2"
|
|
652
|
+
>
|
|
653
|
+
<div className="flex items-end gap-2">
|
|
654
|
+
<label className="grid min-w-0 flex-1 gap-1 text-sm text-ink-2">
|
|
655
|
+
From
|
|
656
|
+
<Input value={fromText} onChange={(e) => setFromText(e.target.value)} placeholder="dd/mm/yyyy" inputMode="numeric" autoComplete="off" aria-invalid={error ? true : undefined} aria-describedby={error ? errId : undefined} className="tabular" />
|
|
657
|
+
</label>
|
|
658
|
+
<label className="grid min-w-0 flex-1 gap-1 text-sm text-ink-2">
|
|
659
|
+
To
|
|
660
|
+
<Input value={toText} onChange={(e) => setToText(e.target.value)} placeholder="dd/mm/yyyy" inputMode="numeric" autoComplete="off" aria-invalid={error ? true : undefined} aria-describedby={error ? errId : undefined} className="tabular" />
|
|
661
|
+
</label>
|
|
662
|
+
<Button type="submit" variant="outline">Apply</Button>
|
|
663
|
+
</div>
|
|
664
|
+
{error && (
|
|
665
|
+
<p id={errId} className="flex items-start gap-control text-sm text-bad">
|
|
666
|
+
<AlertCircle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
|
667
|
+
{error}
|
|
668
|
+
</p>
|
|
669
|
+
)}
|
|
670
|
+
</form>
|
|
671
|
+
<RangeBody
|
|
672
|
+
value={value}
|
|
673
|
+
presets={presets}
|
|
674
|
+
maxDays={maxDays}
|
|
675
|
+
months={months}
|
|
676
|
+
rules={rules}
|
|
677
|
+
onStart={(iso) => {
|
|
678
|
+
setFromText(formatDateInput(iso))
|
|
679
|
+
setToText("")
|
|
680
|
+
}}
|
|
681
|
+
onRange={(from, to) => apply({ from, to })}
|
|
682
|
+
onPreset={(p) => apply({ preset: p.id })}
|
|
683
|
+
/>
|
|
684
|
+
</div>
|
|
685
|
+
)
|
|
686
|
+
}
|