panelui-native 0.68.0 → 0.71.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 +12 -2
- package/lib/module/components/accordion/index.js +28 -9
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/button/index.js +14 -7
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/collapsible/index.js +280 -0
- package/lib/module/components/collapsible/index.js.map +1 -0
- package/lib/module/components/flow/index.js +66 -3
- package/lib/module/components/flow/index.js.map +1 -1
- package/lib/module/components/live-line-chart/index.js +103 -9
- package/lib/module/components/live-line-chart/index.js.map +1 -1
- package/lib/module/components/live-line-chart/live-line-accessibility.js +47 -0
- package/lib/module/components/live-line-chart/live-line-accessibility.js.map +1 -0
- package/lib/module/components/live-line-chart/live-line-lifecycle.js +33 -0
- package/lib/module/components/live-line-chart/live-line-lifecycle.js.map +1 -0
- package/lib/module/components/marquee/index.js +194 -0
- package/lib/module/components/marquee/index.js.map +1 -0
- package/lib/module/components/meter/index.js +38 -20
- package/lib/module/components/meter/index.js.map +1 -1
- package/lib/module/components/meter/meter-scale.js +62 -8
- package/lib/module/components/meter/meter-scale.js.map +1 -1
- package/lib/module/components/planner/index.js +133 -59
- package/lib/module/components/planner/index.js.map +1 -1
- package/lib/module/components/planner/planner-announcement.js +30 -0
- package/lib/module/components/planner/planner-announcement.js.map +1 -0
- package/lib/module/components/planner/planner-entries.js +15 -7
- package/lib/module/components/planner/planner-entries.js.map +1 -1
- package/lib/module/components/planner/planner-grid-navigation.js +33 -0
- package/lib/module/components/planner/planner-grid-navigation.js.map +1 -0
- package/lib/module/components/planner/planner-lifecycle.js +45 -0
- package/lib/module/components/planner/planner-lifecycle.js.map +1 -0
- package/lib/module/components/popover/index.js +2 -2
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/focus-restoration-store.js +52 -0
- package/lib/module/primitives/focus-restoration-store.js.map +1 -0
- package/lib/module/primitives/portal.js +36 -2
- package/lib/module/primitives/portal.js.map +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +4 -0
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/collapsible/index.d.ts +76 -0
- package/lib/typescript/src/components/collapsible/index.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/index.d.ts +7 -1
- package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
- package/lib/typescript/src/components/live-line-chart/index.d.ts +21 -7
- package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts +22 -0
- package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts.map +1 -0
- package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts +23 -0
- package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts.map +1 -0
- package/lib/typescript/src/components/marquee/index.d.ts +73 -0
- package/lib/typescript/src/components/marquee/index.d.ts.map +1 -0
- package/lib/typescript/src/components/meter/index.d.ts +7 -4
- package/lib/typescript/src/components/meter/index.d.ts.map +1 -1
- package/lib/typescript/src/components/meter/meter-scale.d.ts +39 -0
- package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/planner-announcement.d.ts +15 -0
- package/lib/typescript/src/components/planner/planner-announcement.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/planner-entries.d.ts +1 -1
- package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +4 -0
- package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/planner-lifecycle.d.ts +20 -0
- package/lib/typescript/src/components/planner/planner-lifecycle.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/focus-restoration-store.d.ts +24 -0
- package/lib/typescript/src/primitives/focus-restoration-store.d.ts.map +1 -0
- package/lib/typescript/src/primitives/portal.d.ts +4 -0
- package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
- package/package.json +36 -3
- package/src/components/accordion/index.tsx +31 -8
- package/src/components/button/index.tsx +11 -4
- package/src/components/collapsible/index.tsx +278 -0
- package/src/components/flow/index.tsx +93 -14
- package/src/components/live-line-chart/index.tsx +130 -13
- package/src/components/live-line-chart/live-line-accessibility.ts +61 -0
- package/src/components/live-line-chart/live-line-lifecycle.ts +63 -0
- package/src/components/marquee/index.tsx +232 -0
- package/src/components/meter/index.tsx +49 -26
- package/src/components/meter/meter-scale.ts +84 -8
- package/src/components/planner/index.tsx +182 -71
- package/src/components/planner/planner-announcement.ts +37 -0
- package/src/components/planner/planner-entries.ts +16 -7
- package/src/components/planner/planner-grid-navigation.ts +45 -0
- package/src/components/planner/planner-lifecycle.ts +76 -0
- package/src/components/popover/index.tsx +3 -3
- package/src/index.ts +13 -0
- package/src/primitives/focus-restoration-store.ts +70 -0
- package/src/primitives/portal.tsx +34 -2
|
@@ -11,16 +11,21 @@ import Animated, {
|
|
|
11
11
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
12
12
|
import { Text } from '../../primitives/text';
|
|
13
13
|
import {
|
|
14
|
-
clamp,
|
|
15
14
|
colorFor,
|
|
16
|
-
fractionOf,
|
|
17
|
-
formatValue,
|
|
18
15
|
litSegments,
|
|
16
|
+
meterSemantics,
|
|
17
|
+
normalizeScale,
|
|
18
|
+
normalizeSegments,
|
|
19
19
|
type MeterColor,
|
|
20
20
|
type MeterThreshold,
|
|
21
21
|
} from './meter-scale';
|
|
22
22
|
|
|
23
|
-
const SPRING = {
|
|
23
|
+
const SPRING = {
|
|
24
|
+
damping: 20,
|
|
25
|
+
stiffness: 180,
|
|
26
|
+
mass: 0.6,
|
|
27
|
+
overshootClamping: true,
|
|
28
|
+
} as const;
|
|
24
29
|
/** Milliseconds for a segment to light or go out. */
|
|
25
30
|
const SEGMENT_DURATION = 180;
|
|
26
31
|
/** How faint an unlit segment sits. Present, but plainly not counted. */
|
|
@@ -32,7 +37,7 @@ const meterVariants = tv({
|
|
|
32
37
|
header: 'flex-row items-center justify-between gap-2',
|
|
33
38
|
track: 'w-full overflow-hidden rounded-full',
|
|
34
39
|
indicator: 'h-full rounded-full',
|
|
35
|
-
segments: 'w-full flex-row',
|
|
40
|
+
segments: 'w-full flex-row overflow-hidden',
|
|
36
41
|
segment: 'flex-1 rounded-full',
|
|
37
42
|
},
|
|
38
43
|
variants: {
|
|
@@ -70,7 +75,7 @@ export interface MeterProps
|
|
|
70
75
|
extends Omit<ViewProps, 'children'>,
|
|
71
76
|
MeterVariantProps {
|
|
72
77
|
className?: string;
|
|
73
|
-
/** The measurement
|
|
78
|
+
/** The measurement. Values outside the scale are clamped to its ends. */
|
|
74
79
|
value: number;
|
|
75
80
|
/**
|
|
76
81
|
* The bottom of the scale — the value at which the bar reads as empty.
|
|
@@ -111,7 +116,8 @@ export interface MeterProps
|
|
|
111
116
|
/**
|
|
112
117
|
* Points on the scale where the colour changes, each `{ from, color }`. The
|
|
113
118
|
* highest one the reading has reached wins, so the order you list them in
|
|
114
|
-
* does not matter; below all of them the `color` prop applies.
|
|
119
|
+
* does not matter; below all of them the `color` prop applies. Non-finite
|
|
120
|
+
* `from` values are ignored.
|
|
115
121
|
*
|
|
116
122
|
* This is the difference between a meter and a bar: the colour is a
|
|
117
123
|
* judgement about the reading. Which direction is bad is yours to say —
|
|
@@ -125,8 +131,10 @@ export interface MeterProps
|
|
|
125
131
|
* say "three out of four" where a bar says "about seventy percent", and a
|
|
126
132
|
* password is not seventy percent strong.
|
|
127
133
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
134
|
+
* Fractional counts round down and counts above 100 clamp to 100. Invalid,
|
|
135
|
+
* non-positive, or sub-one counts use the continuous bar. Any value above
|
|
136
|
+
* the floor lights at least one block, so a reading that is not empty never
|
|
137
|
+
* looks it.
|
|
130
138
|
*/
|
|
131
139
|
segments?: number;
|
|
132
140
|
/** Extra classes for the fill, or for a lit segment. */
|
|
@@ -207,8 +215,9 @@ export const Meter = forwardRef<View, MeterProps>(
|
|
|
207
215
|
},
|
|
208
216
|
ref
|
|
209
217
|
) => {
|
|
210
|
-
const
|
|
211
|
-
const
|
|
218
|
+
const scale = normalizeScale(value, minValue, maxValue);
|
|
219
|
+
const { value: held, fraction } = scale;
|
|
220
|
+
const segmentCount = normalizeSegments(segments);
|
|
212
221
|
const resolvedColor = colorFor(held, color ?? 'primary', thresholds);
|
|
213
222
|
const slots = meterVariants({ color: resolvedColor, size });
|
|
214
223
|
const reducedMotion = useReducedMotion();
|
|
@@ -218,7 +227,7 @@ export const Meter = forwardRef<View, MeterProps>(
|
|
|
218
227
|
// Any reading above the floor lights a block, so "a little" never looks
|
|
219
228
|
// like "none". Rounding down would leave the first quarter of a
|
|
220
229
|
// four-block meter dark, which is the reading it is least able to afford.
|
|
221
|
-
const litCount =
|
|
230
|
+
const litCount = litSegments(fraction, segmentCount);
|
|
222
231
|
const lit = useSharedValue(litCount);
|
|
223
232
|
|
|
224
233
|
// Reduce motion lands on the value instead of springing to it — the
|
|
@@ -232,39 +241,47 @@ export const Meter = forwardRef<View, MeterProps>(
|
|
|
232
241
|
}, [lit, litCount]);
|
|
233
242
|
|
|
234
243
|
const onLayout = (event: LayoutChangeEvent) => {
|
|
235
|
-
|
|
244
|
+
const width = event.nativeEvent.layout.width;
|
|
245
|
+
trackWidth.value = Number.isFinite(width) && width > 0 ? width : 0;
|
|
236
246
|
};
|
|
237
247
|
|
|
238
248
|
const fillStyle = useAnimatedStyle(() => ({
|
|
239
249
|
width: trackWidth.value * progress.value,
|
|
240
250
|
}));
|
|
241
251
|
|
|
242
|
-
const
|
|
252
|
+
const semantics = meterSemantics({
|
|
253
|
+
value: held,
|
|
254
|
+
fraction,
|
|
255
|
+
// The normalized ends, not the raw props: an invalid scale is repaired
|
|
256
|
+
// for the bar, and the spoken reading has to describe the same one.
|
|
257
|
+
minValue: scale.min,
|
|
258
|
+
maxValue: scale.max,
|
|
259
|
+
label,
|
|
260
|
+
accessibilityLabel,
|
|
261
|
+
valueLabel,
|
|
262
|
+
formatOptions,
|
|
263
|
+
});
|
|
264
|
+
const spoken = semantics.text;
|
|
243
265
|
const showValue = showValueLabel;
|
|
244
266
|
const hasHeader = label != null || showValue;
|
|
245
267
|
|
|
246
268
|
const accessibility = {
|
|
247
269
|
accessibilityRole: 'progressbar' as const,
|
|
248
|
-
accessibilityLabel:
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
now: held,
|
|
253
|
-
// Without this the scale is read as a bare number. `text` is what
|
|
254
|
-
// carries the unit — the percent sign, the gigabytes, the word.
|
|
255
|
-
text: spoken,
|
|
256
|
-
},
|
|
270
|
+
accessibilityLabel: semantics.label,
|
|
271
|
+
// `text` carries the unit or word and intentionally overrides the
|
|
272
|
+
// platform's generic rendering of this numeric range.
|
|
273
|
+
accessibilityValue: semantics.value,
|
|
257
274
|
};
|
|
258
275
|
|
|
259
276
|
const bar =
|
|
260
|
-
|
|
277
|
+
segmentCount > 0 ? (
|
|
261
278
|
<View
|
|
262
279
|
ref={ref}
|
|
263
280
|
{...accessibility}
|
|
264
281
|
className={slots.segments({ className })}
|
|
265
282
|
{...props}
|
|
266
283
|
>
|
|
267
|
-
{Array.from({ length:
|
|
284
|
+
{Array.from({ length: segmentCount }, (_, index) => (
|
|
268
285
|
<Segment
|
|
269
286
|
key={index}
|
|
270
287
|
index={index}
|
|
@@ -293,7 +310,13 @@ export const Meter = forwardRef<View, MeterProps>(
|
|
|
293
310
|
|
|
294
311
|
return (
|
|
295
312
|
<View className={slots.root()}>
|
|
296
|
-
<View
|
|
313
|
+
<View
|
|
314
|
+
className={slots.header({ className: headerClassName })}
|
|
315
|
+
// This is the visual rendering of the name and value already owned
|
|
316
|
+
// by the bar below. Hiding the subtree keeps one concise focus stop.
|
|
317
|
+
accessibilityElementsHidden
|
|
318
|
+
importantForAccessibility="no-hide-descendants"
|
|
319
|
+
>
|
|
297
320
|
{label != null ? (
|
|
298
321
|
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
299
322
|
{label}
|
|
@@ -29,11 +29,55 @@ export interface MeterThreshold {
|
|
|
29
29
|
color: MeterColor;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** A bounded block count keeps malformed input from allocating without limit. */
|
|
33
|
+
export const MAX_METER_SEGMENTS = 100;
|
|
34
|
+
|
|
35
|
+
export interface MeterScale {
|
|
36
|
+
min: number;
|
|
37
|
+
max: number;
|
|
38
|
+
value: number;
|
|
39
|
+
fraction: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Make a public scale safe for layout, animation and native accessibility.
|
|
44
|
+
* Invalid bounds fall back to their documented defaults; if those still do
|
|
45
|
+
* not produce a positive span, the scale honestly collapses at its floor.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeScale(value: number, min: number, max: number): MeterScale {
|
|
48
|
+
const floor = Number.isFinite(min) ? min : 0;
|
|
49
|
+
const candidateMax = Number.isFinite(max) ? max : 100;
|
|
50
|
+
const ceiling = candidateMax > floor ? candidateMax : floor;
|
|
51
|
+
const held = Number.isNaN(value)
|
|
52
|
+
? floor
|
|
53
|
+
: value === Number.POSITIVE_INFINITY
|
|
54
|
+
? ceiling
|
|
55
|
+
: value === Number.NEGATIVE_INFINITY
|
|
56
|
+
? floor
|
|
57
|
+
: Math.min(Math.max(value, floor), ceiling);
|
|
58
|
+
const magnitude = Math.max(Math.abs(floor), Math.abs(ceiling), 1);
|
|
59
|
+
const position =
|
|
60
|
+
ceiling > floor
|
|
61
|
+
? (held / magnitude - floor / magnitude) /
|
|
62
|
+
(ceiling / magnitude - floor / magnitude)
|
|
63
|
+
: 0;
|
|
64
|
+
return {
|
|
65
|
+
min: floor,
|
|
66
|
+
max: ceiling,
|
|
67
|
+
value: held,
|
|
68
|
+
fraction: Math.min(Math.max(position, 0), 1),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Positive counts are whole and bounded; zero means the continuous meter. */
|
|
73
|
+
export function normalizeSegments(segments: number | undefined) {
|
|
74
|
+
if (segments == null || !Number.isFinite(segments) || !(segments > 0)) return 0;
|
|
75
|
+
return Math.min(Math.floor(segments), MAX_METER_SEGMENTS);
|
|
76
|
+
}
|
|
77
|
+
|
|
32
78
|
/** `value` held inside the scale, so a stray number cannot escape the track. */
|
|
33
79
|
export function clamp(value: number, min: number, max: number) {
|
|
34
|
-
|
|
35
|
-
if (value > max) return max;
|
|
36
|
-
return value;
|
|
80
|
+
return normalizeScale(value, min, max).value;
|
|
37
81
|
}
|
|
38
82
|
|
|
39
83
|
/**
|
|
@@ -41,9 +85,7 @@ export function clamp(value: number, min: number, max: number) {
|
|
|
41
85
|
* meaningful position in it, so it reads as empty rather than dividing by zero.
|
|
42
86
|
*/
|
|
43
87
|
export function fractionOf(value: number, min: number, max: number) {
|
|
44
|
-
|
|
45
|
-
if (!(span > 0)) return 0;
|
|
46
|
-
return clamp((value - min) / span, 0, 1);
|
|
88
|
+
return normalizeScale(value, min, max).fraction;
|
|
47
89
|
}
|
|
48
90
|
|
|
49
91
|
/**
|
|
@@ -62,6 +104,7 @@ export function colorFor(
|
|
|
62
104
|
if (!thresholds?.length) return base;
|
|
63
105
|
let winner: MeterThreshold | undefined;
|
|
64
106
|
for (const threshold of thresholds) {
|
|
107
|
+
if (!Number.isFinite(threshold.from)) continue;
|
|
65
108
|
if (value < threshold.from) continue;
|
|
66
109
|
if (!winner || threshold.from > winner.from) winner = threshold;
|
|
67
110
|
}
|
|
@@ -77,8 +120,10 @@ export function colorFor(
|
|
|
77
120
|
* wrong.
|
|
78
121
|
*/
|
|
79
122
|
export function litSegments(fraction: number, segments: number) {
|
|
80
|
-
|
|
81
|
-
|
|
123
|
+
const count = normalizeSegments(segments);
|
|
124
|
+
if (count === 0 || Number.isNaN(fraction) || fraction <= 0) return 0;
|
|
125
|
+
if (!Number.isFinite(fraction) || fraction >= 1) return count;
|
|
126
|
+
return Math.min(Math.ceil(fraction * count), count);
|
|
82
127
|
}
|
|
83
128
|
|
|
84
129
|
/**
|
|
@@ -107,3 +152,34 @@ export function formatValue(
|
|
|
107
152
|
}
|
|
108
153
|
return `${Math.round(fraction * 100)}%`;
|
|
109
154
|
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The meter's single spoken contract. Visual treatments such as segments,
|
|
158
|
+
* thresholds and motion do not belong here: they do not change the reading.
|
|
159
|
+
*/
|
|
160
|
+
export function meterSemantics({
|
|
161
|
+
value,
|
|
162
|
+
fraction,
|
|
163
|
+
minValue,
|
|
164
|
+
maxValue,
|
|
165
|
+
label,
|
|
166
|
+
accessibilityLabel,
|
|
167
|
+
valueLabel,
|
|
168
|
+
formatOptions,
|
|
169
|
+
}: {
|
|
170
|
+
value: number;
|
|
171
|
+
fraction: number;
|
|
172
|
+
minValue: number;
|
|
173
|
+
maxValue: number;
|
|
174
|
+
label?: string;
|
|
175
|
+
accessibilityLabel?: string;
|
|
176
|
+
valueLabel?: string;
|
|
177
|
+
formatOptions?: Intl.NumberFormatOptions;
|
|
178
|
+
}) {
|
|
179
|
+
const text = formatValue(value, fraction, valueLabel, formatOptions);
|
|
180
|
+
return {
|
|
181
|
+
label: accessibilityLabel ?? label,
|
|
182
|
+
text,
|
|
183
|
+
value: { min: minValue, max: maxValue, now: value, text },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
useContext,
|
|
65
65
|
useEffect,
|
|
66
66
|
useMemo,
|
|
67
|
+
useRef,
|
|
67
68
|
useState,
|
|
68
69
|
type ReactElement,
|
|
69
70
|
type ReactNode,
|
|
@@ -71,6 +72,7 @@ import {
|
|
|
71
72
|
import {
|
|
72
73
|
AccessibilityInfo,
|
|
73
74
|
AppState,
|
|
75
|
+
Platform,
|
|
74
76
|
Pressable,
|
|
75
77
|
View,
|
|
76
78
|
type ViewProps,
|
|
@@ -107,6 +109,12 @@ import {
|
|
|
107
109
|
visibleEntries,
|
|
108
110
|
type PlannerCountedCategory,
|
|
109
111
|
} from './planner-entries';
|
|
112
|
+
import { usePlannerMonthAnnouncement } from './planner-announcement';
|
|
113
|
+
import { plannerGridTarget } from './planner-grid-navigation';
|
|
114
|
+
import {
|
|
115
|
+
usePlannerMonthLifecycle,
|
|
116
|
+
usePlannerSelectionLifecycle,
|
|
117
|
+
} from './planner-lifecycle';
|
|
110
118
|
|
|
111
119
|
/** How many of a day's entries a cell draws before it stops and counts. */
|
|
112
120
|
const DEFAULT_ENTRY_LIMIT = 2;
|
|
@@ -114,6 +122,11 @@ const DEFAULT_ENTRY_LIMIT = 2;
|
|
|
114
122
|
/** The palette a category takes its dot from when it does not name a colour. */
|
|
115
123
|
const PALETTE_SIZE = 5;
|
|
116
124
|
|
|
125
|
+
/** Stable wrapper keeps the native method's receiver and the hook dependency steady. */
|
|
126
|
+
const announceMonth = (label: string) => {
|
|
127
|
+
AccessibilityInfo.announceForAccessibility(label);
|
|
128
|
+
};
|
|
129
|
+
|
|
117
130
|
const plannerVariants = tv({
|
|
118
131
|
slots: {
|
|
119
132
|
grid: 'gap-1 px-3 pb-3 pt-1',
|
|
@@ -203,6 +216,7 @@ interface PlannerContextValue {
|
|
|
203
216
|
summary: { total: number; categories: PlannerCountedCategory[] };
|
|
204
217
|
entryLimit: number;
|
|
205
218
|
weekStartsOn: number;
|
|
219
|
+
grid: Date[][];
|
|
206
220
|
locale: DateLocale;
|
|
207
221
|
system: 'gregory' | 'islamic';
|
|
208
222
|
isInMonth: (date: Date) => boolean;
|
|
@@ -315,34 +329,51 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
|
|
|
315
329
|
const system = resolveCalendar(calendar, locale);
|
|
316
330
|
const palette = usePalette();
|
|
317
331
|
const today = useToday();
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
332
|
+
const settleMonth = useCallback(
|
|
333
|
+
(date: Date) => startOfCalendarMonth(date, system, locale),
|
|
334
|
+
[system, locale]
|
|
321
335
|
);
|
|
322
|
-
const month = monthProp
|
|
323
|
-
? startOfCalendarMonth(monthProp, system, locale)
|
|
324
|
-
: internalMonth;
|
|
325
336
|
|
|
337
|
+
const [month, requestMonth] = usePlannerMonthLifecycle({
|
|
338
|
+
month: monthProp,
|
|
339
|
+
defaultMonth,
|
|
340
|
+
settleMonth,
|
|
341
|
+
onMonthChange,
|
|
342
|
+
});
|
|
343
|
+
/*
|
|
344
|
+
* The announcement is registered against the month a press asks for, and
|
|
345
|
+
* spoken only once a commit arrives carrying it. That is what keeps a
|
|
346
|
+
* controlled parent's rejection silent, so it wraps the lifecycle's setter
|
|
347
|
+
* rather than living inside it — the hook owns which month is current, and
|
|
348
|
+
* this owns whether the change was the user's to hear about.
|
|
349
|
+
*/
|
|
350
|
+
const expectMonthAnnouncement = usePlannerMonthAnnouncement({
|
|
351
|
+
monthKey: month.getTime(),
|
|
352
|
+
monthLabel: calendarMonthLabel(month, system, locale),
|
|
353
|
+
announce: announceMonth,
|
|
354
|
+
});
|
|
326
355
|
const setMonth = useCallback(
|
|
327
356
|
(next: Date) => {
|
|
328
|
-
|
|
329
|
-
|
|
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);
|
|
357
|
+
expectMonthAnnouncement(settleMonth(next));
|
|
358
|
+
requestMonth(next);
|
|
341
359
|
},
|
|
342
|
-
[
|
|
360
|
+
[expectMonthAnnouncement, requestMonth, settleMonth]
|
|
343
361
|
);
|
|
362
|
+
const [selected, select] = usePlannerSelectionLifecycle({
|
|
363
|
+
selected: selectedProp,
|
|
364
|
+
defaultSelected,
|
|
365
|
+
onSelectedChange,
|
|
366
|
+
});
|
|
344
367
|
|
|
345
368
|
const days = useMemo(() => bucketByDay(entries), [entries]);
|
|
369
|
+
const normalizedWeekStart =
|
|
370
|
+
weekStartsOn === 'auto'
|
|
371
|
+
? localeWeekStart(locale)
|
|
372
|
+
: normalizeWeekStart(weekStartsOn);
|
|
373
|
+
const grid = useMemo(
|
|
374
|
+
() => monthGrid(month, normalizedWeekStart, system, locale),
|
|
375
|
+
[month, normalizedWeekStart, system, locale]
|
|
376
|
+
);
|
|
346
377
|
|
|
347
378
|
const isInMonth = useCallback(
|
|
348
379
|
(date: Date) => isSameCalendarMonth(date, month, system, locale),
|
|
@@ -354,34 +385,33 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
|
|
|
354
385
|
[categories]
|
|
355
386
|
);
|
|
356
387
|
|
|
357
|
-
const
|
|
358
|
-
(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
388
|
+
const categoryColors = useMemo(
|
|
389
|
+
() => {
|
|
390
|
+
const colors = new Map<string, string | undefined>();
|
|
391
|
+
categories.forEach((category, index) => {
|
|
392
|
+
// `findIndex` used to make the first duplicate id authoritative.
|
|
393
|
+
if (colors.has(category.id)) return;
|
|
394
|
+
const slot = (category.colorIndex ?? index + 1) - 1;
|
|
395
|
+
colors.set(
|
|
396
|
+
category.id,
|
|
397
|
+
category.color ??
|
|
398
|
+
palette[((slot % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE]
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
return colors;
|
|
366
402
|
},
|
|
367
403
|
[categories, palette]
|
|
368
404
|
);
|
|
405
|
+
const colorOf = useCallback(
|
|
406
|
+
(id: string | undefined) => id ? categoryColors.get(id) : undefined,
|
|
407
|
+
[categoryColors]
|
|
408
|
+
);
|
|
369
409
|
|
|
370
410
|
const summary = useMemo(
|
|
371
|
-
() => summariseMonth(
|
|
372
|
-
[
|
|
411
|
+
() => summariseMonth(days, grid, categories, isInMonth),
|
|
412
|
+
[days, grid, categories, isInMonth]
|
|
373
413
|
);
|
|
374
414
|
|
|
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
415
|
const context = useMemo<PlannerContextValue>(
|
|
386
416
|
() => ({
|
|
387
417
|
month,
|
|
@@ -395,10 +425,8 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
|
|
|
395
425
|
colorOf,
|
|
396
426
|
summary,
|
|
397
427
|
entryLimit,
|
|
398
|
-
weekStartsOn:
|
|
399
|
-
|
|
400
|
-
? localeWeekStart(locale)
|
|
401
|
-
: normalizeWeekStart(weekStartsOn),
|
|
428
|
+
weekStartsOn: normalizedWeekStart,
|
|
429
|
+
grid,
|
|
402
430
|
locale,
|
|
403
431
|
system,
|
|
404
432
|
isInMonth,
|
|
@@ -406,7 +434,8 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
|
|
|
406
434
|
}),
|
|
407
435
|
[
|
|
408
436
|
month, setMonth, today, selected, select, days, categories, categoryLabels,
|
|
409
|
-
colorOf, summary, entryLimit,
|
|
437
|
+
colorOf, summary, entryLimit, normalizedWeekStart, grid, locale, system,
|
|
438
|
+
isInMonth, onDayPress,
|
|
410
439
|
]
|
|
411
440
|
);
|
|
412
441
|
|
|
@@ -600,6 +629,21 @@ export interface PlannerGridProps {
|
|
|
600
629
|
renderDay?: PlannerDayRenderer;
|
|
601
630
|
}
|
|
602
631
|
|
|
632
|
+
interface PlannerGridKeyDownEvent {
|
|
633
|
+
nativeEvent: { key?: string };
|
|
634
|
+
preventDefault: () => void;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
interface PlannerGridNavigationContextValue {
|
|
638
|
+
activeIndex: number;
|
|
639
|
+
indexByDay: ReadonlyMap<number, number>;
|
|
640
|
+
register: (index: number, node: View | null) => void;
|
|
641
|
+
focus: (index: number, event: PlannerGridKeyDownEvent) => void;
|
|
642
|
+
makeActive: (index: number) => void;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const PlannerGridNavigationContext = createContext<PlannerGridNavigationContextValue | null>(null);
|
|
646
|
+
|
|
603
647
|
/**
|
|
604
648
|
* The weekday row and the six weeks below it.
|
|
605
649
|
*
|
|
@@ -609,40 +653,77 @@ export interface PlannerGridProps {
|
|
|
609
653
|
* weekday headings are hidden rather than read out 42 times over.
|
|
610
654
|
*/
|
|
611
655
|
function PlannerGrid({ className, renderDay }: PlannerGridProps) {
|
|
612
|
-
const {
|
|
656
|
+
const { grid: weeks, weekStartsOn, locale, selected, today, isInMonth } =
|
|
657
|
+
usePlanner('Planner.Grid');
|
|
613
658
|
const { grid, week: weekRow, heading } = plannerVariants();
|
|
614
|
-
|
|
615
|
-
const weeks = useMemo(
|
|
616
|
-
() => monthGrid(month, weekStartsOn, system, locale),
|
|
617
|
-
[month, weekStartsOn, system, locale]
|
|
618
|
-
);
|
|
619
659
|
const headings = useMemo(
|
|
620
660
|
() => weekdayNames(locale, weekStartsOn),
|
|
621
661
|
[locale, weekStartsOn]
|
|
622
662
|
);
|
|
663
|
+
const dates = useMemo(() => weeks.flat(), [weeks]);
|
|
664
|
+
const indexByDay = useMemo(
|
|
665
|
+
() => new Map(dates.map((date, index) => [date.getTime(), index])),
|
|
666
|
+
[dates]
|
|
667
|
+
);
|
|
668
|
+
const initialDay = selected ?? today;
|
|
669
|
+
const [activeDay, setActiveDay] = useState(initialDay.getTime());
|
|
670
|
+
const storedIndex = indexByDay.get(activeDay);
|
|
671
|
+
const preferredIndex =
|
|
672
|
+
(selected ? indexByDay.get(startOfDay(selected).getTime()) : undefined) ??
|
|
673
|
+
indexByDay.get(today.getTime()) ??
|
|
674
|
+
dates.findIndex(isInMonth);
|
|
675
|
+
const activeIndex = storedIndex ?? Math.max(0, preferredIndex);
|
|
676
|
+
const refs = useRef(new Map<number, View>());
|
|
677
|
+
const register = useCallback((index: number, node: View | null) => {
|
|
678
|
+
if (node) refs.current.set(index, node);
|
|
679
|
+
else refs.current.delete(index);
|
|
680
|
+
}, []);
|
|
681
|
+
const makeActive = useCallback(
|
|
682
|
+
(index: number) => {
|
|
683
|
+
const date = dates[index];
|
|
684
|
+
if (date) setActiveDay(date.getTime());
|
|
685
|
+
},
|
|
686
|
+
[dates]
|
|
687
|
+
);
|
|
688
|
+
const focus = useCallback(
|
|
689
|
+
(index: number, event: PlannerGridKeyDownEvent) => {
|
|
690
|
+
const target = plannerGridTarget(event.nativeEvent.key ?? '', index, dates.length);
|
|
691
|
+
if (target === null) return;
|
|
692
|
+
event.preventDefault();
|
|
693
|
+
makeActive(target);
|
|
694
|
+
refs.current.get(target)?.focus();
|
|
695
|
+
},
|
|
696
|
+
[dates.length, makeActive]
|
|
697
|
+
);
|
|
698
|
+
const navigation = useMemo<PlannerGridNavigationContextValue>(
|
|
699
|
+
() => ({ activeIndex, indexByDay, register, focus, makeActive }),
|
|
700
|
+
[activeIndex, indexByDay, register, focus, makeActive]
|
|
701
|
+
);
|
|
623
702
|
|
|
624
703
|
return (
|
|
625
|
-
<
|
|
626
|
-
<View
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
{label
|
|
634
|
-
|
|
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} />
|
|
704
|
+
<PlannerGridNavigationContext.Provider value={renderDay ? null : navigation}>
|
|
705
|
+
<View className={grid({ className })}>
|
|
706
|
+
<View
|
|
707
|
+
className={weekRow()}
|
|
708
|
+
accessibilityElementsHidden
|
|
709
|
+
importantForAccessibility="no-hide-descendants"
|
|
710
|
+
>
|
|
711
|
+
{headings.map((label) => (
|
|
712
|
+
<Text key={label} size="xs" muted className={heading()}>
|
|
713
|
+
{label.toUpperCase()}
|
|
714
|
+
</Text>
|
|
642
715
|
))}
|
|
643
716
|
</View>
|
|
644
|
-
|
|
645
|
-
|
|
717
|
+
|
|
718
|
+
{weeks.map((week, index) => (
|
|
719
|
+
<View key={index} className={weekRow()}>
|
|
720
|
+
{week.map((date) => (
|
|
721
|
+
<PlannerDay key={date.getTime()} date={date} renderDay={renderDay} />
|
|
722
|
+
))}
|
|
723
|
+
</View>
|
|
724
|
+
))}
|
|
725
|
+
</View>
|
|
726
|
+
</PlannerGridNavigationContext.Provider>
|
|
646
727
|
);
|
|
647
728
|
}
|
|
648
729
|
PlannerGrid.displayName = 'Planner.Grid';
|
|
@@ -658,6 +739,10 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
|
|
|
658
739
|
days, today, selected, select, colorOf, categoryLabels,
|
|
659
740
|
entryLimit, locale, system, isInMonth, onDayPress,
|
|
660
741
|
} = usePlanner('Planner.Day');
|
|
742
|
+
const navigation = useContext(PlannerGridNavigationContext);
|
|
743
|
+
const registerGridCell = navigation?.register;
|
|
744
|
+
const focusGridCell = navigation?.focus;
|
|
745
|
+
const makeGridCellActive = navigation?.makeActive;
|
|
661
746
|
|
|
662
747
|
const entries = entriesOn(days, date);
|
|
663
748
|
const inMonth = isInMonth(date);
|
|
@@ -677,6 +762,31 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
|
|
|
677
762
|
onDayPress?.(date, entries);
|
|
678
763
|
select(date);
|
|
679
764
|
};
|
|
765
|
+
const gridIndex = navigation?.indexByDay.get(date.getTime());
|
|
766
|
+
const setRef = useCallback(
|
|
767
|
+
(node: View | null) => {
|
|
768
|
+
if (gridIndex !== undefined) registerGridCell?.(gridIndex, node);
|
|
769
|
+
},
|
|
770
|
+
[gridIndex, registerGridCell]
|
|
771
|
+
);
|
|
772
|
+
const onFocus = useCallback(() => {
|
|
773
|
+
if (gridIndex !== undefined) makeGridCellActive?.(gridIndex);
|
|
774
|
+
}, [gridIndex, makeGridCellActive]);
|
|
775
|
+
const onKeyDown = useCallback(
|
|
776
|
+
(event: PlannerGridKeyDownEvent) => {
|
|
777
|
+
if (gridIndex !== undefined) focusGridCell?.(gridIndex, event);
|
|
778
|
+
},
|
|
779
|
+
[gridIndex, focusGridCell]
|
|
780
|
+
);
|
|
781
|
+
const webGridProps =
|
|
782
|
+
Platform.OS === 'web' && gridIndex !== undefined && navigation
|
|
783
|
+
? {
|
|
784
|
+
ref: setRef,
|
|
785
|
+
tabIndex: navigation.activeIndex === gridIndex ? (0 as const) : (-1 as const),
|
|
786
|
+
onFocus,
|
|
787
|
+
onKeyDown,
|
|
788
|
+
}
|
|
789
|
+
: {};
|
|
680
790
|
|
|
681
791
|
if (renderDay) {
|
|
682
792
|
return (
|
|
@@ -688,6 +798,7 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
|
|
|
688
798
|
|
|
689
799
|
return (
|
|
690
800
|
<Pressable
|
|
801
|
+
{...(webGridProps as ViewProps)}
|
|
691
802
|
onPress={press}
|
|
692
803
|
accessibilityRole="button"
|
|
693
804
|
accessibilityLabel={label}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
interface PlannerMonthAnnouncementOptions {
|
|
4
|
+
/** The settled month currently visible in the grid. */
|
|
5
|
+
monthKey: number;
|
|
6
|
+
monthLabel: string;
|
|
7
|
+
announce: (label: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Announces a month only when the next committed render accepts a user request.
|
|
12
|
+
*
|
|
13
|
+
* Prop-driven months stay silent. In a controlled planner, a render that keeps
|
|
14
|
+
* the previous month rejects the pending request and clears it without speaking.
|
|
15
|
+
*/
|
|
16
|
+
export function usePlannerMonthAnnouncement({
|
|
17
|
+
monthKey,
|
|
18
|
+
monthLabel,
|
|
19
|
+
announce,
|
|
20
|
+
}: PlannerMonthAnnouncementOptions): (month: Date) => void {
|
|
21
|
+
const pendingMonth = useRef<number | null>(null);
|
|
22
|
+
|
|
23
|
+
const expectMonth = useCallback((month: Date) => {
|
|
24
|
+
pendingMonth.current = month.getTime();
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
// Deliberately runs after every commit: an unchanged controlled render is
|
|
28
|
+
// how a parent rejects a request, while a changed matching render accepts it.
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const expected = pendingMonth.current;
|
|
31
|
+
if (expected === null) return;
|
|
32
|
+
pendingMonth.current = null;
|
|
33
|
+
if (expected === monthKey) announce(monthLabel);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return expectMonth;
|
|
37
|
+
}
|