panelui-native 0.30.0 → 0.34.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 +4 -2
- package/lib/module/components/button/index.js +106 -11
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/calendar/index.js +107 -24
- package/lib/module/components/calendar/index.js.map +1 -1
- package/lib/module/components/drawer/index.js +477 -0
- package/lib/module/components/drawer/index.js.map +1 -0
- package/lib/module/components/heatmap-chart/index.js +9 -8
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/map/index.js +50 -4
- package/lib/module/components/map/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +1024 -0
- package/lib/module/components/panelside/index.js.map +1 -0
- package/lib/module/components/plan/index.js +307 -21
- package/lib/module/components/plan/index.js.map +1 -1
- package/lib/module/components/post/index.js +992 -0
- package/lib/module/components/post/index.js.map +1 -0
- package/lib/module/components/response/index.js +296 -0
- package/lib/module/components/response/index.js.map +1 -0
- package/lib/module/components/response/markdown.js +463 -0
- package/lib/module/components/response/markdown.js.map +1 -0
- package/lib/module/components/select/index.js +111 -18
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/table/index.js +632 -0
- package/lib/module/components/table/index.js.map +1 -0
- package/lib/module/icons/index.js +220 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +6 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js +30 -0
- package/lib/module/native/index.js.map +1 -1
- package/lib/module/utils/date.js +129 -22
- package/lib/module/utils/date.js.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +22 -1
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/calendar/index.d.ts +12 -3
- package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
- package/lib/typescript/src/components/drawer/index.d.ts +146 -0
- package/lib/typescript/src/components/drawer/index.d.ts.map +1 -0
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/index.d.ts +8 -1
- package/lib/typescript/src/components/map/index.d.ts.map +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts +354 -0
- package/lib/typescript/src/components/panelside/index.d.ts.map +1 -0
- package/lib/typescript/src/components/plan/index.d.ts +121 -10
- package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
- package/lib/typescript/src/components/post/index.d.ts +358 -0
- package/lib/typescript/src/components/post/index.d.ts.map +1 -0
- package/lib/typescript/src/components/response/index.d.ts +91 -0
- package/lib/typescript/src/components/response/index.d.ts.map +1 -0
- package/lib/typescript/src/components/response/markdown.d.ts +88 -0
- package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +27 -2
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/table/index.d.ts +274 -0
- package/lib/typescript/src/components/table/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +41 -1
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +7 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +20 -0
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/date.d.ts +49 -5
- package/lib/typescript/src/utils/date.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/button/index.tsx +140 -8
- package/src/components/calendar/index.tsx +136 -23
- package/src/components/drawer/index.tsx +662 -0
- package/src/components/heatmap-chart/index.tsx +9 -9
- package/src/components/map/index.tsx +53 -4
- package/src/components/panelside/index.tsx +1358 -0
- package/src/components/plan/index.tsx +344 -21
- package/src/components/post/index.tsx +1116 -0
- package/src/components/response/index.tsx +390 -0
- package/src/components/response/markdown.ts +489 -0
- package/src/components/select/index.tsx +155 -15
- package/src/components/table/index.tsx +747 -0
- package/src/icons/index.tsx +176 -1
- package/src/index.ts +93 -0
- package/src/native/index.ts +51 -0
- package/src/utils/date.ts +146 -22
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { forwardRef, type ReactNode } from 'react';
|
|
2
|
-
import
|
|
2
|
+
import { View } from 'react-native';
|
|
3
3
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
4
4
|
import { useCSSVariable } from 'uniwind';
|
|
5
5
|
import {
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from '../../primitives/animated-pressable';
|
|
9
9
|
import { Text, textChildren } from '../../primitives/text';
|
|
10
10
|
import { IconColorProvider } from '../../icons';
|
|
11
|
-
import { getNativeUI } from '../../native';
|
|
11
|
+
import { getNativeUI, getSwiftUIModifiers } from '../../native';
|
|
12
12
|
import { Spinner } from '../spinner';
|
|
13
13
|
|
|
14
14
|
const buttonVariants = tv({
|
|
@@ -118,8 +118,29 @@ export interface ButtonProps
|
|
|
118
118
|
* A native button **sizes itself to its label**, the way a platform button
|
|
119
119
|
* is supposed to. It does not stretch to fill its container, and `fullWidth`
|
|
120
120
|
* has no effect on it.
|
|
121
|
+
*
|
|
122
|
+
* **Give it a string, not elements, unless `size` is `icon`.** A string
|
|
123
|
+
* becomes the platform's own label. Anything else has to be hosted inside
|
|
124
|
+
* the native tree, and a hosted view only measures where something above it
|
|
125
|
+
* is definite on *both* axes — which is true of an icon button, because it
|
|
126
|
+
* is a square this component sizes, and false of a labelled one, whose width
|
|
127
|
+
* is its text's and known to nobody in advance. Host a label without a width
|
|
128
|
+
* and the two layout systems ask each other the same question until the app
|
|
129
|
+
* dies, in native code, where a `try` here has nothing to catch.
|
|
121
130
|
*/
|
|
122
131
|
native?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Draw the native button in the platform's Liquid Glass material — the one
|
|
134
|
+
* iOS 26 uses for its own floating controls. Requires `native`, and iOS 26 or
|
|
135
|
+
* later; anywhere else it is ignored and the button keeps its ordinary
|
|
136
|
+
* platform style rather than failing.
|
|
137
|
+
*
|
|
138
|
+
* `primary` and `destructive` take the prominent variant, which keeps the
|
|
139
|
+
* accent tint a filled button is supposed to have; every other variant takes
|
|
140
|
+
* the plain one. An icon button is drawn round rather than in the platform's
|
|
141
|
+
* default capsule.
|
|
142
|
+
*/
|
|
143
|
+
glass?: boolean;
|
|
123
144
|
}
|
|
124
145
|
|
|
125
146
|
/**
|
|
@@ -140,6 +161,51 @@ const NATIVE_HEIGHT: Record<NonNullable<ButtonVariantProps['size']>, number> = {
|
|
|
140
161
|
icon: 44,
|
|
141
162
|
};
|
|
142
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Room around the glyph in a native icon button, applied to the glyph itself.
|
|
166
|
+
*
|
|
167
|
+
* Not to the button, and that distinction is the whole thing. SwiftUI's
|
|
168
|
+
* `padding` on a Button pads *outside* its background — the chrome stays the
|
|
169
|
+
* size it was and moves inward — and `frame` only sets the layout box, leaving
|
|
170
|
+
* a glyph-sized background centred in a larger invisible one. Neither grows
|
|
171
|
+
* the button. What the background is drawn around is the *label*, and the
|
|
172
|
+
* label here is a React Native view we host ourselves, so padding it in React
|
|
173
|
+
* is both the simplest lever and the only one that works.
|
|
174
|
+
*/
|
|
175
|
+
const NATIVE_ICON_PADDING = 6;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The frame a native icon button is given, which must contain the padded glyph
|
|
179
|
+
* above with room to spare.
|
|
180
|
+
*
|
|
181
|
+
* It exists to end a measurement, not to set a look. The chain is
|
|
182
|
+
* `Host(matchContents)` → platform button → `RNHostView(matchContents)` → our
|
|
183
|
+
* view: every link sized by its contents, and nothing anywhere with a size of
|
|
184
|
+
* its own. Something has to be definite or the two layout systems ask each
|
|
185
|
+
* other the same question forever, and that fails down in the platform, where
|
|
186
|
+
* there is nothing for a JavaScript `try` to catch.
|
|
187
|
+
*
|
|
188
|
+
*/
|
|
189
|
+
const NATIVE_ICON_FRAME = 44;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Our sizes on the platform's own control scale, which is how a native button
|
|
193
|
+
* is made bigger: it scales the room the style leaves around the label, and the
|
|
194
|
+
* label with it.
|
|
195
|
+
*
|
|
196
|
+
* Only ever sent for a *string* label. A hosted label is the case that has
|
|
197
|
+
* taken this app down twice, and while the cause turned out to be a missing
|
|
198
|
+
* frame rather than this modifier, a labelled button has no hosted view and no
|
|
199
|
+
* measurement to get wrong — so there is no reason to find out on both at once.
|
|
200
|
+
*/
|
|
201
|
+
const NATIVE_CONTROL_SIZE = {
|
|
202
|
+
sm: 'small',
|
|
203
|
+
md: 'regular',
|
|
204
|
+
lg: 'large',
|
|
205
|
+
icon: 'regular',
|
|
206
|
+
} as const;
|
|
207
|
+
|
|
208
|
+
|
|
143
209
|
/** PanelUI variants mapped onto the platform button styles. */
|
|
144
210
|
const NATIVE_VARIANT: Record<
|
|
145
211
|
NonNullable<ButtonVariantProps['variant']>,
|
|
@@ -167,6 +233,7 @@ export const Button = forwardRef<View, ButtonProps>(
|
|
|
167
233
|
startContent,
|
|
168
234
|
endContent,
|
|
169
235
|
native,
|
|
236
|
+
glass = false,
|
|
170
237
|
...props
|
|
171
238
|
},
|
|
172
239
|
ref
|
|
@@ -198,6 +265,39 @@ export const Button = forwardRef<View, ButtonProps>(
|
|
|
198
265
|
if (nativeUI) {
|
|
199
266
|
const { Host, Button: NativeButton, RNHostView } = nativeUI;
|
|
200
267
|
const isStringLabel = typeof children === 'string';
|
|
268
|
+
const prominent = variant === 'primary' || variant === 'destructive';
|
|
269
|
+
|
|
270
|
+
/*
|
|
271
|
+
* Looks the portable props cannot ask for.
|
|
272
|
+
*
|
|
273
|
+
* `buttonStyle` because Liquid Glass has no cross-platform variant to
|
|
274
|
+
* map onto. `glassProminent` is the tinted one — it keeps the accent
|
|
275
|
+
* fill a prominent button is supposed to have, which drawing the
|
|
276
|
+
* material by hand over a plain button throws away. A supplied
|
|
277
|
+
* `buttonStyle` replaces the one the variant would have set, so this is
|
|
278
|
+
* a substitution rather than a layer on top.
|
|
279
|
+
*
|
|
280
|
+
* `buttonBorderShape` because an icon button is round and the platform's
|
|
281
|
+
* default capsule is not — a lone glyph in a capsule reads as a text
|
|
282
|
+
* button somebody forgot to label. It shapes the glass too.
|
|
283
|
+
*/
|
|
284
|
+
const swiftUI = getSwiftUIModifiers();
|
|
285
|
+
const nativeModifiers = swiftUI
|
|
286
|
+
? [
|
|
287
|
+
glass ? swiftUI.buttonStyle(prominent ? 'glassProminent' : 'glass') : null,
|
|
288
|
+
size === 'icon' ? swiftUI.buttonBorderShape('circle') : null,
|
|
289
|
+
isStringLabel ? swiftUI.controlSize(NATIVE_CONTROL_SIZE[size ?? 'md']) : null,
|
|
290
|
+
].filter(Boolean)
|
|
291
|
+
: [];
|
|
292
|
+
|
|
293
|
+
/*
|
|
294
|
+
* The platform paints the background, not the theme — so a hosted icon
|
|
295
|
+
* cannot read its colour from a token the way it does in the styled
|
|
296
|
+
* button. On a tinted button that is white, whatever the theme thinks its
|
|
297
|
+
* own primary foreground is; on the rest, including plain glass, the
|
|
298
|
+
* material is clear enough to leave the page's own foreground legible.
|
|
299
|
+
*/
|
|
300
|
+
const nativeContent = prominent ? '#ffffff' : contentColor;
|
|
201
301
|
|
|
202
302
|
return (
|
|
203
303
|
<Host matchContents>
|
|
@@ -205,7 +305,22 @@ export const Button = forwardRef<View, ButtonProps>(
|
|
|
205
305
|
label={isStringLabel ? children : undefined}
|
|
206
306
|
variant={NATIVE_VARIANT[variant ?? 'primary']}
|
|
207
307
|
disabled={isDisabled}
|
|
208
|
-
|
|
308
|
+
/*
|
|
309
|
+
* A square for an icon button, a height for a labelled one — and
|
|
310
|
+
* in both cases a definite number, which is what stops the
|
|
311
|
+
* measurement chain above from having no fixed point in it.
|
|
312
|
+
*
|
|
313
|
+
* The frame does not decide how big the *button* looks; the padded
|
|
314
|
+
* label does, because that is what the platform draws its
|
|
315
|
+
* background around. The frame only has to be large enough to hold
|
|
316
|
+
* the result.
|
|
317
|
+
*/
|
|
318
|
+
style={
|
|
319
|
+
size === 'icon'
|
|
320
|
+
? { width: NATIVE_ICON_FRAME, height: NATIVE_ICON_FRAME }
|
|
321
|
+
: { height: NATIVE_HEIGHT[size ?? 'md'] }
|
|
322
|
+
}
|
|
323
|
+
modifiers={nativeModifiers.length ? nativeModifiers : undefined}
|
|
209
324
|
onPress={props.onPress}
|
|
210
325
|
>
|
|
211
326
|
{/* Non-string children are React Native views, and the native
|
|
@@ -215,11 +330,28 @@ export const Button = forwardRef<View, ButtonProps>(
|
|
|
215
330
|
bare text once it is in there. */}
|
|
216
331
|
{isStringLabel ? undefined : (
|
|
217
332
|
<RNHostView matchContents>
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
333
|
+
{/* Padding the hosted label is what grows the button, because
|
|
334
|
+
the platform draws its background around the label. The
|
|
335
|
+
centring is not redundant with it: a view lays a child out
|
|
336
|
+
from its leading edge, so an icon narrower than the line it
|
|
337
|
+
sits on ends up off to one side of a circle that is not. */}
|
|
338
|
+
<View
|
|
339
|
+
style={
|
|
340
|
+
size === 'icon'
|
|
341
|
+
? {
|
|
342
|
+
padding: NATIVE_ICON_PADDING,
|
|
343
|
+
alignItems: 'center',
|
|
344
|
+
justifyContent: 'center',
|
|
345
|
+
}
|
|
346
|
+
: undefined
|
|
347
|
+
}
|
|
348
|
+
>
|
|
349
|
+
<IconColorProvider color={nativeContent}>
|
|
350
|
+
{textChildren(children, (text) => (
|
|
351
|
+
<Text className={label({ className: labelClassName })}>{text}</Text>
|
|
352
|
+
))}
|
|
353
|
+
</IconColorProvider>
|
|
354
|
+
</View>
|
|
223
355
|
</RNHostView>
|
|
224
356
|
)}
|
|
225
357
|
</NativeButton>
|
|
@@ -39,11 +39,12 @@ import {
|
|
|
39
39
|
createContext,
|
|
40
40
|
useCallback,
|
|
41
41
|
useContext,
|
|
42
|
+
useEffect,
|
|
42
43
|
useMemo,
|
|
43
44
|
useRef,
|
|
44
45
|
useState,
|
|
45
46
|
} from 'react';
|
|
46
|
-
import { Pressable, ScrollView, View, type ViewProps } from 'react-native';
|
|
47
|
+
import { AppState, Pressable, ScrollView, View, type ViewProps } from 'react-native';
|
|
47
48
|
import { tv } from 'tailwind-variants';
|
|
48
49
|
import { useCSSVariable } from 'uniwind';
|
|
49
50
|
import {
|
|
@@ -57,17 +58,20 @@ import { cn } from '../../utils/cn';
|
|
|
57
58
|
import { Popover } from '../popover';
|
|
58
59
|
import {
|
|
59
60
|
addCalendarMonths,
|
|
60
|
-
addDays,
|
|
61
61
|
calendarDayNumber,
|
|
62
62
|
calendarLongDate,
|
|
63
63
|
calendarMonthLabel,
|
|
64
64
|
calendarMonthNames,
|
|
65
65
|
calendarParts,
|
|
66
|
+
clampDate,
|
|
66
67
|
isAfter,
|
|
67
68
|
isBefore,
|
|
68
69
|
isSameCalendarMonth,
|
|
69
70
|
isSameDay,
|
|
70
71
|
isWithin,
|
|
72
|
+
localeWeekStart,
|
|
73
|
+
monthGrid,
|
|
74
|
+
normalizeWeekStart,
|
|
71
75
|
resolveCalendar,
|
|
72
76
|
startOfCalendarMonth,
|
|
73
77
|
startOfDay,
|
|
@@ -184,6 +188,35 @@ function useCalendar(component: string): CalendarContextValue {
|
|
|
184
188
|
return context;
|
|
185
189
|
}
|
|
186
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Today, kept current.
|
|
193
|
+
*
|
|
194
|
+
* "Today" is not a constant, and a calendar is exactly the kind of thing left
|
|
195
|
+
* on screen across the boundary that changes it — a picker open in a sheet, an
|
|
196
|
+
* app resumed the next morning. Recomputed when the app comes back to the
|
|
197
|
+
* foreground, which is when a stale answer would otherwise become visible, and
|
|
198
|
+
* held as state so the marker actually moves when it does.
|
|
199
|
+
*/
|
|
200
|
+
function useToday(): Date {
|
|
201
|
+
const [today, setToday] = useState(() => startOfDay(new Date()));
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const refresh = () => {
|
|
205
|
+
const now = startOfDay(new Date());
|
|
206
|
+
// A new object every resume would invalidate every memo below it for a
|
|
207
|
+
// date that has not changed.
|
|
208
|
+
setToday((current) => (current.getTime() === now.getTime() ? current : now));
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const subscription = AppState.addEventListener('change', (state) => {
|
|
212
|
+
if (state === 'active') refresh();
|
|
213
|
+
});
|
|
214
|
+
return () => subscription.remove();
|
|
215
|
+
}, []);
|
|
216
|
+
|
|
217
|
+
return today;
|
|
218
|
+
}
|
|
219
|
+
|
|
187
220
|
/** Whether a rule, a list or a span rules this day out. */
|
|
188
221
|
function isDisabled(
|
|
189
222
|
date: Date,
|
|
@@ -245,8 +278,17 @@ export interface CalendarProps<Mode extends CalendarMode = 'single'>
|
|
|
245
278
|
startMonth?: Date;
|
|
246
279
|
/** Latest month the caption's pickers offer. Defaults to `maxDate`, else ten years on. */
|
|
247
280
|
endMonth?: Date;
|
|
248
|
-
/**
|
|
249
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Which day the week's first column is. `0` is Sunday.
|
|
283
|
+
*
|
|
284
|
+
* `auto` asks the locale, which is what most of the world wants — `en-GB` and
|
|
285
|
+
* `fr-FR` start on Monday, and a calendar that says otherwise is wrong in a
|
|
286
|
+
* way its reader notices immediately. It is not the default only because
|
|
287
|
+
* changing an existing calendar's columns out from under it is not something
|
|
288
|
+
* a version bump should do. Out-of-range numbers wrap rather than producing
|
|
289
|
+
* a blank column.
|
|
290
|
+
*/
|
|
291
|
+
weekStartsOn?: number | 'auto';
|
|
250
292
|
/** Draw the neighbouring months' days rather than leaving the cells blank. */
|
|
251
293
|
showOutsideDays?: boolean;
|
|
252
294
|
/**
|
|
@@ -291,7 +333,7 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
|
|
|
291
333
|
maxDate,
|
|
292
334
|
startMonth,
|
|
293
335
|
endMonth,
|
|
294
|
-
weekStartsOn = 0,
|
|
336
|
+
weekStartsOn: weekStartsOnProp = 0,
|
|
295
337
|
showOutsideDays = true,
|
|
296
338
|
selectOutsideDays = false,
|
|
297
339
|
bordered = true,
|
|
@@ -302,6 +344,16 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
|
|
|
302
344
|
// Settled once here rather than at each call site, so every part of the
|
|
303
345
|
// grid is certainly reading the same calendar.
|
|
304
346
|
const system = useMemo(() => resolveCalendar(calendar, locale), [calendar, locale]);
|
|
347
|
+
// Settled once too, and normalised: everything below indexes an array of
|
|
348
|
+
// seven with it, where a negative or out-of-range value is a blank column
|
|
349
|
+
// rather than an error anyone would trace back to here.
|
|
350
|
+
const weekStartsOn = useMemo(
|
|
351
|
+
() =>
|
|
352
|
+
weekStartsOnProp === 'auto'
|
|
353
|
+
? localeWeekStart(locale)
|
|
354
|
+
: normalizeWeekStart(weekStartsOnProp),
|
|
355
|
+
[weekStartsOnProp, locale]
|
|
356
|
+
);
|
|
305
357
|
const [internalSelected, setInternalSelected] = useState<CalendarSelection[Mode] | undefined>(
|
|
306
358
|
defaultSelected
|
|
307
359
|
);
|
|
@@ -328,15 +380,52 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
|
|
|
328
380
|
}, []);
|
|
329
381
|
|
|
330
382
|
const [internalMonth, setInternalMonth] = useState(initialMonth);
|
|
331
|
-
|
|
383
|
+
/*
|
|
384
|
+
* Memoised on the *instant*, not on the prop. A controlled caller almost
|
|
385
|
+
* always passes a fresh `new Date(…)`, and deriving a new month object from
|
|
386
|
+
* it on every render invalidates the grid memo and the context memo below —
|
|
387
|
+
* so a calendar that has not moved rebuilds forty-two cells per keystroke
|
|
388
|
+
* somewhere else on the screen.
|
|
389
|
+
*/
|
|
390
|
+
const controlledMonth = useMemo(
|
|
391
|
+
() => (monthProp ? startOfCalendarMonth(monthProp, system, locale) : undefined),
|
|
392
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
393
|
+
[monthProp?.getTime(), system, locale]
|
|
394
|
+
);
|
|
395
|
+
const month = controlledMonth ?? internalMonth;
|
|
396
|
+
|
|
397
|
+
/*
|
|
398
|
+
* How far the calendar may be paged, as the first day of the outermost month
|
|
399
|
+
* either way. `startMonth`/`endMonth` win where they are given, because they
|
|
400
|
+
* are the props about *navigation*; `minDate`/`maxDate` are about which days
|
|
401
|
+
* can be picked, and stand in when the navigation bounds are not stated.
|
|
402
|
+
*/
|
|
403
|
+
const monthBounds = useMemo(() => {
|
|
404
|
+
const lower = startMonth ?? minDate;
|
|
405
|
+
const upper = endMonth ?? maxDate;
|
|
406
|
+
return {
|
|
407
|
+
from: lower ? startOfCalendarMonth(lower, system, locale) : undefined,
|
|
408
|
+
to: upper ? startOfCalendarMonth(upper, system, locale) : undefined,
|
|
409
|
+
};
|
|
410
|
+
}, [startMonth, minDate, endMonth, maxDate, system, locale]);
|
|
332
411
|
|
|
333
412
|
const setMonth = useCallback(
|
|
334
413
|
(next: Date) => {
|
|
335
|
-
|
|
414
|
+
/*
|
|
415
|
+
* Clamped here rather than only at the arrows. The arrows guard
|
|
416
|
+
* themselves, but the caption's dropdowns and a controlled caller both
|
|
417
|
+
* reach this by other routes — and a month list that offers a year is a
|
|
418
|
+
* poor place to discover that the year was out of bounds.
|
|
419
|
+
*/
|
|
420
|
+
const settled = clampDate(
|
|
421
|
+
startOfCalendarMonth(next, system, locale),
|
|
422
|
+
monthBounds.from,
|
|
423
|
+
monthBounds.to
|
|
424
|
+
);
|
|
336
425
|
if (!monthProp) setInternalMonth(settled);
|
|
337
426
|
onMonthChange?.(settled);
|
|
338
427
|
},
|
|
339
|
-
[monthProp, onMonthChange, system, locale]
|
|
428
|
+
[monthProp, onMonthChange, system, locale, monthBounds]
|
|
340
429
|
);
|
|
341
430
|
|
|
342
431
|
const commit = useCallback(
|
|
@@ -351,6 +440,14 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
|
|
|
351
440
|
(date: Date) => {
|
|
352
441
|
const day = startOfDay(date);
|
|
353
442
|
|
|
443
|
+
/*
|
|
444
|
+
* A tap on a neighbouring month's cell — which only happens with
|
|
445
|
+
* `selectOutsideDays` — commits a date the grid is not showing. Page to
|
|
446
|
+
* it, or the selection lands in a greyed-out cell of the month you are
|
|
447
|
+
* still looking at and reads as having missed.
|
|
448
|
+
*/
|
|
449
|
+
if (!isSameCalendarMonth(day, month, system, locale)) setMonth(day);
|
|
450
|
+
|
|
354
451
|
if (mode === 'multiple') {
|
|
355
452
|
const current = (value as Date[] | undefined) ?? [];
|
|
356
453
|
const without = current.filter((picked) => !isSameDay(picked, day));
|
|
@@ -379,7 +476,7 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
|
|
|
379
476
|
const current = value as Date | undefined;
|
|
380
477
|
commit((isSameDay(current, day) ? undefined : day) as CalendarSelection[Mode]);
|
|
381
478
|
},
|
|
382
|
-
[mode, value, commit]
|
|
479
|
+
[mode, value, commit, month, system, locale, setMonth]
|
|
383
480
|
);
|
|
384
481
|
|
|
385
482
|
const context = useMemo(
|
|
@@ -563,10 +660,30 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
|
|
|
563
660
|
*/
|
|
564
661
|
const first = startMonth ?? minDate;
|
|
565
662
|
const last = endMonth ?? maxDate;
|
|
663
|
+
const firstParts = first ? calendarParts(first, system, locale) : null;
|
|
664
|
+
const lastParts = last ? calendarParts(last, system, locale) : null;
|
|
566
665
|
const bounds = {
|
|
567
|
-
from:
|
|
568
|
-
to:
|
|
666
|
+
from: firstParts ? firstParts.year : current.year - 100,
|
|
667
|
+
to: lastParts ? lastParts.year : current.year + 10,
|
|
569
668
|
};
|
|
669
|
+
/*
|
|
670
|
+
* The month list is bounded too, and only the year list used to be — so a
|
|
671
|
+
* calendar starting in March 1925 still offered January and February of 1925,
|
|
672
|
+
* and picking one navigated somewhere the calendar had said it would not go.
|
|
673
|
+
* Only the outermost year of each bound loses months; every year between them
|
|
674
|
+
* has all twelve.
|
|
675
|
+
*/
|
|
676
|
+
const monthOptions = months
|
|
677
|
+
.map((label, index) => ({ label, month: index + 1 }))
|
|
678
|
+
.filter(({ month }) => {
|
|
679
|
+
if (firstParts && current.year === firstParts.year && month < firstParts.month) {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
if (lastParts && current.year === lastParts.year && month > lastParts.month) {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
return true;
|
|
686
|
+
});
|
|
570
687
|
/*
|
|
571
688
|
* Oldest first, the way a year runs. The list is long — a century by default
|
|
572
689
|
* — so it is opened scrolled to the year already in the caption rather than
|
|
@@ -594,10 +711,10 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
|
|
|
594
711
|
<CaptionDropdown
|
|
595
712
|
label={months[current.month - 1] ?? String(current.month)}
|
|
596
713
|
accessibilityLabel="Month"
|
|
597
|
-
options={
|
|
598
|
-
active={
|
|
714
|
+
options={monthOptions.map((option) => option.label)}
|
|
715
|
+
active={monthOptions.findIndex((option) => option.month === current.month)}
|
|
599
716
|
width={MONTH_LIST_WIDTH}
|
|
600
|
-
onSelect={(index) => choose(index
|
|
717
|
+
onSelect={(index) => choose((monthOptions[index]?.month ?? current.month) - current.month)}
|
|
601
718
|
/>
|
|
602
719
|
<CaptionDropdown
|
|
603
720
|
label={String(current.year)}
|
|
@@ -787,16 +904,12 @@ function CalendarGrid({
|
|
|
787
904
|
* boundaries and the day numbers differ — so the walk itself is the same
|
|
788
905
|
* either way and only where it starts moves.
|
|
789
906
|
*/
|
|
790
|
-
const weeks = useMemo(
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
return Array.from({ length: 6 }, (_unusedWeek, week) =>
|
|
795
|
-
Array.from({ length: 7 }, (_unusedDay, day) => addDays(start, week * 7 + day))
|
|
796
|
-
);
|
|
797
|
-
}, [month, weekStartsOn, system, locale]);
|
|
907
|
+
const weeks = useMemo(
|
|
908
|
+
() => monthGrid(month, weekStartsOn, system, locale),
|
|
909
|
+
[month, weekStartsOn, system, locale]
|
|
910
|
+
);
|
|
798
911
|
const headings = useMemo(() => weekdayNames(locale, weekStartsOn), [locale, weekStartsOn]);
|
|
799
|
-
const today =
|
|
912
|
+
const today = useToday();
|
|
800
913
|
|
|
801
914
|
const range = mode === 'range' ? (value as DateRange | undefined) : undefined;
|
|
802
915
|
const multiple = mode === 'multiple' ? ((value as Date[] | undefined) ?? []) : [];
|