@12-apps/ui 2.1.0 → 3.1.0
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/components-catalog.md +8 -0
- package/package.json +2 -1
- package/src/components/data-display/Alert/Alert.types.ts +1 -1
- package/src/components/data-display/Avatar/Avatar.types.ts +1 -1
- package/src/components/data-display/Avatar/Avatar.view.tsx +5 -1
- package/src/components/data-display/Badge/Badge.styles.ts +11 -2
- package/src/components/data-display/Badge/Badge.types.ts +1 -1
- package/src/components/data-display/Carousel/Carousel.types.ts +4 -4
- package/src/components/data-display/Chart/Chart.types.ts +1 -1
- package/src/components/data-display/Chart/ChartSpec.types.ts +1 -1
- package/src/components/data-display/Chip/Chip.tsx +1 -1
- package/src/components/data-display/Chip/Chip.types.ts +6 -3
- package/src/components/data-display/DataViews/data-views-day-input.tsx +43 -22
- package/src/components/data-display/DateTimeDisplay/DateTimeDisplay.types.ts +1 -1
- package/src/components/data-display/InteractiveTooltip/InteractiveTooltip.types.ts +1 -1
- package/src/components/data-display/LoadingState/LoadingState.helpers.ts +4 -0
- package/src/components/data-display/LoadingState/LoadingState.types.ts +1 -1
- package/src/components/data-display/Progress/Progress.styles.ts +3 -0
- package/src/components/data-display/Progress/Progress.types.ts +2 -2
- package/src/components/data-display/Sheet/Sheet.types.ts +1 -1
- package/src/components/data-display/Stepper/Stepper.tsx +18 -6
- package/src/components/data-display/Stepper/Stepper.types.ts +2 -2
- package/src/components/data-display/Tooltip/Tooltip.types.ts +1 -1
- package/src/components/form/Button/Button.styles.ts +1 -0
- package/src/components/form/Command/Command.types.ts +1 -1
- package/src/components/form/CreatableSelect/CreatableSelect.tsx +5 -2
- package/src/components/form/CreatableSelect/CreatableSelect.types.ts +1 -1
- package/src/components/form/DateRangePicker/DateRangePicker.dates.ts +149 -0
- package/src/components/form/DateRangePicker/DateRangePicker.hooks.ts +87 -0
- package/src/components/form/DateRangePicker/DateRangePicker.md +228 -0
- package/src/components/form/DateRangePicker/DateRangePicker.quick.ts +97 -0
- package/src/components/form/DateRangePicker/DateRangePicker.range.ts +105 -0
- package/src/components/form/DateRangePicker/DateRangePicker.tsx +146 -0
- package/src/components/form/DateRangePicker/DateRangePicker.types.ts +193 -0
- package/src/components/form/DateRangePicker/DateRangePickerFields.tsx +90 -0
- package/src/components/form/DateRangePicker/DateRangePickerQuickList.tsx +123 -0
- package/src/components/form/DateRangePicker/index.ts +25 -0
- package/src/components/form/Input/Input.styles.ts +4 -0
- package/src/components/form/Input/Input.types.ts +1 -1
- package/src/components/form/InputOTP/InputOTP.styles.ts +1 -0
- package/src/components/form/InputOTP/InputOTP.types.ts +1 -1
- package/src/components/form/RadioGroup/RadioGroup.styles.ts +1 -0
- package/src/components/form/RadioGroup/RadioGroup.types.ts +1 -1
- package/src/components/form/Select/Select.types.ts +1 -1
- package/src/components/form/Slider/Slider.styles.ts +1 -0
- package/src/components/form/Slider/Slider.types.ts +1 -1
- package/src/components/form/Switch/Switch.styles.ts +1 -0
- package/src/components/form/Switch/Switch.types.ts +1 -1
- package/src/components/form/Textarea/Textarea.styles.ts +1 -0
- package/src/components/form/Textarea/Textarea.types.ts +1 -1
- package/src/components/form/Toggle/Toggle.styles.ts +1 -0
- package/src/components/form/Toggle/Toggle.types.ts +1 -1
- package/src/components/form/ToggleGroup/ToggleGroup.tsx +1 -0
- package/src/components/form/ToggleGroup/ToggleGroup.types.ts +1 -1
- package/src/components/form/index.ts +1 -0
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.styles.tsx +6 -3
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.types.ts +1 -1
- package/src/components/navigation/ContextMenu/ContextMenu.types.ts +1 -1
- package/src/components/navigation/DropdownMenu/DropdownMenu.types.ts +1 -1
- package/src/components/navigation/NavigationMenu/NavigationMenu.types.ts +1 -1
- package/src/components/navigation/Pagination/Pagination.types.ts +1 -1
- package/src/components/navigation/Tabs/Tabs.types.ts +1 -1
- package/src/components/typography/Blockquote/Blockquote.tsx +1 -0
- package/src/components/typography/Blockquote/Blockquote.types.ts +1 -1
- package/src/components/typography/Code/Code.types.ts +1 -1
- package/src/components/typography/Heading/Heading.styles.ts +23 -7
- package/src/components/typography/Heading/Heading.types.ts +1 -1
- package/src/components/typography/Paragraph/Paragraph.tsx +1 -0
- package/src/components/typography/Paragraph/Paragraph.types.ts +1 -1
- package/src/components/typography/Text/Text.tsx +11 -5
- package/src/components/typography/Text/Text.types.ts +1 -1
- package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +1 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.types.ts +1 -1
- package/src/components/utility/WorkflowStep/WorkflowStep.types.ts +1 -1
- package/src/tokens/scales.ts +58 -7
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Civil-day arithmetic for the range picker.
|
|
3
|
+
*
|
|
4
|
+
* Two representations meet here and the boundary between them is the whole
|
|
5
|
+
* point of the file:
|
|
6
|
+
*
|
|
7
|
+
* - `AAAA-MM-DD` — what the picker's value, the quick ranges and any caller
|
|
8
|
+
* speak. A day with no instant attached, so it cannot drift.
|
|
9
|
+
* - a LOCAL `Date` at NOON — what `Calendar` deals in. Noon (not midnight) is
|
|
10
|
+
* `Calendar.dates.normalizeDate`'s own rule: a daylight-saving jump at
|
|
11
|
+
* midnight can move a date across a day boundary, and midday never can.
|
|
12
|
+
*
|
|
13
|
+
* The arithmetic itself is `Calendar.dates`' — `addDays` and `startOfWeek` are
|
|
14
|
+
* imported rather than re-written, so a week here starts on the same day the
|
|
15
|
+
* grid draws it.
|
|
16
|
+
*
|
|
17
|
+
* ONE thing needs a time zone: which day is today. Everything after that is
|
|
18
|
+
* calendar arithmetic that has no clock in it.
|
|
19
|
+
*/
|
|
20
|
+
import { addDays, startOfWeek } from '../Calendar/Calendar.dates';
|
|
21
|
+
|
|
22
|
+
import type { DayString, WeekStart } from './DateRangePicker.types';
|
|
23
|
+
|
|
24
|
+
const NOON = 12;
|
|
25
|
+
const MS_PER_DAY = 86_400_000;
|
|
26
|
+
const MONTHS_PER_QUARTER = 3;
|
|
27
|
+
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
28
|
+
|
|
29
|
+
/** `[year, month 1-12, day]`, or null when the text is not a day at all. */
|
|
30
|
+
function dayParts(day: DayString): [number, number, number] | null {
|
|
31
|
+
const parts = DAY_PATTERN.exec(day);
|
|
32
|
+
if (!parts) return null;
|
|
33
|
+
return [Number(parts[1]), Number(parts[2]), Number(parts[3])];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const pad = (value: number): string => String(value).padStart(2, '0');
|
|
37
|
+
|
|
38
|
+
/** `AAAA-MM-DD` from calendar parts. Month is 1-12. */
|
|
39
|
+
function formatDay(year: number, month: number, day: number): DayString {
|
|
40
|
+
return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A LOCAL `Date` → the day it falls on, read off the same local getters. */
|
|
44
|
+
export function toDayString(date: Date): DayString {
|
|
45
|
+
return formatDay(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `AAAA-MM-DD` → a LOCAL `Date` at noon, or null when the text is not a real
|
|
50
|
+
* day.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately not `new Date(day)`: the spec reads a bare `AAAA-MM-DD` as UTC
|
|
53
|
+
* midnight, so west of Greenwich it lands on the day BEFORE and every date in
|
|
54
|
+
* the picker is off by one. The round-trip check is what rejects `2026-02-31`,
|
|
55
|
+
* which `Date` would otherwise roll silently into March.
|
|
56
|
+
*/
|
|
57
|
+
export function toLocalDay(day: DayString | null | undefined): Date | null {
|
|
58
|
+
if (!day) return null;
|
|
59
|
+
const parts = dayParts(day);
|
|
60
|
+
if (!parts) return null;
|
|
61
|
+
const date = new Date(parts[0], parts[1] - 1, parts[2], NOON);
|
|
62
|
+
return toDayString(date) === day ? date : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const civilFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
66
|
+
|
|
67
|
+
function civilFormatter(timeZone: string): Intl.DateTimeFormat {
|
|
68
|
+
const cached = civilFormatters.get(timeZone);
|
|
69
|
+
if (cached) return cached;
|
|
70
|
+
const created = new Intl.DateTimeFormat('en-US', {
|
|
71
|
+
timeZone,
|
|
72
|
+
year: 'numeric',
|
|
73
|
+
month: '2-digit',
|
|
74
|
+
day: '2-digit',
|
|
75
|
+
});
|
|
76
|
+
civilFormatters.set(timeZone, created);
|
|
77
|
+
return created;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The civil day `now` falls on, on `timeZone`'s clock (the host's when absent).
|
|
82
|
+
*
|
|
83
|
+
* Read through `formatToParts` rather than by adding an offset to an epoch: the
|
|
84
|
+
* zone database already knows about every transition, and reconstructing one by
|
|
85
|
+
* hand is how a picker ends up a day out for exactly one week of the year.
|
|
86
|
+
*/
|
|
87
|
+
export function todayIn(timeZone: string | undefined, now: Date): DayString {
|
|
88
|
+
if (!timeZone) return toDayString(now);
|
|
89
|
+
const parts = civilFormatter(timeZone).formatToParts(now);
|
|
90
|
+
const read = (type: Intl.DateTimeFormatPartTypes): string =>
|
|
91
|
+
parts.find((part) => part.type === type)?.value ?? '';
|
|
92
|
+
return `${read('year')}-${read('month')}-${read('day')}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The same day `days` later (or earlier). */
|
|
96
|
+
export function shiftDay(day: DayString, days: number): DayString {
|
|
97
|
+
const date = toLocalDay(day);
|
|
98
|
+
return date ? toDayString(addDays(date, days)) : day;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The 1st of `day`'s month. */
|
|
102
|
+
export function startOfMonthDay(day: DayString): DayString {
|
|
103
|
+
const parts = dayParts(day);
|
|
104
|
+
return parts ? formatDay(parts[0], parts[1], 1) : day;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The 1st of `day`'s CALENDAR quarter — Jan/Apr/Jul/Oct. Fiscal quarters are a
|
|
109
|
+
* different thing and belong in a caller's own quick range, not in a default.
|
|
110
|
+
*/
|
|
111
|
+
export function startOfQuarterDay(day: DayString): DayString {
|
|
112
|
+
const parts = dayParts(day);
|
|
113
|
+
if (!parts) return day;
|
|
114
|
+
const month = parts[1] - ((parts[1] - 1) % MONTHS_PER_QUARTER);
|
|
115
|
+
return formatDay(parts[0], month, 1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The 1st of January of `day`'s year. */
|
|
119
|
+
export function startOfYearDay(day: DayString): DayString {
|
|
120
|
+
const parts = dayParts(day);
|
|
121
|
+
return parts ? formatDay(parts[0], 1, 1) : day;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The first day of `day`'s week, on the same week start the grid uses. */
|
|
125
|
+
export function startOfWeekDay(day: DayString, weekStartsOn: WeekStart): DayString {
|
|
126
|
+
const date = toLocalDay(day);
|
|
127
|
+
return date ? toDayString(startOfWeek(date, weekStartsOn)) : day;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* How many days a window covers, both ends INCLUDED — so a single day is 1.
|
|
132
|
+
*
|
|
133
|
+
* Measured on UTC epochs rather than on the local dates: the difference between
|
|
134
|
+
* two local noons can be 23 or 25 hours across a transition, and dividing that
|
|
135
|
+
* by a fixed day length is how a 30-day window starts reporting 29.96.
|
|
136
|
+
*/
|
|
137
|
+
export function dayCount(from: DayString, to: DayString): number {
|
|
138
|
+
const start = dayParts(from);
|
|
139
|
+
const end = dayParts(to);
|
|
140
|
+
if (!start || !end) return 0;
|
|
141
|
+
const startMs = Date.UTC(start[0], start[1] - 1, start[2]);
|
|
142
|
+
const endMs = Date.UTC(end[0], end[1] - 1, end[2]);
|
|
143
|
+
return Math.round((endMs - startMs) / MS_PER_DAY) + 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `AAAA-MM-DD` sorts lexicographically, so this needs no parsing at all. */
|
|
147
|
+
export function isAfterDay(a: DayString, b: DayString): boolean {
|
|
148
|
+
return a > b;
|
|
149
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the picker derives and every way its range can move, kept out of
|
|
3
|
+
* the component so that file is a layout and this one is the behaviour — the
|
|
4
|
+
* same split `Calendar.hooks.ts` uses next door.
|
|
5
|
+
*/
|
|
6
|
+
import { useId, useMemo, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { toDayString, todayIn } from './DateRangePicker.dates';
|
|
9
|
+
import {
|
|
10
|
+
activeQuickRangeId,
|
|
11
|
+
defaultMessages,
|
|
12
|
+
resolveDayRange,
|
|
13
|
+
resolveQuickRanges,
|
|
14
|
+
} from './DateRangePicker.range';
|
|
15
|
+
import type {
|
|
16
|
+
DateRangeChangeMeta,
|
|
17
|
+
DateRangeDraft,
|
|
18
|
+
DateRangePickerState,
|
|
19
|
+
UseDateRangePickerArgs,
|
|
20
|
+
} from './DateRangePicker.types';
|
|
21
|
+
|
|
22
|
+
export function useDateRangePicker({
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
timeZone,
|
|
26
|
+
now,
|
|
27
|
+
maxRangeDays,
|
|
28
|
+
weekStartsOn,
|
|
29
|
+
quickRanges,
|
|
30
|
+
messages,
|
|
31
|
+
}: UseDateRangePickerArgs): DateRangePickerState {
|
|
32
|
+
const statusId = useId();
|
|
33
|
+
// `Calendar` reads which month to open on from the range it is handed, and
|
|
34
|
+
// only when it MOUNTS. Bumping this key remounts it, which is how the other
|
|
35
|
+
// two views move it. It changes for a quick pick and for a typed date and
|
|
36
|
+
// NEVER for a click inside the grid — a calendar that jumped under the
|
|
37
|
+
// pointer picking in it would be unusable.
|
|
38
|
+
const [viewNonce, setViewNonce] = useState(0);
|
|
39
|
+
|
|
40
|
+
const copy = useMemo(() => ({ ...defaultMessages(), ...messages }), [messages]);
|
|
41
|
+
const today = useMemo(() => todayIn(timeZone, now ?? new Date()), [timeZone, now]);
|
|
42
|
+
const options = useMemo(
|
|
43
|
+
() => resolveQuickRanges(quickRanges, { today, weekStartsOn }, maxRangeDays),
|
|
44
|
+
[quickRanges, today, weekStartsOn, maxRangeDays],
|
|
45
|
+
);
|
|
46
|
+
const status = useMemo(() => resolveDayRange(value, maxRangeDays), [value, maxRangeDays]);
|
|
47
|
+
|
|
48
|
+
const emit = (
|
|
49
|
+
next: DateRangeDraft,
|
|
50
|
+
source: DateRangeChangeMeta['source'],
|
|
51
|
+
quickRangeId?: string,
|
|
52
|
+
): void => {
|
|
53
|
+
onChange(next, {
|
|
54
|
+
source,
|
|
55
|
+
...(quickRangeId ? { quickRangeId } : {}),
|
|
56
|
+
// Judged here, once. A caller left to re-derive "is this usable" is a
|
|
57
|
+
// second opinion able to disagree with the message on screen.
|
|
58
|
+
status: resolveDayRange(next, maxRangeDays),
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
copy,
|
|
64
|
+
options,
|
|
65
|
+
activeId: activeQuickRangeId(options, value),
|
|
66
|
+
status,
|
|
67
|
+
statusId,
|
|
68
|
+
viewNonce,
|
|
69
|
+
pickQuick: (option) => {
|
|
70
|
+
setViewNonce((nonce) => nonce + 1);
|
|
71
|
+
emit({ from: option.window.from, to: option.window.to }, 'quick', option.id);
|
|
72
|
+
},
|
|
73
|
+
editBound: (which, bound) => {
|
|
74
|
+
setViewNonce((nonce) => nonce + 1);
|
|
75
|
+
emit({ ...value, [which]: bound }, which);
|
|
76
|
+
},
|
|
77
|
+
pickOnCalendar: (range) => {
|
|
78
|
+
emit(
|
|
79
|
+
{
|
|
80
|
+
from: range.start ? toDayString(range.start) : null,
|
|
81
|
+
to: range.end ? toDayString(range.end) : null,
|
|
82
|
+
},
|
|
83
|
+
'calendar',
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# DateRangePicker
|
|
2
|
+
|
|
3
|
+
Three views of ONE day range — a range calendar, a quick-pick column, and two
|
|
4
|
+
typed `dd/mm/aaaa` fields. Change any and the other two follow.
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
|
|
8
|
+
A range calendar alone makes "this quarter" cost two clicks, a month of paging
|
|
9
|
+
and a mental note of which day a quarter starts on. A list of presets alone
|
|
10
|
+
cannot express "the 3rd to the 19th". Typed fields alone make you spell out a
|
|
11
|
+
window you can already see. Each of the three is the fastest route to a
|
|
12
|
+
*different* range, so this control offers all three over one value instead of
|
|
13
|
+
picking a favourite.
|
|
14
|
+
|
|
15
|
+
It is a design-system control and knows nothing about the data behind the
|
|
16
|
+
range. The quick list, the maximum length and the clock "today" is read on are
|
|
17
|
+
all props — a consumer with its own periods passes them rather than teaching
|
|
18
|
+
this component about its domain.
|
|
19
|
+
|
|
20
|
+
## Not a fork of Calendar
|
|
21
|
+
|
|
22
|
+
The calendar half **is** [`Calendar`](../Calendar/Calendar.md) in
|
|
23
|
+
`selectionMode="range"`. The hover preview, the keyboard grid, backwards
|
|
24
|
+
selection and the length check all come from there. This component composes it;
|
|
25
|
+
it does not reimplement it.
|
|
26
|
+
|
|
27
|
+
The typed fields are `DayBoundInput`, the masked `dd/mm/aaaa` field the data
|
|
28
|
+
views already use — imported from inside the package rather than exported,
|
|
29
|
+
because a second masked date field would mean a second set of rules for what
|
|
30
|
+
backspace does.
|
|
31
|
+
|
|
32
|
+
## Public API
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
interface DateRangePickerProps {
|
|
36
|
+
value: DateRangeDraft; // { from: string | null; to: string | null }
|
|
37
|
+
onChange: (next: DateRangeDraft, meta: DateRangeChangeMeta) => void;
|
|
38
|
+
timeZone?: string; // IANA zone (or offset) TODAY is read on
|
|
39
|
+
now?: Date; // freeze the clock (stories, tests)
|
|
40
|
+
maxRangeDays?: number; // longest usable range, inclusive days
|
|
41
|
+
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; // default 0 (Sunday)
|
|
42
|
+
quickRanges?: QuickRange[]; // default: the nine below; [] hides the column
|
|
43
|
+
locale?: string; // month names / weekday initials
|
|
44
|
+
numberOfMonths?: number; // default 2
|
|
45
|
+
messages?: Partial<DateRangePickerMessages>;
|
|
46
|
+
dataTestId?: string; // default 'date-range-picker'
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### The value is a DRAFT, not a window
|
|
52
|
+
|
|
53
|
+
`value` is `{ from, to }` with either end possibly `null`, and it accepts a pair
|
|
54
|
+
that is reversed or too long. A control whose value could only hold a legal
|
|
55
|
+
range has nowhere to put a reader who typed the end date first, and has to
|
|
56
|
+
either discard the keystrokes or lie about what is on screen.
|
|
57
|
+
|
|
58
|
+
Every `onChange` carries the verdict with it:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
interface DateRangeChangeMeta {
|
|
62
|
+
source: 'quick' | 'calendar' | 'from' | 'to';
|
|
63
|
+
quickRangeId?: string; // set only when source is 'quick'
|
|
64
|
+
status: DateRangeStatus; // { ok: true, window, days } | { ok: false, problem, days }
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`resolveDayRange(draft, maxRangeDays)` is exported so a caller's confirm button
|
|
69
|
+
reads the SAME verdict rather than re-deriving "is this usable" from the two
|
|
70
|
+
strings and drifting from the message on screen.
|
|
71
|
+
|
|
72
|
+
`quickRangeId` is what lets a caller express a pick as its own thing. The
|
|
73
|
+
reports surface uses it to apply its real `today` preset when "Hoje" is chosen,
|
|
74
|
+
so the period afterwards reads `Hoje` and not `Personalizado…`.
|
|
75
|
+
|
|
76
|
+
## Quick ranges
|
|
77
|
+
|
|
78
|
+
Nine built-ins, all derivable from today and the calendar alone:
|
|
79
|
+
|
|
80
|
+
| id | window |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `today` | today |
|
|
83
|
+
| `yesterday` | the day before today |
|
|
84
|
+
| `this-week` | the `weekStartsOn` day of this week → today |
|
|
85
|
+
| `last-7-days` | today and the 6 days before it |
|
|
86
|
+
| `this-month` | the 1st of this month → today |
|
|
87
|
+
| `last-30-days` | today and the 29 days before it |
|
|
88
|
+
| `this-quarter` | the 1st of this CALENDAR quarter (Jan/Apr/Jul/Oct) → today |
|
|
89
|
+
| `this-year` | 1 January → today |
|
|
90
|
+
| `last-365-days` | today and the 364 days before it |
|
|
91
|
+
|
|
92
|
+
"Last N days" **includes** today: on the 10th, "last 7 days" is the 4th through
|
|
93
|
+
the 10th. Quarters are calendar quarters; a fiscal quarter is a caller's own
|
|
94
|
+
`quickRanges` entry, not a variant of this one.
|
|
95
|
+
|
|
96
|
+
`createQuickRanges({ today: 'Hoje', … })` relabels the built-ins without
|
|
97
|
+
re-deriving their arithmetic — which is how two lists that claim the same thing
|
|
98
|
+
start disagreeing. `quickRanges` replaces the list wholesale; `[]` removes the
|
|
99
|
+
column.
|
|
100
|
+
|
|
101
|
+
## Time zone
|
|
102
|
+
|
|
103
|
+
Civil days are zone-free; **which day is today is not**. At 21:30 on 31 July in
|
|
104
|
+
São Paulo it is already 1 August in UTC, so "this month" resolved on the host's
|
|
105
|
+
clock can name a period the reader's store has not reached. `timeZone` is read
|
|
106
|
+
for exactly one thing — today — and every quick range hangs off it.
|
|
107
|
+
|
|
108
|
+
Absent, the host's own zone is used. That is right for a picker over the
|
|
109
|
+
reader's own data and wrong for one reporting on somewhere else.
|
|
110
|
+
|
|
111
|
+
## Maximum length
|
|
112
|
+
|
|
113
|
+
`maxRangeDays` is an inclusive day count and is enforced in three places at
|
|
114
|
+
once:
|
|
115
|
+
|
|
116
|
+
- a quick range longer than it is `aria-disabled`, keeps its place in the tab
|
|
117
|
+
order, and renders the reason under its label;
|
|
118
|
+
- the calendar is given the same ceiling, so it cannot close a range over it;
|
|
119
|
+
- a typed pair over it is reported as `problem: 'over-max'` with the reason in
|
|
120
|
+
the status line.
|
|
121
|
+
|
|
122
|
+
**Nothing is ever clamped.** Handing back eleven months for a control labelled
|
|
123
|
+
"This year" is worse than refusing: the reader gets a number for a period they
|
|
124
|
+
did not ask for and nothing on screen says so.
|
|
125
|
+
|
|
126
|
+
## When a typed date commits
|
|
127
|
+
|
|
128
|
+
The moment it is a **whole, real day** — eight digits naming a date that exists.
|
|
129
|
+
|
|
130
|
+
- Not on every keystroke: `2026` passes through `0002`, `0020` and `0202`, each
|
|
131
|
+
a legal year, and committing those sends the calendar to the year 20 while the
|
|
132
|
+
reader is still typing.
|
|
133
|
+
- Not only on blur: someone who types a date and looks at the calendar to check
|
|
134
|
+
it would see the old month until they clicked somewhere else.
|
|
135
|
+
- Half-typed text stays in the field and changes nothing; leaving the field
|
|
136
|
+
discards it and shows what the range actually is.
|
|
137
|
+
- Enter needs no special case — by the time it can be pressed, the date has
|
|
138
|
+
already committed.
|
|
139
|
+
|
|
140
|
+
## Which month the calendar shows
|
|
141
|
+
|
|
142
|
+
`Calendar` reads its opening month from the range it is handed, and only when it
|
|
143
|
+
mounts. The picker remounts it when a **quick pick** or a **typed date** moves
|
|
144
|
+
the range, and never when a day inside the grid is clicked — a calendar that
|
|
145
|
+
jumped under the pointer picking in it would be unusable.
|
|
146
|
+
|
|
147
|
+
So the grid follows the range's **start**. Picking "This year" in August moves
|
|
148
|
+
it to January, because that is where the range now begins.
|
|
149
|
+
|
|
150
|
+
## Layout
|
|
151
|
+
|
|
152
|
+
Calendar first, quick column beside it (`md` and up), fields underneath. Below
|
|
153
|
+
`md` the quick entries wrap and stack under the calendar rather than taking a
|
|
154
|
+
column out of the day grid: at 390px a side column squeezes the grid to about
|
|
155
|
+
half its width and the numbers stop being readable.
|
|
156
|
+
|
|
157
|
+
There is no `size` prop. The day grid has a fixed metric that a size scale would
|
|
158
|
+
not change, and a prop that only altered the padding of the quick entries would
|
|
159
|
+
be a scale in name only.
|
|
160
|
+
|
|
161
|
+
## Accessibility
|
|
162
|
+
|
|
163
|
+
- The quick entries are native `<button>`s with `aria-pressed`; the active one
|
|
164
|
+
is the entry the draft currently equals. (A listbox with `aria-selected` would
|
|
165
|
+
model this too — the rule is to pick one and use it for every entry.)
|
|
166
|
+
- Over-cap entries are `aria-disabled`, **not** `disabled`: a disabled button is
|
|
167
|
+
skipped by the tab order, so the reason under it would be unreachable by
|
|
168
|
+
exactly the reader most likely to need it. They also carry
|
|
169
|
+
`aria-describedby` pointing at that reason.
|
|
170
|
+
- The status line is `role="status" aria-live="polite"`, so a refusal is spoken
|
|
171
|
+
when it appears rather than found by someone who goes looking.
|
|
172
|
+
- Both fields point at that line through `aria-describedby`, and are marked
|
|
173
|
+
`error` when the PAIR is impossible — never merely incomplete, which would
|
|
174
|
+
make the first click of a two-click control look like a mistake.
|
|
175
|
+
- Keyboard: the quick column is buttons; the grid is `Calendar`'s own roving
|
|
176
|
+
focus (arrows, `Home`/`End`, `PageUp`/`PageDown`, Enter/Space).
|
|
177
|
+
|
|
178
|
+
## Copy
|
|
179
|
+
|
|
180
|
+
Every string is in `messages` and defaults to English. The summary prints days
|
|
181
|
+
in the same `dd/mm/aaaa` the fields are masked to rather than in the host
|
|
182
|
+
locale's order: one control cannot show the same date two ways, and an `en-US`
|
|
183
|
+
summary reading `08/05` over a field reading `05/08` leaves the reader to work
|
|
184
|
+
out which is lying. Replace `messages.summary` to choose another order.
|
|
185
|
+
|
|
186
|
+
## Test IDs
|
|
187
|
+
|
|
188
|
+
All derived from `dataTestId` (default `date-range-picker`):
|
|
189
|
+
|
|
190
|
+
| id | element |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `{root}` | the container |
|
|
193
|
+
| `{root}-quick-list` | the quick group |
|
|
194
|
+
| `{root}-quick-{quickRangeId}` | one quick entry |
|
|
195
|
+
| `{root}-from`, `{root}-to` | the two `<input>`s |
|
|
196
|
+
| `{root}-status` | the summary / refusal line |
|
|
197
|
+
|
|
198
|
+
The calendar keeps its own ids (`calendar-container`, `calendar-header`,
|
|
199
|
+
`calendar-date-{day}`). **`calendar-date-{day}` is the day of the month only**,
|
|
200
|
+
so with two months on screen every day appears twice; a test that selects days
|
|
201
|
+
by that id should render `numberOfMonths={1}`.
|
|
202
|
+
|
|
203
|
+
## Usage
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
const [range, setRange] = useState<DateRangeDraft>({ from: null, to: null });
|
|
207
|
+
const [status, setStatus] = useState<DateRangeStatus>(resolveDayRange(range));
|
|
208
|
+
|
|
209
|
+
<DateRangePicker
|
|
210
|
+
value={range}
|
|
211
|
+
onChange={(next, meta) => {
|
|
212
|
+
setRange(next);
|
|
213
|
+
setStatus(meta.status);
|
|
214
|
+
}}
|
|
215
|
+
timeZone="America/Sao_Paulo"
|
|
216
|
+
maxRangeDays={366}
|
|
217
|
+
locale="pt-BR"
|
|
218
|
+
numberOfMonths={1}
|
|
219
|
+
/>
|
|
220
|
+
<Button disabled={!status.ok} onClick={() => status.ok && apply(status.window)}>
|
|
221
|
+
Aplicar
|
|
222
|
+
</Button>
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Related
|
|
226
|
+
|
|
227
|
+
- [`Calendar`](../Calendar/Calendar.md) — the range grid this composes
|
|
228
|
+
- `DataViews` — where the masked `dd/mm/aaaa` field comes from
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The quick-pick column's default list, and how to relabel it.
|
|
3
|
+
*
|
|
4
|
+
* These are DOMAIN-FREE on purpose: every one of them is derivable from "today"
|
|
5
|
+
* and the calendar, so the design system can ship them without knowing what the
|
|
6
|
+
* range is for. A product's own periods (a fiscal quarter, "since the store
|
|
7
|
+
* opened", the last closed month) are a `quickRanges` array the caller passes —
|
|
8
|
+
* the prop exists so that list never has to be smuggled in here.
|
|
9
|
+
*
|
|
10
|
+
* "Last N days" INCLUDES today, which is the reading every dashboard in the
|
|
11
|
+
* wild uses: on the 10th, "last 7 days" is the 4th through the 10th, not the
|
|
12
|
+
* 3rd through the 9th.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
shiftDay,
|
|
16
|
+
startOfMonthDay,
|
|
17
|
+
startOfQuarterDay,
|
|
18
|
+
startOfWeekDay,
|
|
19
|
+
startOfYearDay,
|
|
20
|
+
} from './DateRangePicker.dates';
|
|
21
|
+
import type { QuickRange, QuickRangeContext } from './DateRangePicker.types';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The ids the built-in list uses. Stable API: a caller keys off these to map a
|
|
25
|
+
* quick pick onto something of its own (a stored preset, an event name), and
|
|
26
|
+
* renaming one is a breaking change in a way that renaming a label is not.
|
|
27
|
+
*/
|
|
28
|
+
export const QUICK_RANGE_IDS = [
|
|
29
|
+
'today',
|
|
30
|
+
'yesterday',
|
|
31
|
+
'this-week',
|
|
32
|
+
'last-7-days',
|
|
33
|
+
'this-month',
|
|
34
|
+
'last-30-days',
|
|
35
|
+
'this-quarter',
|
|
36
|
+
'this-year',
|
|
37
|
+
'last-365-days',
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
export type QuickRangeId = (typeof QUICK_RANGE_IDS)[number];
|
|
41
|
+
|
|
42
|
+
/** English defaults. Every one is replaceable through {@link createQuickRanges}. */
|
|
43
|
+
export const DEFAULT_QUICK_RANGE_LABELS: Record<QuickRangeId, string> = {
|
|
44
|
+
today: 'Today',
|
|
45
|
+
yesterday: 'Yesterday',
|
|
46
|
+
'this-week': 'This week',
|
|
47
|
+
'last-7-days': 'Last 7 days',
|
|
48
|
+
'this-month': 'This month',
|
|
49
|
+
'last-30-days': 'Last 30 days',
|
|
50
|
+
'this-quarter': 'This quarter',
|
|
51
|
+
'this-year': 'This year',
|
|
52
|
+
'last-365-days': 'Last 365 days',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** A window that ends today and reaches back `days` days, today included. */
|
|
56
|
+
const trailing =
|
|
57
|
+
(days: number) =>
|
|
58
|
+
({ today }: QuickRangeContext) => ({ from: shiftDay(today, 1 - days), to: today });
|
|
59
|
+
|
|
60
|
+
/** A window from the start of some period through today. */
|
|
61
|
+
const sinceStartOf =
|
|
62
|
+
(start: (context: QuickRangeContext) => string) => (context: QuickRangeContext) => ({
|
|
63
|
+
from: start(context),
|
|
64
|
+
to: context.today,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const RESOLVERS: Record<QuickRangeId, QuickRange['resolve']> = {
|
|
68
|
+
today: trailing(1),
|
|
69
|
+
yesterday: ({ today }) => ({ from: shiftDay(today, -1), to: shiftDay(today, -1) }),
|
|
70
|
+
'this-week': sinceStartOf(({ today, weekStartsOn }) => startOfWeekDay(today, weekStartsOn)),
|
|
71
|
+
'last-7-days': trailing(7),
|
|
72
|
+
'this-month': sinceStartOf(({ today }) => startOfMonthDay(today)),
|
|
73
|
+
'last-30-days': trailing(30),
|
|
74
|
+
'this-quarter': sinceStartOf(({ today }) => startOfQuarterDay(today)),
|
|
75
|
+
'this-year': sinceStartOf(({ today }) => startOfYearDay(today)),
|
|
76
|
+
'last-365-days': trailing(365),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The built-in quick ranges, optionally relabelled.
|
|
81
|
+
*
|
|
82
|
+
* Relabelling rather than rebuilding is the point: a Portuguese host wants
|
|
83
|
+
* "Este mês" over the same month-to-date arithmetic, and re-deriving the dates
|
|
84
|
+
* to get the word is how two lists that claim the same thing start disagreeing.
|
|
85
|
+
*/
|
|
86
|
+
export function createQuickRanges(
|
|
87
|
+
labels: Partial<Record<QuickRangeId, string>> = {},
|
|
88
|
+
): QuickRange[] {
|
|
89
|
+
return QUICK_RANGE_IDS.map((id) => ({
|
|
90
|
+
id,
|
|
91
|
+
label: labels[id] ?? DEFAULT_QUICK_RANGE_LABELS[id],
|
|
92
|
+
resolve: RESOLVERS[id],
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The list a picker uses when the caller says nothing. */
|
|
97
|
+
export const DEFAULT_QUICK_RANGES: QuickRange[] = createQuickRanges();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Judging a draft, and saying so in words.
|
|
3
|
+
*
|
|
4
|
+
* The picker never repairs what it is given. A reversed pair is not silently
|
|
5
|
+
* swapped and an over-long one is not clipped to the cap: both are reported,
|
|
6
|
+
* because a control that returns a different range from the one on screen is
|
|
7
|
+
* indistinguishable from one that ignored you. The caller reads
|
|
8
|
+
* {@link DateRangeStatus} and keeps its own confirm button shut.
|
|
9
|
+
*/
|
|
10
|
+
import { isoToBr } from '../../data-display/DataViews/data-views-day-input';
|
|
11
|
+
|
|
12
|
+
import { dayCount, isAfterDay } from './DateRangePicker.dates';
|
|
13
|
+
import type {
|
|
14
|
+
DateRangePickerMessages,
|
|
15
|
+
DateRangeDraft,
|
|
16
|
+
DateRangeStatus,
|
|
17
|
+
QuickRange,
|
|
18
|
+
QuickRangeContext,
|
|
19
|
+
ResolvedQuickRange,
|
|
20
|
+
} from './DateRangePicker.types';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether a draft is a window this picker will hand over.
|
|
24
|
+
*
|
|
25
|
+
* Exported because the caller needs the same verdict the picker shows: a dialog
|
|
26
|
+
* disabling its "Apply" button must not re-derive "is this legal" from the two
|
|
27
|
+
* strings, or the two answers drift and the button ends up enabled over a
|
|
28
|
+
* message saying the range is impossible.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveDayRange(
|
|
31
|
+
draft: DateRangeDraft,
|
|
32
|
+
maxRangeDays?: number,
|
|
33
|
+
): DateRangeStatus {
|
|
34
|
+
const { from, to } = draft;
|
|
35
|
+
if (!from || !to) return { ok: false, problem: 'incomplete', days: 0 };
|
|
36
|
+
if (isAfterDay(from, to)) return { ok: false, problem: 'reversed', days: 0 };
|
|
37
|
+
|
|
38
|
+
const days = dayCount(from, to);
|
|
39
|
+
if (maxRangeDays !== undefined && days > maxRangeDays) {
|
|
40
|
+
return { ok: false, problem: 'over-max', days };
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, window: { from, to }, days };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The English defaults.
|
|
47
|
+
*
|
|
48
|
+
* The summary prints the days in the SAME `dd/mm/aaaa` the two fields are
|
|
49
|
+
* masked to, rather than in the host locale's order. One control cannot show
|
|
50
|
+
* the same date two ways: an `en-US` host formatting the summary as `08/05`
|
|
51
|
+
* over a field reading `05/08` leaves the reader to work out which of the two
|
|
52
|
+
* is lying. A host that wants its own order replaces `messages.summary`, which
|
|
53
|
+
* moves both halves of the decision to the same place.
|
|
54
|
+
*/
|
|
55
|
+
export function defaultMessages(): DateRangePickerMessages {
|
|
56
|
+
return {
|
|
57
|
+
from: 'Start date',
|
|
58
|
+
to: 'End date',
|
|
59
|
+
quickRanges: 'Quick ranges',
|
|
60
|
+
incomplete: 'Choose both dates.',
|
|
61
|
+
reversed: 'The end date must be on or after the start date.',
|
|
62
|
+
overMax: ({ maxRangeDays }) => `Choose a range of at most ${maxRangeDays} days.`,
|
|
63
|
+
summary: ({ from, to }) => `${isoToBr(from)} – ${isoToBr(to)}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The line under the fields: the chosen window, or why there isn't one. */
|
|
68
|
+
export function statusMessage(
|
|
69
|
+
status: DateRangeStatus,
|
|
70
|
+
messages: DateRangePickerMessages,
|
|
71
|
+
maxRangeDays: number | undefined,
|
|
72
|
+
): string {
|
|
73
|
+
if (status.ok) return messages.summary(status.window);
|
|
74
|
+
if (status.problem === 'incomplete') return messages.incomplete;
|
|
75
|
+
if (status.problem === 'reversed') return messages.reversed;
|
|
76
|
+
return messages.overMax({ maxRangeDays: maxRangeDays ?? status.days, days: status.days });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Every quick range with its window, its length, and whether it is over cap. */
|
|
80
|
+
export function resolveQuickRanges(
|
|
81
|
+
quickRanges: QuickRange[],
|
|
82
|
+
context: QuickRangeContext,
|
|
83
|
+
maxRangeDays: number | undefined,
|
|
84
|
+
): ResolvedQuickRange[] {
|
|
85
|
+
return quickRanges.map((quickRange) => {
|
|
86
|
+
const window = quickRange.resolve(context);
|
|
87
|
+
const days = dayCount(window.from, window.to);
|
|
88
|
+
return {
|
|
89
|
+
...quickRange,
|
|
90
|
+
window,
|
|
91
|
+
days,
|
|
92
|
+
overMax: maxRangeDays !== undefined && days > maxRangeDays,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The quick range the draft currently IS, if it is exactly one of them. */
|
|
98
|
+
export function activeQuickRangeId(
|
|
99
|
+
options: ResolvedQuickRange[],
|
|
100
|
+
draft: DateRangeDraft,
|
|
101
|
+
): string | undefined {
|
|
102
|
+
return options.find(
|
|
103
|
+
(option) => option.window.from === draft.from && option.window.to === draft.to,
|
|
104
|
+
)?.id;
|
|
105
|
+
}
|