panelui-native 0.66.1 → 0.68.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/README.md +21 -1
- package/lib/module/components/area-chart/index.js +18 -1
- package/lib/module/components/area-chart/index.js.map +1 -1
- package/lib/module/components/bar-chart/index.js +18 -1
- package/lib/module/components/bar-chart/index.js.map +1 -1
- package/lib/module/components/candlestick-chart/index.js +18 -1
- package/lib/module/components/candlestick-chart/index.js.map +1 -1
- package/lib/module/components/form/index.js +7 -5
- package/lib/module/components/form/index.js.map +1 -1
- package/lib/module/components/form/typed-form.js +11 -0
- package/lib/module/components/form/typed-form.js.map +1 -0
- package/lib/module/components/form/use-form.js +4 -0
- package/lib/module/components/form/use-form.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +27 -1
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/line-chart/index.js +18 -1
- package/lib/module/components/line-chart/index.js.map +1 -1
- package/lib/module/components/message-scroller/index.js +172 -10
- package/lib/module/components/message-scroller/index.js.map +1 -1
- package/lib/module/components/message-scroller/message-scroller-math.js +23 -0
- package/lib/module/components/message-scroller/message-scroller-math.js.map +1 -1
- package/lib/module/components/meter/index.js +250 -0
- package/lib/module/components/meter/index.js.map +1 -0
- package/lib/module/components/meter/meter-scale.js +88 -0
- package/lib/module/components/meter/meter-scale.js.map +1 -0
- package/lib/module/components/planner/index.js +714 -0
- package/lib/module/components/planner/index.js.map +1 -0
- package/lib/module/components/planner/planner-entries.js +124 -0
- package/lib/module/components/planner/planner-entries.js.map +1 -0
- package/lib/module/components/radar-chart/index.js +18 -1
- package/lib/module/components/radar-chart/index.js.map +1 -1
- package/lib/module/components/scatter-chart/index.js +18 -1
- package/lib/module/components/scatter-chart/index.js.map +1 -1
- package/lib/module/components/skeleton/index.js +45 -7
- package/lib/module/components/skeleton/index.js.map +1 -1
- package/lib/module/components/spinner/index.js +63 -14
- package/lib/module/components/spinner/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +46 -21
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/components/time-picker/ruler-window.js +25 -0
- package/lib/module/components/time-picker/ruler-window.js.map +1 -0
- package/lib/module/hooks/breakpoint-contract.js +29 -0
- package/lib/module/hooks/breakpoint-contract.js.map +1 -0
- package/lib/module/hooks/index.js +1 -1
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-breakpoint.js +46 -1
- package/lib/module/hooks/use-breakpoint.js.map +1 -1
- package/lib/module/index.js +3 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/chart-accessibility.js +68 -0
- package/lib/module/primitives/chart-accessibility.js.map +1 -0
- package/lib/typescript/src/components/area-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bar-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/candlestick-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/form/index.d.ts +18 -5
- package/lib/typescript/src/components/form/index.d.ts.map +1 -1
- package/lib/typescript/src/components/form/typed-form.d.ts +7 -0
- package/lib/typescript/src/components/form/typed-form.d.ts.map +1 -0
- package/lib/typescript/src/components/form/use-form.d.ts +4 -0
- package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/message-scroller/index.d.ts +30 -2
- package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
- package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts +10 -0
- package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts.map +1 -1
- package/lib/typescript/src/components/meter/index.d.ts +270 -0
- package/lib/typescript/src/components/meter/index.d.ts.map +1 -0
- package/lib/typescript/src/components/meter/meter-scale.d.ts +57 -0
- package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/index.d.ts +274 -0
- package/lib/typescript/src/components/planner/index.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/planner-entries.d.ts +75 -0
- package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -0
- package/lib/typescript/src/components/radar-chart/index.d.ts +4 -0
- package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/scatter-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/skeleton/index.d.ts +25 -2
- package/lib/typescript/src/components/skeleton/index.d.ts.map +1 -1
- package/lib/typescript/src/components/spinner/index.d.ts +25 -2
- package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts +5 -6
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/ruler-window.d.ts +12 -0
- package/lib/typescript/src/components/time-picker/ruler-window.d.ts.map +1 -0
- package/lib/typescript/src/hooks/breakpoint-contract.d.ts +6 -0
- package/lib/typescript/src/hooks/breakpoint-contract.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +1 -1
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-breakpoint.d.ts +22 -0
- package/lib/typescript/src/hooks/use-breakpoint.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/chart-accessibility.d.ts +33 -0
- package/lib/typescript/src/primitives/chart-accessibility.d.ts.map +1 -0
- package/lib/typescript/types/form-api.type-test.d.ts +2 -0
- package/lib/typescript/types/form-api.type-test.d.ts.map +1 -0
- package/package.json +28 -2
- package/src/components/area-chart/index.tsx +27 -2
- package/src/components/bar-chart/index.tsx +27 -2
- package/src/components/candlestick-chart/index.tsx +30 -2
- package/src/components/form/index.tsx +32 -5
- package/src/components/form/typed-form.ts +13 -0
- package/src/components/form/use-form.ts +9 -0
- package/src/components/heatmap-chart/index.tsx +49 -3
- package/src/components/line-chart/index.tsx +27 -2
- package/src/components/message-scroller/index.tsx +234 -6
- package/src/components/message-scroller/message-scroller-math.ts +38 -0
- package/src/components/meter/index.tsx +318 -0
- package/src/components/meter/meter-scale.ts +109 -0
- package/src/components/planner/index.tsx +886 -0
- package/src/components/planner/planner-entries.ts +146 -0
- package/src/components/radar-chart/index.tsx +24 -0
- package/src/components/scatter-chart/index.tsx +27 -2
- package/src/components/skeleton/index.tsx +59 -5
- package/src/components/spinner/index.tsx +80 -13
- package/src/components/time-picker/index.tsx +57 -29
- package/src/components/time-picker/ruler-window.ts +24 -0
- package/src/hooks/breakpoint-contract.ts +31 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/use-breakpoint.ts +61 -10
- package/src/index.ts +30 -0
- package/src/primitives/chart-accessibility.ts +102 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planner — a month of days, each carrying what falls on it.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* const [month, setMonth] = useState(new Date());
|
|
6
|
+
*
|
|
7
|
+
* <Planner
|
|
8
|
+
* month={month}
|
|
9
|
+
* onMonthChange={setMonth}
|
|
10
|
+
* entries={renewals}
|
|
11
|
+
* categories={[
|
|
12
|
+
* { id: 'monthly', label: 'Monthly' },
|
|
13
|
+
* { id: 'yearly', label: 'Yearly', colorIndex: 4 },
|
|
14
|
+
* ]}
|
|
15
|
+
* >
|
|
16
|
+
* <Planner.Header>
|
|
17
|
+
* <Planner.Title />
|
|
18
|
+
* <Planner.Today />
|
|
19
|
+
* <Planner.Nav />
|
|
20
|
+
* </Planner.Header>
|
|
21
|
+
* <Planner.Grid />
|
|
22
|
+
* <Planner.Legend />
|
|
23
|
+
* <Planner.Details>
|
|
24
|
+
* {(date, entries) => <Text>{entries.length} on {date.toDateString()}</Text>}
|
|
25
|
+
* </Planner.Details>
|
|
26
|
+
* </Planner>
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* ## How it differs from Calendar
|
|
30
|
+
*
|
|
31
|
+
* `Calendar` picks a date and answers with one. This shows what is already on
|
|
32
|
+
* the days and answers with the day you asked about — the selection exists to
|
|
33
|
+
* open something, not to be submitted.
|
|
34
|
+
*
|
|
35
|
+
* ## Why it draws its own Frame
|
|
36
|
+
*
|
|
37
|
+
* A month at a glance is a widget: a boundary, a strip along the top carrying
|
|
38
|
+
* the month and the way through it, and a footer that holds still while the
|
|
39
|
+
* middle changes. That is `Frame`, so the root renders one instead of leaving
|
|
40
|
+
* every caller to assemble the same shell. Pass `frame={false}` to drop it,
|
|
41
|
+
* for a planner in a sheet or a card that already draws its own edge.
|
|
42
|
+
*
|
|
43
|
+
* ## Why the grid is always six weeks
|
|
44
|
+
*
|
|
45
|
+
* A month can span five weeks or six. Drawn at its natural height the panel
|
|
46
|
+
* jumps as you page through the year and the days appear to move under your
|
|
47
|
+
* thumb, so the grid is always six rows and the last one is sometimes all
|
|
48
|
+
* next month. `Calendar` fixes its height for the same reason.
|
|
49
|
+
*
|
|
50
|
+
* ## Why a day says more than its date
|
|
51
|
+
*
|
|
52
|
+
* The marker on a day is a coloured dot, and colour is a signal that does not
|
|
53
|
+
* reach everyone looking at it. So the legend prints its label beside every
|
|
54
|
+
* swatch, and a day is spoken as its date, how many entries it carries and
|
|
55
|
+
* which categories they belong to. Neither is decoration: between them they
|
|
56
|
+
* are the whole content of the grid for somebody who cannot see it.
|
|
57
|
+
*/
|
|
58
|
+
import {
|
|
59
|
+
Children,
|
|
60
|
+
createContext,
|
|
61
|
+
forwardRef,
|
|
62
|
+
isValidElement,
|
|
63
|
+
useCallback,
|
|
64
|
+
useContext,
|
|
65
|
+
useEffect,
|
|
66
|
+
useMemo,
|
|
67
|
+
useState,
|
|
68
|
+
type ReactElement,
|
|
69
|
+
type ReactNode,
|
|
70
|
+
} from 'react';
|
|
71
|
+
import {
|
|
72
|
+
AccessibilityInfo,
|
|
73
|
+
AppState,
|
|
74
|
+
Pressable,
|
|
75
|
+
View,
|
|
76
|
+
type ViewProps,
|
|
77
|
+
} from 'react-native';
|
|
78
|
+
import { tv } from 'tailwind-variants';
|
|
79
|
+
import { useCSSVariable } from 'uniwind';
|
|
80
|
+
import { ChevronLeftIcon, ChevronRightIcon } from '../../icons';
|
|
81
|
+
import { Text, type TextProps } from '../../primitives/text';
|
|
82
|
+
import { cn } from '../../utils/cn';
|
|
83
|
+
import {
|
|
84
|
+
addCalendarMonths,
|
|
85
|
+
calendarDayNumber,
|
|
86
|
+
calendarLongDate,
|
|
87
|
+
calendarMonthLabel,
|
|
88
|
+
isSameCalendarMonth,
|
|
89
|
+
isSameDay,
|
|
90
|
+
localeWeekStart,
|
|
91
|
+
monthGrid,
|
|
92
|
+
normalizeWeekStart,
|
|
93
|
+
resolveCalendar,
|
|
94
|
+
startOfCalendarMonth,
|
|
95
|
+
startOfDay,
|
|
96
|
+
weekdayNames,
|
|
97
|
+
type CalendarSystem,
|
|
98
|
+
type DateLocale,
|
|
99
|
+
} from '../../utils/date';
|
|
100
|
+
import { Dialog } from '../dialog';
|
|
101
|
+
import { Frame } from '../frame';
|
|
102
|
+
import {
|
|
103
|
+
bucketByDay,
|
|
104
|
+
dayAccessibilityLabel,
|
|
105
|
+
entriesOn,
|
|
106
|
+
summariseMonth,
|
|
107
|
+
visibleEntries,
|
|
108
|
+
type PlannerCountedCategory,
|
|
109
|
+
} from './planner-entries';
|
|
110
|
+
|
|
111
|
+
/** How many of a day's entries a cell draws before it stops and counts. */
|
|
112
|
+
const DEFAULT_ENTRY_LIMIT = 2;
|
|
113
|
+
|
|
114
|
+
/** The palette a category takes its dot from when it does not name a colour. */
|
|
115
|
+
const PALETTE_SIZE = 5;
|
|
116
|
+
|
|
117
|
+
const plannerVariants = tv({
|
|
118
|
+
slots: {
|
|
119
|
+
grid: 'gap-1 px-3 pb-3 pt-1',
|
|
120
|
+
week: 'flex-row',
|
|
121
|
+
heading: 'flex-1 text-center',
|
|
122
|
+
legend: 'flex-row flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3',
|
|
123
|
+
swatch: 'h-2 w-2 rounded-full',
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const dayVariants = tv({
|
|
128
|
+
slots: {
|
|
129
|
+
/*
|
|
130
|
+
* A fixed height, and a square-ish tile rather than a circle.
|
|
131
|
+
*
|
|
132
|
+
* Fixed because the cell has to be the same size whether the day carries
|
|
133
|
+
* anything or not — sized to its contents, a row with one icon in it is
|
|
134
|
+
* taller than the five around it and the grid stops being a grid.
|
|
135
|
+
*
|
|
136
|
+
* Square-ish because a day here holds a number, a marker and sometimes an
|
|
137
|
+
* icon, and a circle wastes the corners it needs for them.
|
|
138
|
+
*/
|
|
139
|
+
cell: 'mx-0.5 h-14 flex-1 rounded-xl px-1.5 pt-1.5',
|
|
140
|
+
number: 'text-xs leading-none',
|
|
141
|
+
marker: 'absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full',
|
|
142
|
+
body: 'flex-1 flex-row items-center justify-center gap-0.5 pb-1',
|
|
143
|
+
},
|
|
144
|
+
variants: {
|
|
145
|
+
/** Inside the month being shown, as opposed to the days either side. */
|
|
146
|
+
inMonth: {
|
|
147
|
+
true: { cell: 'bg-muted/40', number: 'text-foreground' },
|
|
148
|
+
false: { cell: 'bg-muted/15', number: 'text-muted-foreground/40' },
|
|
149
|
+
},
|
|
150
|
+
/*
|
|
151
|
+
* Today is the number, not the tile. A ring on today and a ring on the
|
|
152
|
+
* open day are two rings that mean different things and look the same.
|
|
153
|
+
*/
|
|
154
|
+
today: { true: { number: 'text-primary font-semibold' } },
|
|
155
|
+
selected: { true: { cell: 'border border-primary/60 bg-primary/10' } },
|
|
156
|
+
disabled: { true: { cell: 'opacity-40' } },
|
|
157
|
+
},
|
|
158
|
+
defaultVariants: { inMonth: true },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/* ------------------------------------------------------------------ *
|
|
162
|
+
* Data
|
|
163
|
+
* ------------------------------------------------------------------ */
|
|
164
|
+
|
|
165
|
+
/** One thing that falls on a day. */
|
|
166
|
+
export interface PlannerEntry {
|
|
167
|
+
/** Stable across renders; it keys the cell's contents. */
|
|
168
|
+
id: string;
|
|
169
|
+
/** When it falls. The time of day is kept but not read by the grid. */
|
|
170
|
+
date: Date;
|
|
171
|
+
/** Named in the dialog and, through its category, in the day's spoken label. */
|
|
172
|
+
label: string;
|
|
173
|
+
/** Matches a `PlannerCategory` id. Without one the entry is still counted. */
|
|
174
|
+
category?: string;
|
|
175
|
+
/** Drawn in the cell — a brand mark, an avatar, a glyph. */
|
|
176
|
+
icon?: ReactNode;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A group of entries: the key to a colour, and a line in the legend. */
|
|
180
|
+
export interface PlannerCategory {
|
|
181
|
+
id: string;
|
|
182
|
+
/** Printed beside the swatch, and spoken as part of a day that carries it. */
|
|
183
|
+
label: string;
|
|
184
|
+
/**
|
|
185
|
+
* Which `--color-chart-*` token the dot takes, 1 to 5. Categories without
|
|
186
|
+
* one are numbered in the order they are declared.
|
|
187
|
+
*/
|
|
188
|
+
colorIndex?: number;
|
|
189
|
+
/** An explicit colour, for a brand that is not the theme's to choose. */
|
|
190
|
+
color?: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface PlannerContextValue {
|
|
194
|
+
month: Date;
|
|
195
|
+
setMonth: (month: Date) => void;
|
|
196
|
+
today: Date;
|
|
197
|
+
selected: Date | null;
|
|
198
|
+
select: (date: Date | null) => void;
|
|
199
|
+
days: Map<number, PlannerEntry[]>;
|
|
200
|
+
categories: PlannerCategory[];
|
|
201
|
+
categoryLabels: Map<string, string>;
|
|
202
|
+
colorOf: (category: string | undefined) => string | undefined;
|
|
203
|
+
summary: { total: number; categories: PlannerCountedCategory[] };
|
|
204
|
+
entryLimit: number;
|
|
205
|
+
weekStartsOn: number;
|
|
206
|
+
locale: DateLocale;
|
|
207
|
+
system: 'gregory' | 'islamic';
|
|
208
|
+
isInMonth: (date: Date) => boolean;
|
|
209
|
+
renderDay?: PlannerDayRenderer;
|
|
210
|
+
onDayPress?: (date: Date, entries: PlannerEntry[]) => void;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const PlannerContext = createContext<PlannerContextValue | null>(null);
|
|
214
|
+
|
|
215
|
+
function usePlanner(component: string): PlannerContextValue {
|
|
216
|
+
const context = useContext(PlannerContext);
|
|
217
|
+
if (!context) {
|
|
218
|
+
throw new Error(`${component} must be used within a <Planner>`);
|
|
219
|
+
}
|
|
220
|
+
return context;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Today, recomputed when the app comes back rather than only at mount. */
|
|
224
|
+
function useToday(): Date {
|
|
225
|
+
const [today, setToday] = useState(() => startOfDay(new Date()));
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
const refresh = () => {
|
|
229
|
+
const now = startOfDay(new Date());
|
|
230
|
+
// A new object every resume would invalidate every memo below it for a
|
|
231
|
+
// date that has not changed.
|
|
232
|
+
setToday((current) => (current.getTime() === now.getTime() ? current : now));
|
|
233
|
+
};
|
|
234
|
+
const subscription = AppState.addEventListener('change', (state) => {
|
|
235
|
+
if (state === 'active') refresh();
|
|
236
|
+
});
|
|
237
|
+
return () => subscription.remove();
|
|
238
|
+
}, []);
|
|
239
|
+
|
|
240
|
+
return today;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The five series colours, read once.
|
|
245
|
+
*
|
|
246
|
+
* A fixed number of reads rather than one per category: a hook cannot run in a
|
|
247
|
+
* loop whose length is a prop.
|
|
248
|
+
*/
|
|
249
|
+
function usePalette(): (string | undefined)[] {
|
|
250
|
+
const one = useCSSVariable('--color-chart-1');
|
|
251
|
+
const two = useCSSVariable('--color-chart-2');
|
|
252
|
+
const three = useCSSVariable('--color-chart-3');
|
|
253
|
+
const four = useCSSVariable('--color-chart-4');
|
|
254
|
+
const five = useCSSVariable('--color-chart-5');
|
|
255
|
+
return useMemo(
|
|
256
|
+
() => [one, two, three, four, five].map((v) => (typeof v === 'string' ? v : undefined)),
|
|
257
|
+
[one, two, three, four, five]
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ------------------------------------------------------------------ *
|
|
262
|
+
* Root
|
|
263
|
+
* ------------------------------------------------------------------ */
|
|
264
|
+
|
|
265
|
+
export interface PlannerProps extends Omit<ViewProps, 'children'> {
|
|
266
|
+
className?: string;
|
|
267
|
+
/** The month on show. Leave it out for an uncontrolled planner. */
|
|
268
|
+
month?: Date;
|
|
269
|
+
defaultMonth?: Date;
|
|
270
|
+
onMonthChange?: (month: Date) => void;
|
|
271
|
+
/** Everything the planner knows about, in any order and any month. */
|
|
272
|
+
entries?: PlannerEntry[];
|
|
273
|
+
/** The colour key. Declaration order is legend order and palette order. */
|
|
274
|
+
categories?: PlannerCategory[];
|
|
275
|
+
/** The open day. `null` is none. Leave it out for an uncontrolled planner. */
|
|
276
|
+
selected?: Date | null;
|
|
277
|
+
defaultSelected?: Date | null;
|
|
278
|
+
onSelectedChange?: (date: Date | null) => void;
|
|
279
|
+
/** Runs before the selection moves, whether or not `Details` is present. */
|
|
280
|
+
onDayPress?: (date: Date, entries: PlannerEntry[]) => void;
|
|
281
|
+
/** How many entries a cell draws before it counts the rest. Default `2`. */
|
|
282
|
+
entryLimit?: number;
|
|
283
|
+
/** First day of the week, 0 is Sunday. Defaults to the locale's. */
|
|
284
|
+
weekStartsOn?: number | 'auto';
|
|
285
|
+
locale?: DateLocale;
|
|
286
|
+
calendar?: CalendarSystem;
|
|
287
|
+
/** Draw the surrounding `Frame`. Off for a planner in a sheet or a card. */
|
|
288
|
+
frame?: boolean;
|
|
289
|
+
children?: ReactNode;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const PlannerRoot = forwardRef<View, PlannerProps>(
|
|
293
|
+
(
|
|
294
|
+
{
|
|
295
|
+
className,
|
|
296
|
+
month: monthProp,
|
|
297
|
+
defaultMonth,
|
|
298
|
+
onMonthChange,
|
|
299
|
+
entries = [],
|
|
300
|
+
categories = [],
|
|
301
|
+
selected: selectedProp,
|
|
302
|
+
defaultSelected = null,
|
|
303
|
+
onSelectedChange,
|
|
304
|
+
onDayPress,
|
|
305
|
+
entryLimit = DEFAULT_ENTRY_LIMIT,
|
|
306
|
+
weekStartsOn = 'auto',
|
|
307
|
+
locale,
|
|
308
|
+
calendar = 'gregory',
|
|
309
|
+
frame = true,
|
|
310
|
+
children,
|
|
311
|
+
...props
|
|
312
|
+
},
|
|
313
|
+
ref
|
|
314
|
+
) => {
|
|
315
|
+
const system = resolveCalendar(calendar, locale);
|
|
316
|
+
const palette = usePalette();
|
|
317
|
+
const today = useToday();
|
|
318
|
+
|
|
319
|
+
const [internalMonth, setInternalMonth] = useState(() =>
|
|
320
|
+
startOfCalendarMonth(defaultMonth ?? new Date(), system, locale)
|
|
321
|
+
);
|
|
322
|
+
const month = monthProp
|
|
323
|
+
? startOfCalendarMonth(monthProp, system, locale)
|
|
324
|
+
: internalMonth;
|
|
325
|
+
|
|
326
|
+
const setMonth = useCallback(
|
|
327
|
+
(next: Date) => {
|
|
328
|
+
const settled = startOfCalendarMonth(next, system, locale);
|
|
329
|
+
if (!monthProp) setInternalMonth(settled);
|
|
330
|
+
onMonthChange?.(settled);
|
|
331
|
+
},
|
|
332
|
+
[monthProp, onMonthChange, system, locale]
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const [internalSelected, setInternalSelected] = useState<Date | null>(defaultSelected);
|
|
336
|
+
const selected = selectedProp !== undefined ? selectedProp : internalSelected;
|
|
337
|
+
const select = useCallback(
|
|
338
|
+
(date: Date | null) => {
|
|
339
|
+
if (selectedProp === undefined) setInternalSelected(date);
|
|
340
|
+
onSelectedChange?.(date);
|
|
341
|
+
},
|
|
342
|
+
[selectedProp, onSelectedChange]
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const days = useMemo(() => bucketByDay(entries), [entries]);
|
|
346
|
+
|
|
347
|
+
const isInMonth = useCallback(
|
|
348
|
+
(date: Date) => isSameCalendarMonth(date, month, system, locale),
|
|
349
|
+
[month, system, locale]
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const categoryLabels = useMemo(
|
|
353
|
+
() => new Map(categories.map((category) => [category.id, category.label])),
|
|
354
|
+
[categories]
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const colorOf = useCallback(
|
|
358
|
+
(id: string | undefined) => {
|
|
359
|
+
if (!id) return undefined;
|
|
360
|
+
const index = categories.findIndex((category) => category.id === id);
|
|
361
|
+
if (index < 0) return undefined;
|
|
362
|
+
const category = categories[index]!;
|
|
363
|
+
if (category.color) return category.color;
|
|
364
|
+
const slot = (category.colorIndex ?? index + 1) - 1;
|
|
365
|
+
return palette[((slot % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE];
|
|
366
|
+
},
|
|
367
|
+
[categories, palette]
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const summary = useMemo(
|
|
371
|
+
() => summariseMonth(entries, categories, isInMonth),
|
|
372
|
+
[entries, categories, isInMonth]
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
/*
|
|
376
|
+
* The month is announced rather than left to the cells. Paging moves 42
|
|
377
|
+
* labels at once and a screen reader reads none of them, so without this
|
|
378
|
+
* the only thing that changes is silent.
|
|
379
|
+
*/
|
|
380
|
+
const monthLabel = calendarMonthLabel(month, system, locale);
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
AccessibilityInfo.announceForAccessibility(monthLabel);
|
|
383
|
+
}, [monthLabel]);
|
|
384
|
+
|
|
385
|
+
const context = useMemo<PlannerContextValue>(
|
|
386
|
+
() => ({
|
|
387
|
+
month,
|
|
388
|
+
setMonth,
|
|
389
|
+
today,
|
|
390
|
+
selected,
|
|
391
|
+
select,
|
|
392
|
+
days,
|
|
393
|
+
categories,
|
|
394
|
+
categoryLabels,
|
|
395
|
+
colorOf,
|
|
396
|
+
summary,
|
|
397
|
+
entryLimit,
|
|
398
|
+
weekStartsOn:
|
|
399
|
+
weekStartsOn === 'auto'
|
|
400
|
+
? localeWeekStart(locale)
|
|
401
|
+
: normalizeWeekStart(weekStartsOn),
|
|
402
|
+
locale,
|
|
403
|
+
system,
|
|
404
|
+
isInMonth,
|
|
405
|
+
onDayPress,
|
|
406
|
+
}),
|
|
407
|
+
[
|
|
408
|
+
month, setMonth, today, selected, select, days, categories, categoryLabels,
|
|
409
|
+
colorOf, summary, entryLimit, weekStartsOn, locale, system, isInMonth, onDayPress,
|
|
410
|
+
]
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
/*
|
|
414
|
+
* The header belongs in the Frame's strip and everything else in its
|
|
415
|
+
* panel, so the root sorts its children rather than asking the caller to
|
|
416
|
+
* nest them in two places to get one widget.
|
|
417
|
+
*/
|
|
418
|
+
const parts = Children.toArray(children);
|
|
419
|
+
const header = parts.find(
|
|
420
|
+
(child) => isValidElement(child) && child.type === PlannerHeader
|
|
421
|
+
);
|
|
422
|
+
const body = parts.filter((child) => child !== header);
|
|
423
|
+
|
|
424
|
+
return (
|
|
425
|
+
<PlannerContext.Provider value={context}>
|
|
426
|
+
{frame ? (
|
|
427
|
+
// Full width by default: the grid is seven equal columns, and a
|
|
428
|
+
// container that sizes to its content collapses them to the width of
|
|
429
|
+
// a two-digit number.
|
|
430
|
+
<Frame ref={ref} className={cn('w-full', className)} {...props}>
|
|
431
|
+
{header}
|
|
432
|
+
<Frame.Panel dividers={false}>{body}</Frame.Panel>
|
|
433
|
+
</Frame>
|
|
434
|
+
) : (
|
|
435
|
+
<View ref={ref} className={cn('w-full', className)} {...props}>
|
|
436
|
+
{header}
|
|
437
|
+
{body}
|
|
438
|
+
</View>
|
|
439
|
+
)}
|
|
440
|
+
</PlannerContext.Provider>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
);
|
|
444
|
+
PlannerRoot.displayName = 'Planner';
|
|
445
|
+
|
|
446
|
+
/* ------------------------------------------------------------------ *
|
|
447
|
+
* Header strip
|
|
448
|
+
* ------------------------------------------------------------------ */
|
|
449
|
+
|
|
450
|
+
export interface PlannerHeaderProps {
|
|
451
|
+
children?: ReactNode;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The strip along the top of the frame. Put `Title`, `Today` and `Nav` in it.
|
|
456
|
+
*
|
|
457
|
+
* The strip has two ends rather than an even spread. What the month *is* —
|
|
458
|
+
* its name, and the way back to today — reads from the leading edge; what
|
|
459
|
+
* *moves* it sits at the trailing edge, under the thumb that reaches for it.
|
|
460
|
+
* Spaced evenly across a full-width strip they read as three unrelated
|
|
461
|
+
* controls instead of a label and a pair of buttons.
|
|
462
|
+
*/
|
|
463
|
+
function PlannerHeader({ children }: PlannerHeaderProps) {
|
|
464
|
+
const parts = Children.toArray(children);
|
|
465
|
+
const trails = (child: ReactNode) =>
|
|
466
|
+
isValidElement(child) && (child.type === PlannerNav || child.type === PlannerAction);
|
|
467
|
+
|
|
468
|
+
const trailing = parts.filter(trails);
|
|
469
|
+
const lead = parts.filter((child) => !trails(child));
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<Frame.Header>
|
|
473
|
+
<View className="min-w-0 flex-1 flex-row items-center gap-2">{lead}</View>
|
|
474
|
+
{trailing.length > 0 ? (
|
|
475
|
+
<View className="shrink-0 flex-row items-center gap-1.5">{trailing}</View>
|
|
476
|
+
) : null}
|
|
477
|
+
</Frame.Header>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
PlannerHeader.displayName = 'Planner.Header';
|
|
481
|
+
|
|
482
|
+
export interface PlannerTitleProps extends Omit<TextProps, 'children'> {
|
|
483
|
+
/** Replaces the month name, for a title that says something else. */
|
|
484
|
+
children?: ReactNode;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** The month on show, in the calendar system and locale the grid uses. */
|
|
488
|
+
function PlannerTitle({ children, className, ...props }: PlannerTitleProps) {
|
|
489
|
+
const { month, system, locale } = usePlanner('Planner.Title');
|
|
490
|
+
return (
|
|
491
|
+
<Text weight="medium" className={cn('text-base', className)} {...props}>
|
|
492
|
+
{children ?? calendarMonthLabel(month, system, locale)}
|
|
493
|
+
</Text>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
PlannerTitle.displayName = 'Planner.Title';
|
|
497
|
+
|
|
498
|
+
export interface PlannerTodayProps {
|
|
499
|
+
/** Replaces the word on the pill. */
|
|
500
|
+
children?: ReactNode;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Jumps back to the month today is in, and selects nothing. */
|
|
504
|
+
function PlannerToday({ children = 'Today' }: PlannerTodayProps) {
|
|
505
|
+
const { today, setMonth, month, system, locale } = usePlanner('Planner.Today');
|
|
506
|
+
const alreadyHere = isSameCalendarMonth(today, month, system, locale);
|
|
507
|
+
|
|
508
|
+
return (
|
|
509
|
+
<Pressable
|
|
510
|
+
onPress={() => setMonth(today)}
|
|
511
|
+
disabled={alreadyHere}
|
|
512
|
+
hitSlop={6}
|
|
513
|
+
accessibilityRole="button"
|
|
514
|
+
accessibilityLabel="Go to this month"
|
|
515
|
+
accessibilityState={{ disabled: alreadyHere }}
|
|
516
|
+
className={cn(
|
|
517
|
+
'rounded-full border border-border px-3 py-1',
|
|
518
|
+
alreadyHere ? 'opacity-40' : 'active:bg-accent'
|
|
519
|
+
)}
|
|
520
|
+
>
|
|
521
|
+
<Text size="xs" muted>
|
|
522
|
+
{children}
|
|
523
|
+
</Text>
|
|
524
|
+
</Pressable>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
PlannerToday.displayName = 'Planner.Today';
|
|
528
|
+
|
|
529
|
+
export interface PlannerNavProps {
|
|
530
|
+
className?: string;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Back and forward a month. */
|
|
534
|
+
function PlannerNav({ className }: PlannerNavProps) {
|
|
535
|
+
const { month, setMonth, system, locale } = usePlanner('Planner.Nav');
|
|
536
|
+
|
|
537
|
+
return (
|
|
538
|
+
<View className={cn('flex-row items-center gap-1', className)}>
|
|
539
|
+
<PlannerArrow
|
|
540
|
+
direction="previous"
|
|
541
|
+
onPress={() => setMonth(addCalendarMonths(month, -1, system, locale))}
|
|
542
|
+
/>
|
|
543
|
+
<PlannerArrow
|
|
544
|
+
direction="next"
|
|
545
|
+
onPress={() => setMonth(addCalendarMonths(month, 1, system, locale))}
|
|
546
|
+
/>
|
|
547
|
+
</View>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
PlannerNav.displayName = 'Planner.Nav';
|
|
551
|
+
|
|
552
|
+
function PlannerArrow({
|
|
553
|
+
direction,
|
|
554
|
+
onPress,
|
|
555
|
+
}: {
|
|
556
|
+
direction: 'previous' | 'next';
|
|
557
|
+
onPress: () => void;
|
|
558
|
+
}) {
|
|
559
|
+
return (
|
|
560
|
+
<Pressable
|
|
561
|
+
onPress={onPress}
|
|
562
|
+
hitSlop={8}
|
|
563
|
+
accessibilityRole="button"
|
|
564
|
+
accessibilityLabel={direction === 'next' ? 'Next month' : 'Previous month'}
|
|
565
|
+
className="h-8 w-8 items-center justify-center rounded-lg active:bg-accent"
|
|
566
|
+
>
|
|
567
|
+
{direction === 'next' ? <ChevronRightIcon size={18} /> : <ChevronLeftIcon size={18} />}
|
|
568
|
+
</Pressable>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export interface PlannerActionProps {
|
|
573
|
+
children?: ReactNode;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** The trailing end of the header strip, for a button of the caller's. */
|
|
577
|
+
function PlannerAction({ children }: PlannerActionProps) {
|
|
578
|
+
return <Frame.Action>{children}</Frame.Action>;
|
|
579
|
+
}
|
|
580
|
+
PlannerAction.displayName = 'Planner.Action';
|
|
581
|
+
|
|
582
|
+
/* ------------------------------------------------------------------ *
|
|
583
|
+
* Grid
|
|
584
|
+
* ------------------------------------------------------------------ */
|
|
585
|
+
|
|
586
|
+
/** What a custom cell is handed. Everything the default cell draws from. */
|
|
587
|
+
export interface PlannerDayState {
|
|
588
|
+
date: Date;
|
|
589
|
+
entries: PlannerEntry[];
|
|
590
|
+
isToday: boolean;
|
|
591
|
+
isSelected: boolean;
|
|
592
|
+
isInMonth: boolean;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export type PlannerDayRenderer = (state: PlannerDayState) => ReactNode;
|
|
596
|
+
|
|
597
|
+
export interface PlannerGridProps {
|
|
598
|
+
className?: string;
|
|
599
|
+
/** Draws a cell yourself. It is handed the day and what falls on it. */
|
|
600
|
+
renderDay?: PlannerDayRenderer;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The weekday row and the six weeks below it.
|
|
605
|
+
*
|
|
606
|
+
* React Native has no per-cell grid vocabulary — no `gridcell`, no `row` — so
|
|
607
|
+
* a screen reader never hears "row three, column five" and cannot fall back on
|
|
608
|
+
* position for context. Every day therefore carries its own full date, and the
|
|
609
|
+
* weekday headings are hidden rather than read out 42 times over.
|
|
610
|
+
*/
|
|
611
|
+
function PlannerGrid({ className, renderDay }: PlannerGridProps) {
|
|
612
|
+
const { month, weekStartsOn, locale, system } = usePlanner('Planner.Grid');
|
|
613
|
+
const { grid, week: weekRow, heading } = plannerVariants();
|
|
614
|
+
|
|
615
|
+
const weeks = useMemo(
|
|
616
|
+
() => monthGrid(month, weekStartsOn, system, locale),
|
|
617
|
+
[month, weekStartsOn, system, locale]
|
|
618
|
+
);
|
|
619
|
+
const headings = useMemo(
|
|
620
|
+
() => weekdayNames(locale, weekStartsOn),
|
|
621
|
+
[locale, weekStartsOn]
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
return (
|
|
625
|
+
<View className={grid({ className })}>
|
|
626
|
+
<View
|
|
627
|
+
className={weekRow()}
|
|
628
|
+
accessibilityElementsHidden
|
|
629
|
+
importantForAccessibility="no-hide-descendants"
|
|
630
|
+
>
|
|
631
|
+
{headings.map((label) => (
|
|
632
|
+
<Text key={label} size="xs" muted className={heading()}>
|
|
633
|
+
{label.toUpperCase()}
|
|
634
|
+
</Text>
|
|
635
|
+
))}
|
|
636
|
+
</View>
|
|
637
|
+
|
|
638
|
+
{weeks.map((week, index) => (
|
|
639
|
+
<View key={index} className={weekRow()}>
|
|
640
|
+
{week.map((date) => (
|
|
641
|
+
<PlannerDay key={date.getTime()} date={date} renderDay={renderDay} />
|
|
642
|
+
))}
|
|
643
|
+
</View>
|
|
644
|
+
))}
|
|
645
|
+
</View>
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
PlannerGrid.displayName = 'Planner.Grid';
|
|
649
|
+
|
|
650
|
+
export interface PlannerDayProps {
|
|
651
|
+
date: Date;
|
|
652
|
+
renderDay?: PlannerDayRenderer;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** One cell. Pressing it selects the day and opens whatever is bound to it. */
|
|
656
|
+
function PlannerDay({ date, renderDay }: PlannerDayProps) {
|
|
657
|
+
const {
|
|
658
|
+
days, today, selected, select, colorOf, categoryLabels,
|
|
659
|
+
entryLimit, locale, system, isInMonth, onDayPress,
|
|
660
|
+
} = usePlanner('Planner.Day');
|
|
661
|
+
|
|
662
|
+
const entries = entriesOn(days, date);
|
|
663
|
+
const inMonth = isInMonth(date);
|
|
664
|
+
const isToday = isSameDay(date, today);
|
|
665
|
+
const isSelected = selected ? isSameDay(date, selected) : false;
|
|
666
|
+
const { shown, overflow } = visibleEntries(entries, entryLimit);
|
|
667
|
+
const drawable = shown.filter((entry) => entry.icon);
|
|
668
|
+
const styles = dayVariants({ inMonth, today: isToday, selected: isSelected });
|
|
669
|
+
|
|
670
|
+
const label = dayAccessibilityLabel(
|
|
671
|
+
calendarLongDate(date, system, locale),
|
|
672
|
+
entries,
|
|
673
|
+
categoryLabels
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const press = () => {
|
|
677
|
+
onDayPress?.(date, entries);
|
|
678
|
+
select(date);
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
if (renderDay) {
|
|
682
|
+
return (
|
|
683
|
+
<View className="flex-1">
|
|
684
|
+
{renderDay({ date, entries, isToday, isSelected, isInMonth: inMonth })}
|
|
685
|
+
</View>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return (
|
|
690
|
+
<Pressable
|
|
691
|
+
onPress={press}
|
|
692
|
+
accessibilityRole="button"
|
|
693
|
+
accessibilityLabel={label}
|
|
694
|
+
accessibilityHint={entries.length > 0 ? 'Opens what is on this day' : undefined}
|
|
695
|
+
accessibilityState={{ selected: isSelected }}
|
|
696
|
+
className={styles.cell()}
|
|
697
|
+
>
|
|
698
|
+
<Text className={styles.number()}>{calendarDayNumber(date, system, locale)}</Text>
|
|
699
|
+
|
|
700
|
+
{entries.length > 0 ? (
|
|
701
|
+
<View className={styles.marker()} style={{ backgroundColor: markerColor(entries, colorOf) }} />
|
|
702
|
+
) : null}
|
|
703
|
+
|
|
704
|
+
{/*
|
|
705
|
+
Only entries that brought an icon are drawn, and the overflow count
|
|
706
|
+
goes with them. A day whose entries have no icons is already saying
|
|
707
|
+
"something is here" with its marker; adding a bare "+3" under an empty
|
|
708
|
+
row says it twice, in a way that looks like a stray number.
|
|
709
|
+
*/}
|
|
710
|
+
{drawable.length > 0 ? (
|
|
711
|
+
<View className={styles.body()}>
|
|
712
|
+
{drawable.map((entry) => (
|
|
713
|
+
<View key={entry.id}>{entry.icon}</View>
|
|
714
|
+
))}
|
|
715
|
+
{overflow > 0 ? (
|
|
716
|
+
<Text size="xs" muted className="leading-none">
|
|
717
|
+
+{overflow}
|
|
718
|
+
</Text>
|
|
719
|
+
) : null}
|
|
720
|
+
</View>
|
|
721
|
+
) : null}
|
|
722
|
+
</Pressable>
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
PlannerDay.displayName = 'Planner.Day';
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* The dot's colour: the first entry that names a category the planner knows.
|
|
729
|
+
*
|
|
730
|
+
* One dot rather than one per entry. A cell this size fits a row of dots or a
|
|
731
|
+
* day number, and the count is already spoken — so the dot answers "is there
|
|
732
|
+
* anything here, and roughly what kind", which is all it has room to answer.
|
|
733
|
+
*/
|
|
734
|
+
function markerColor(
|
|
735
|
+
entries: readonly PlannerEntry[],
|
|
736
|
+
colorOf: (category: string | undefined) => string | undefined
|
|
737
|
+
): string | undefined {
|
|
738
|
+
for (const entry of entries) {
|
|
739
|
+
const color = colorOf(entry.category);
|
|
740
|
+
if (color) return color;
|
|
741
|
+
}
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/* ------------------------------------------------------------------ *
|
|
746
|
+
* Legend and summary
|
|
747
|
+
* ------------------------------------------------------------------ */
|
|
748
|
+
|
|
749
|
+
export interface PlannerLegendProps {
|
|
750
|
+
className?: string;
|
|
751
|
+
/** Print each category's count for the month beside its label. */
|
|
752
|
+
counts?: boolean;
|
|
753
|
+
/** Sits at the trailing end — a total, a currency, whatever the month adds to. */
|
|
754
|
+
children?: ReactNode;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The key to the dots.
|
|
759
|
+
*
|
|
760
|
+
* It prints the label beside every swatch, because a column of coloured dots
|
|
761
|
+
* with nothing to read them against is a quiz.
|
|
762
|
+
*/
|
|
763
|
+
function PlannerLegend({ className, counts = false, children }: PlannerLegendProps) {
|
|
764
|
+
const { summary, colorOf } = usePlanner('Planner.Legend');
|
|
765
|
+
const { legend, swatch } = plannerVariants();
|
|
766
|
+
|
|
767
|
+
return (
|
|
768
|
+
<View className={cn(legend(), 'justify-between border-t border-border', className)}>
|
|
769
|
+
<View className="flex-row flex-wrap items-center gap-x-4 gap-y-2">
|
|
770
|
+
{summary.categories.map((category) => (
|
|
771
|
+
<View key={category.id} className="flex-row items-center gap-1.5">
|
|
772
|
+
<View
|
|
773
|
+
className={swatch()}
|
|
774
|
+
style={{ backgroundColor: colorOf(category.id) }}
|
|
775
|
+
/>
|
|
776
|
+
<Text size="xs" muted>
|
|
777
|
+
{counts ? `${category.label} ${category.count}` : category.label}
|
|
778
|
+
</Text>
|
|
779
|
+
</View>
|
|
780
|
+
))}
|
|
781
|
+
</View>
|
|
782
|
+
{children}
|
|
783
|
+
</View>
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
PlannerLegend.displayName = 'Planner.Legend';
|
|
787
|
+
|
|
788
|
+
export interface PlannerSummaryProps extends Omit<TextProps, 'children'> {
|
|
789
|
+
/** Replaces the count, for a total that is money rather than entries. */
|
|
790
|
+
children?: ReactNode;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** What the month adds up to. Counts this month only, never the days either side. */
|
|
794
|
+
function PlannerSummary({ children, ...props }: PlannerSummaryProps) {
|
|
795
|
+
const { summary } = usePlanner('Planner.Summary');
|
|
796
|
+
return (
|
|
797
|
+
<Text size="xs" muted {...props}>
|
|
798
|
+
{children ?? `${summary.total} ${summary.total === 1 ? 'entry' : 'entries'}`}
|
|
799
|
+
</Text>
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
PlannerSummary.displayName = 'Planner.Summary';
|
|
803
|
+
|
|
804
|
+
export interface PlannerFooterProps {
|
|
805
|
+
className?: string;
|
|
806
|
+
children?: ReactNode;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** The strip along the bottom, for tools that act on the month. */
|
|
810
|
+
function PlannerFooter({ className, children }: PlannerFooterProps) {
|
|
811
|
+
return (
|
|
812
|
+
<View className={cn('flex-row items-center justify-between gap-3 px-4 py-3', className)}>
|
|
813
|
+
{children}
|
|
814
|
+
</View>
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
PlannerFooter.displayName = 'Planner.Footer';
|
|
818
|
+
|
|
819
|
+
/* ------------------------------------------------------------------ *
|
|
820
|
+
* Details
|
|
821
|
+
* ------------------------------------------------------------------ */
|
|
822
|
+
|
|
823
|
+
export interface PlannerDetailsProps {
|
|
824
|
+
className?: string;
|
|
825
|
+
/** Title above the children. Defaults to the day's full date. */
|
|
826
|
+
title?: ReactNode;
|
|
827
|
+
/**
|
|
828
|
+
* The line under the title. Defaults to how many entries the day carries,
|
|
829
|
+
* so the dialog answers "how much of this is there" before it is read.
|
|
830
|
+
* Pass `null` to drop it.
|
|
831
|
+
*/
|
|
832
|
+
description?: ReactNode;
|
|
833
|
+
/** Given the open day and what falls on it. */
|
|
834
|
+
children: (date: Date, entries: PlannerEntry[]) => ReactNode;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* A dialog bound to the open day.
|
|
839
|
+
*
|
|
840
|
+
* The planner owns the grid and the binding; what the dialog says is the
|
|
841
|
+
* application's, because the contents of a day are its data and not the
|
|
842
|
+
* component's. Leave it out and `onDayPress` is still called — a planner that
|
|
843
|
+
* pushes a screen instead of opening a dialog wants that and nothing else.
|
|
844
|
+
*/
|
|
845
|
+
function PlannerDetails({ className, title, description, children }: PlannerDetailsProps) {
|
|
846
|
+
const { selected, select, days, system, locale } = usePlanner('Planner.Details');
|
|
847
|
+
const entries = selected ? entriesOn(days, selected) : [];
|
|
848
|
+
|
|
849
|
+
const count =
|
|
850
|
+
entries.length === 1 ? '1 entry' : `${entries.length} entries`;
|
|
851
|
+
|
|
852
|
+
return (
|
|
853
|
+
<Dialog open={selected !== null} onOpenChange={(open) => !open && select(null)}>
|
|
854
|
+
<Dialog.Content className={cn('gap-3', className)}>
|
|
855
|
+
<View className="gap-0.5">
|
|
856
|
+
<Dialog.Title>
|
|
857
|
+
{title ?? (selected ? calendarLongDate(selected, system, locale) : '')}
|
|
858
|
+
</Dialog.Title>
|
|
859
|
+
{description === null ? null : (
|
|
860
|
+
<Dialog.Description>
|
|
861
|
+
{description ?? (entries.length === 0 ? 'Nothing planned' : count)}
|
|
862
|
+
</Dialog.Description>
|
|
863
|
+
)}
|
|
864
|
+
</View>
|
|
865
|
+
{selected ? children(selected, entries) : null}
|
|
866
|
+
</Dialog.Content>
|
|
867
|
+
</Dialog>
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
PlannerDetails.displayName = 'Planner.Details';
|
|
871
|
+
|
|
872
|
+
export const Planner = Object.assign(PlannerRoot, {
|
|
873
|
+
Header: PlannerHeader,
|
|
874
|
+
Title: PlannerTitle,
|
|
875
|
+
Today: PlannerToday,
|
|
876
|
+
Nav: PlannerNav,
|
|
877
|
+
Action: PlannerAction,
|
|
878
|
+
Grid: PlannerGrid,
|
|
879
|
+
Day: PlannerDay,
|
|
880
|
+
Legend: PlannerLegend,
|
|
881
|
+
Summary: PlannerSummary,
|
|
882
|
+
Footer: PlannerFooter,
|
|
883
|
+
Details: PlannerDetails,
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
export type { PlannerCountedCategory };
|