@lotics/ui 42.3.0 → 43.0.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/MIGRATION.md +115 -0
- package/docs/catalog.md +49 -7
- package/docs/composition.md +21 -1
- package/docs/data_entry.md +9 -4
- package/package.json +3 -1
- package/src/back_button.tsx +4 -1
- package/src/check_circle.tsx +1 -2
- package/src/checkbox_input.tsx +2 -2
- package/src/choice_list.tsx +2 -2
- package/src/comments_button.tsx +3 -1
- package/src/control_surface.ts +27 -0
- package/src/data_grid.tsx +1 -1
- package/src/date_calendar.tsx +210 -58
- package/src/date_filter.tsx +1 -5
- package/src/date_picker.tsx +2 -0
- package/src/date_range_selection.ts +18 -0
- package/src/date_segments.ts +15 -1
- package/src/file_dropzone.tsx +2 -1
- package/src/file_row.tsx +2 -2
- package/src/file_thumbnail.tsx +4 -1
- package/src/focus_ring_pressable.tsx +6 -1
- package/src/icon_button.tsx +3 -1
- package/src/image_gallery.tsx +1 -1
- package/src/inline_button.tsx +3 -1
- package/src/inline_time_picker.tsx +97 -48
- package/src/list_item.tsx +109 -11
- package/src/locale.tsx +9 -2
- package/src/menu_button.tsx +19 -0
- package/src/option_list.tsx +8 -0
- package/src/picker.tsx +50 -5
- package/src/picker_empty_option.ts +30 -0
- package/src/pressable_highlight.tsx +20 -8
- package/src/pressable_row.tsx +2 -2
- package/src/scroll_to_bottom.tsx +3 -0
- package/src/slider.tsx +2 -2
- package/src/switch.tsx +3 -1
- package/src/text.tsx +16 -11
- package/src/time_columns.tsx +225 -0
- package/src/time_options.ts +138 -0
- package/src/time_picker.tsx +102 -64
- package/src/use_option_list.ts +19 -0
package/src/date_calendar.tsx
CHANGED
|
@@ -5,10 +5,10 @@ import { colors } from "./colors";
|
|
|
5
5
|
import { useScreenSize } from "./use_screen_size";
|
|
6
6
|
import { Picker, PickerOption } from "./picker";
|
|
7
7
|
import { IconButton } from "./icon_button";
|
|
8
|
-
import { FOCUS_RING } from "./control_surface";
|
|
8
|
+
import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
|
|
9
9
|
import { useFocusRing } from "./use_focus_ring";
|
|
10
10
|
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
11
|
-
import { nextRangeSelection } from "./date_range_selection";
|
|
11
|
+
import { nextRangeSelection, previewEndFor } from "./date_range_selection";
|
|
12
12
|
|
|
13
13
|
/** Accessible names for the calendar's month-navigation arrows. Backs the
|
|
14
14
|
* `calendar` locale slice. (The month/weekday NAMES come from `Intl` + the
|
|
@@ -18,13 +18,29 @@ export interface CalendarLabels {
|
|
|
18
18
|
nextMonth: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* One day: a full-width SLOT that carries the range bar, and a circle inside it
|
|
23
|
+
* that carries selection.
|
|
24
|
+
*
|
|
25
|
+
* Two layers because the two states have different shapes. A range is a
|
|
26
|
+
* CONTINUOUS band across the days it covers — cells drawn as separate rounded
|
|
27
|
+
* pills read as a row of unrelated chips, not as one span — while a selected day
|
|
28
|
+
* is a disc. So the slot paints the band edge to edge and rounds only where the
|
|
29
|
+
* span actually ends (`capStart` / `capEnd`, which the row computes from its
|
|
30
|
+
* neighbours, so a span wrapping to the next week caps at the row edge and
|
|
31
|
+
* resumes on the line below).
|
|
32
|
+
*/
|
|
21
33
|
function DayCell(props: {
|
|
22
34
|
day: number;
|
|
23
35
|
label: string;
|
|
24
36
|
isActive: boolean;
|
|
25
|
-
|
|
37
|
+
inSpan: boolean;
|
|
38
|
+
capStart: boolean;
|
|
39
|
+
capEnd: boolean;
|
|
26
40
|
isToday: boolean;
|
|
27
41
|
onPress: () => void;
|
|
42
|
+
onHoverIn?: () => void;
|
|
43
|
+
onHoverOut?: () => void;
|
|
28
44
|
}) {
|
|
29
45
|
const { focusVisible, focusProps } = useFocusRing();
|
|
30
46
|
return (
|
|
@@ -35,24 +51,38 @@ function DayCell(props: {
|
|
|
35
51
|
// build, and `selected` has no meaning on a `button` role regardless.
|
|
36
52
|
aria-pressed={props.isActive}
|
|
37
53
|
{...focusProps}
|
|
38
|
-
style={
|
|
39
|
-
styles.
|
|
40
|
-
props.
|
|
41
|
-
props.
|
|
42
|
-
|
|
43
|
-
focusVisible && { boxShadow: FOCUS_RING },
|
|
54
|
+
style={[
|
|
55
|
+
styles.daySlot,
|
|
56
|
+
props.inSpan && styles.spanFill,
|
|
57
|
+
props.inSpan && props.capStart && styles.spanCapStart,
|
|
58
|
+
props.inSpan && props.capEnd && styles.spanCapEnd,
|
|
44
59
|
]}
|
|
45
60
|
onPress={props.onPress}
|
|
61
|
+
onHoverIn={props.onHoverIn}
|
|
62
|
+
onHoverOut={props.onHoverOut}
|
|
46
63
|
>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
{({ hovered }: { hovered?: boolean }) => (
|
|
65
|
+
<View
|
|
66
|
+
style={[
|
|
67
|
+
styles.dayCircle,
|
|
68
|
+
props.isActive && styles.dayCircleSelected,
|
|
69
|
+
// A day already in the span keeps the band rather than fading under
|
|
70
|
+
// the pointer — the wash is lighter than the fill it would replace.
|
|
71
|
+
hovered && !props.isActive && !props.inSpan && styles.dayCircleHovered,
|
|
72
|
+
focusVisible && { boxShadow: FOCUS_RING },
|
|
73
|
+
]}
|
|
74
|
+
>
|
|
75
|
+
<Text
|
|
76
|
+
size="sm"
|
|
77
|
+
color={props.isActive ? "inverted" : "default"}
|
|
78
|
+
userSelect="none"
|
|
79
|
+
weight={props.isToday ? "semibold" : "regular"}
|
|
80
|
+
>
|
|
81
|
+
{props.day}
|
|
82
|
+
</Text>
|
|
83
|
+
{props.isToday && <View style={[styles.todayDot, props.isActive && styles.todayDotInverted]} />}
|
|
84
|
+
</View>
|
|
85
|
+
)}
|
|
56
86
|
</Pressable>
|
|
57
87
|
);
|
|
58
88
|
}
|
|
@@ -112,8 +142,18 @@ interface CalendarMonthProps {
|
|
|
112
142
|
selectedDate: Date | null;
|
|
113
143
|
rangeStart: Date | null;
|
|
114
144
|
rangeEnd: Date | null;
|
|
145
|
+
/**
|
|
146
|
+
* The end a range WOULD have if the pointer stopped here — set while a range is
|
|
147
|
+
* open (a start, no end) and the pointer is over a day. It fills the span like
|
|
148
|
+
* a committed range without marking either end selected, so the feedback says
|
|
149
|
+
* "this is what you are about to pick", not "this is picked".
|
|
150
|
+
*/
|
|
151
|
+
previewEnd?: Date | null;
|
|
115
152
|
isRange: boolean;
|
|
116
153
|
onDateSelect: (date: Date) => void;
|
|
154
|
+
/** The pointer entered (`true`) or left (`false`) a day. The OWNER decides what
|
|
155
|
+
* that means — the leave of a day you already moved off is not a clear. */
|
|
156
|
+
onDayPointer?: (date: Date, entering: boolean) => void;
|
|
117
157
|
onMonthChange: (year: number, month: number) => void;
|
|
118
158
|
showNavigation?: boolean;
|
|
119
159
|
/** Show only left arrow (for range mode left calendar) */
|
|
@@ -212,6 +252,12 @@ function isSameDay(date1: Date | null, date2: Date | null): boolean {
|
|
|
212
252
|
);
|
|
213
253
|
}
|
|
214
254
|
|
|
255
|
+
/** A day's disc — one size in every state, so nothing grows under the pointer. */
|
|
256
|
+
const DAY_DISC = 36;
|
|
257
|
+
/** The narrowest a day's slot may be: the disc plus the air that keeps two
|
|
258
|
+
* neighbouring discs apart. Seven of these is the month's minimum width. */
|
|
259
|
+
const DAY_SLOT_MIN = DAY_DISC + 4;
|
|
260
|
+
|
|
215
261
|
function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
|
|
216
262
|
if (!start || !end) return false;
|
|
217
263
|
const time = date.getTime();
|
|
@@ -220,6 +266,18 @@ function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
|
|
|
220
266
|
return time > Math.min(startTime, endTime) && time < Math.max(startTime, endTime);
|
|
221
267
|
}
|
|
222
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Between `start` and `end` INCLUSIVE, in either order — the span a range covers
|
|
271
|
+
* on screen.
|
|
272
|
+
*
|
|
273
|
+
* A single day is not a span: with both bounds on the same date the band would be
|
|
274
|
+
* a capsule drawn behind one disc, which says nothing the disc does not.
|
|
275
|
+
*/
|
|
276
|
+
function inClosedRange(date: Date, start: Date | null, end: Date | null | undefined): boolean {
|
|
277
|
+
if (!start || !end || isSameDay(start, end)) return false;
|
|
278
|
+
return isInRange(date, start, end) || isSameDay(date, start) || isSameDay(date, end);
|
|
279
|
+
}
|
|
280
|
+
|
|
223
281
|
function isToday(date: Date): boolean {
|
|
224
282
|
const today = new Date();
|
|
225
283
|
return isSameDay(date, today);
|
|
@@ -236,8 +294,10 @@ function CalendarMonth(props: CalendarMonthProps) {
|
|
|
236
294
|
selectedDate,
|
|
237
295
|
rangeStart,
|
|
238
296
|
rangeEnd,
|
|
297
|
+
previewEnd,
|
|
239
298
|
isRange,
|
|
240
299
|
onDateSelect,
|
|
300
|
+
onDayPointer,
|
|
241
301
|
onMonthChange,
|
|
242
302
|
showNavigation = true,
|
|
243
303
|
showLeftArrow = false,
|
|
@@ -384,35 +444,64 @@ function CalendarMonth(props: CalendarMonthProps) {
|
|
|
384
444
|
</View>
|
|
385
445
|
|
|
386
446
|
{/* Calendar grid */}
|
|
387
|
-
{weeks.map((week, weekIndex) =>
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
447
|
+
{weeks.map((week, weekIndex) => {
|
|
448
|
+
// Whether each day of THIS row sits in the span — computed for the row
|
|
449
|
+
// before any cell renders, because a cell's rounded caps depend on what
|
|
450
|
+
// its neighbours are doing, not on itself.
|
|
451
|
+
//
|
|
452
|
+
// CLOSED at both ends: the band runs under the endpoint discs rather than
|
|
453
|
+
// starting after one and stopping before the other, which read as a
|
|
454
|
+
// selected day sitting outside the very span it bounds. (`isInRange` is
|
|
455
|
+
// strictly between, because it was written for a fill that the dark
|
|
456
|
+
// bounds capped.) It sorts its arguments, so hovering BACKWARDS from the
|
|
457
|
+
// start previews exactly as well as forwards.
|
|
458
|
+
const inSpans = week.map((day) => {
|
|
459
|
+
if (day === null || !isRange) return false;
|
|
460
|
+
const date = new Date(year, month, day);
|
|
461
|
+
return (
|
|
462
|
+
inClosedRange(date, rangeStart, rangeEnd) ||
|
|
463
|
+
inClosedRange(date, rangeStart, previewEnd)
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
return (
|
|
468
|
+
<View key={weekIndex} style={styles.weekRow}>
|
|
469
|
+
{week.map((day, dayIndex) => {
|
|
470
|
+
if (day === null) {
|
|
471
|
+
return <View key={dayIndex} style={styles.daySlot} />;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const date = new Date(year, month, day);
|
|
475
|
+
const isSelected = isSameDay(date, selectedDate);
|
|
476
|
+
const isRangeStart = isRange && isSameDay(date, rangeStart);
|
|
477
|
+
const isRangeEnd = isRange && isSameDay(date, rangeEnd);
|
|
478
|
+
const isTodayDate = isToday(date);
|
|
479
|
+
// The day under the pointer wears the SELECTED disc, because the
|
|
480
|
+
// preview answers "what do I get if I click here" and what you get
|
|
481
|
+
// is a selected end. A third, in-between shade would be a state the
|
|
482
|
+
// system does not otherwise have.
|
|
483
|
+
const isPreviewEnd = previewEnd != null && isSameDay(date, previewEnd);
|
|
484
|
+
const isActive = isSelected || isRangeStart || isRangeEnd || isPreviewEnd;
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<DayCell
|
|
488
|
+
key={dayIndex}
|
|
489
|
+
day={day}
|
|
490
|
+
label={names.formatDayLabel(date)}
|
|
491
|
+
isActive={isActive}
|
|
492
|
+
inSpan={inSpans[dayIndex]}
|
|
493
|
+
capStart={!inSpans[dayIndex - 1]}
|
|
494
|
+
capEnd={!inSpans[dayIndex + 1]}
|
|
495
|
+
isToday={isTodayDate}
|
|
496
|
+
onPress={() => onDateSelect(date)}
|
|
497
|
+
onHoverIn={onDayPointer ? () => onDayPointer(date, true) : undefined}
|
|
498
|
+
onHoverOut={onDayPointer ? () => onDayPointer(date, false) : undefined}
|
|
499
|
+
/>
|
|
500
|
+
);
|
|
501
|
+
})}
|
|
502
|
+
</View>
|
|
503
|
+
);
|
|
504
|
+
})}
|
|
416
505
|
</View>
|
|
417
506
|
);
|
|
418
507
|
}
|
|
@@ -542,6 +631,27 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
|
|
|
542
631
|
[value, onValueChange],
|
|
543
632
|
);
|
|
544
633
|
|
|
634
|
+
// The day under the pointer, so an OPEN range can show the span it would close
|
|
635
|
+
// on. Held here rather than per month, because the two calendars are one grid
|
|
636
|
+
// to a member dragging across a month boundary.
|
|
637
|
+
const [hoveredDate, setHoveredDate] = useState<Date | null>(null);
|
|
638
|
+
|
|
639
|
+
const handleDayPointer = useCallback((date: Date, entering: boolean) => {
|
|
640
|
+
if (entering) {
|
|
641
|
+
setHoveredDate(date);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
// Moving between cells fires the old cell's LEAVE and the new cell's ENTER in
|
|
645
|
+
// no guaranteed order, so an unconditional clear would blank the preview on
|
|
646
|
+
// every step across the grid. Clear only while this day is still the one
|
|
647
|
+
// being reported — which also means leaving the grid entirely does clear it.
|
|
648
|
+
setHoveredDate((current) => (current && isSameDay(current, date) ? null : current));
|
|
649
|
+
}, []);
|
|
650
|
+
|
|
651
|
+
// Shares its open-ness test with `nextRangeSelection`, so the span shown can
|
|
652
|
+
// never disagree with the one the next click produces.
|
|
653
|
+
const previewEnd = previewEndFor(value, hoveredDate);
|
|
654
|
+
|
|
545
655
|
const handleMonthChange = useCallback((year: number, month: number) => {
|
|
546
656
|
setLeftYear(year);
|
|
547
657
|
setLeftMonth(month);
|
|
@@ -577,8 +687,10 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
|
|
|
577
687
|
selectedDate={null}
|
|
578
688
|
rangeStart={value.start}
|
|
579
689
|
rangeEnd={value.end}
|
|
690
|
+
previewEnd={previewEnd}
|
|
580
691
|
isRange={true}
|
|
581
692
|
onDateSelect={handleDateSelect}
|
|
693
|
+
onDayPointer={handleDayPointer}
|
|
582
694
|
onMonthChange={handleMonthChange}
|
|
583
695
|
showNavigation={true}
|
|
584
696
|
firstDayOfWeek={firstDayOfWeek}
|
|
@@ -597,8 +709,10 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
|
|
|
597
709
|
selectedDate={null}
|
|
598
710
|
rangeStart={value.start}
|
|
599
711
|
rangeEnd={value.end}
|
|
712
|
+
previewEnd={previewEnd}
|
|
600
713
|
isRange={true}
|
|
601
714
|
onDateSelect={handleDateSelect}
|
|
715
|
+
onDayPointer={handleDayPointer}
|
|
602
716
|
onMonthChange={handleMonthChange}
|
|
603
717
|
showNavigation={false}
|
|
604
718
|
showLeftArrow={true}
|
|
@@ -612,8 +726,10 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
|
|
|
612
726
|
selectedDate={null}
|
|
613
727
|
rangeStart={value.start}
|
|
614
728
|
rangeEnd={value.end}
|
|
729
|
+
previewEnd={previewEnd}
|
|
615
730
|
isRange={true}
|
|
616
731
|
onDateSelect={handleDateSelect}
|
|
732
|
+
onDayPointer={handleDayPointer}
|
|
617
733
|
onMonthChange={handleMonthChange}
|
|
618
734
|
showNavigation={false}
|
|
619
735
|
showRightArrow={true}
|
|
@@ -653,36 +769,72 @@ const styles = StyleSheet.create({
|
|
|
653
769
|
},
|
|
654
770
|
weekHeader: {
|
|
655
771
|
flexDirection: "row",
|
|
656
|
-
justifyContent: "space-around",
|
|
657
772
|
borderBottomWidth: 1,
|
|
658
773
|
borderBottomColor: colors.zinc["200"],
|
|
659
774
|
paddingBottom: 8,
|
|
660
775
|
marginBottom: 4,
|
|
661
776
|
},
|
|
662
777
|
dayHeaderCell: {
|
|
663
|
-
|
|
778
|
+
// Shares the day slots' sizing so the column heads stay over their columns.
|
|
779
|
+
flex: 1,
|
|
780
|
+
minWidth: DAY_SLOT_MIN,
|
|
664
781
|
alignItems: "center",
|
|
665
782
|
justifyContent: "center",
|
|
666
783
|
height: 32,
|
|
667
784
|
},
|
|
668
785
|
weekRow: {
|
|
669
786
|
flexDirection: "row",
|
|
670
|
-
|
|
787
|
+
// Rows are separated so one week's band cannot touch the next one's. Without
|
|
788
|
+
// it the bands meet edge to edge down the whole grid and a range that wraps
|
|
789
|
+
// reads as a single block rather than as a span that continues on the line
|
|
790
|
+
// below.
|
|
791
|
+
marginBottom: 2,
|
|
671
792
|
},
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
793
|
+
// The band's carrier, and the reason the grid FILLS its month rather than
|
|
794
|
+
// leaving slack at the right edge: seven equal shares of whatever width the
|
|
795
|
+
// month has, floored so the disc inside always has room to breathe.
|
|
796
|
+
//
|
|
797
|
+
// Cells sit EDGE TO EDGE — spaced apart, a range would paint as separate pills
|
|
798
|
+
// with gaps between them rather than as one span.
|
|
799
|
+
daySlot: {
|
|
800
|
+
cursor: CURSOR_DEFAULT,
|
|
801
|
+
flex: 1,
|
|
802
|
+
minWidth: DAY_SLOT_MIN,
|
|
803
|
+
height: DAY_DISC,
|
|
804
|
+
alignItems: "center",
|
|
805
|
+
justifyContent: "center",
|
|
806
|
+
},
|
|
807
|
+
spanFill: {
|
|
808
|
+
backgroundColor: colors.zinc["100"],
|
|
809
|
+
},
|
|
810
|
+
// Rounded only where the span actually ends — including at a row's edge, so a
|
|
811
|
+
// span that wraps closes on one line and reopens on the next.
|
|
812
|
+
spanCapStart: {
|
|
813
|
+
borderTopLeftRadius: 999,
|
|
814
|
+
borderBottomLeftRadius: 999,
|
|
815
|
+
},
|
|
816
|
+
spanCapEnd: {
|
|
817
|
+
borderTopRightRadius: 999,
|
|
818
|
+
borderBottomRightRadius: 999,
|
|
819
|
+
},
|
|
820
|
+
// The SAME size in every state, and narrower than the slot that holds it.
|
|
821
|
+
//
|
|
822
|
+
// The slots are edge to edge — that is what makes the band continuous — so a
|
|
823
|
+
// disc as wide as its slot would touch the disc beside it, which is what had
|
|
824
|
+
// the hovered day and the selected one meeting as a single lozenge. The room
|
|
825
|
+
// comes from the SLOT being wider, not from shrinking the disc, so the days
|
|
826
|
+
// stay the size they were and spread further apart as the month gets wider.
|
|
827
|
+
dayCircle: {
|
|
828
|
+
width: DAY_DISC,
|
|
829
|
+
height: DAY_DISC,
|
|
675
830
|
alignItems: "center",
|
|
676
831
|
justifyContent: "center",
|
|
677
832
|
borderRadius: 999,
|
|
678
833
|
},
|
|
679
|
-
|
|
834
|
+
dayCircleSelected: {
|
|
680
835
|
backgroundColor: colors.zinc["800"],
|
|
681
836
|
},
|
|
682
|
-
|
|
683
|
-
backgroundColor: colors.zinc["100"],
|
|
684
|
-
},
|
|
685
|
-
dayCellHovered: {
|
|
837
|
+
dayCircleHovered: {
|
|
686
838
|
backgroundColor: colors.zinc["50"],
|
|
687
839
|
},
|
|
688
840
|
todayDot: {
|
package/src/date_filter.tsx
CHANGED
|
@@ -91,10 +91,9 @@ function RangeBound(props: {
|
|
|
91
91
|
bound: { date: Date | null; time: string | null };
|
|
92
92
|
includeTime: boolean;
|
|
93
93
|
localeTag: string;
|
|
94
|
-
segmentLabels: SegmentLabels;
|
|
95
94
|
onTimeChange: (time: string) => void;
|
|
96
95
|
}) {
|
|
97
|
-
const { label, placeholder, bound, includeTime, localeTag,
|
|
96
|
+
const { label, placeholder, bound, includeTime, localeTag, onTimeChange } = props;
|
|
98
97
|
return (
|
|
99
98
|
<View style={styles.rangeItem}>
|
|
100
99
|
<Text size="xs" color="zinc-500">
|
|
@@ -111,7 +110,6 @@ function RangeBound(props: {
|
|
|
111
110
|
disabled={!bound.date}
|
|
112
111
|
accessibilityLabel={label}
|
|
113
112
|
locale={localeTag}
|
|
114
|
-
segmentLabels={segmentLabels}
|
|
115
113
|
/>
|
|
116
114
|
</View>
|
|
117
115
|
)}
|
|
@@ -300,7 +298,6 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
300
298
|
bound={shown.start}
|
|
301
299
|
includeTime={includeTime}
|
|
302
300
|
localeTag={localeTag}
|
|
303
|
-
segmentLabels={labels}
|
|
304
301
|
onTimeChange={handleStartTimeChange}
|
|
305
302
|
/>
|
|
306
303
|
<RangeBound
|
|
@@ -309,7 +306,6 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
309
306
|
bound={shown.end}
|
|
310
307
|
includeTime={includeTime}
|
|
311
308
|
localeTag={localeTag}
|
|
312
|
-
segmentLabels={labels}
|
|
313
309
|
onTimeChange={handleEndTimeChange}
|
|
314
310
|
/>
|
|
315
311
|
</View>
|
package/src/date_picker.tsx
CHANGED
|
@@ -42,6 +42,8 @@ export interface DatePickerLabels extends SegmentLabels {
|
|
|
42
42
|
clear: string;
|
|
43
43
|
/** Accessible name for the calendar button. */
|
|
44
44
|
openCalendar: string;
|
|
45
|
+
/** Accessible name for the clock button that opens the time list. */
|
|
46
|
+
chooseTime: string;
|
|
45
47
|
/** Accessible name for the time field (single datetime). */
|
|
46
48
|
time: string;
|
|
47
49
|
/** Accessible name for the start-time field (datetime range). */
|
|
@@ -30,3 +30,21 @@ export function nextRangeSelection(
|
|
|
30
30
|
? { start: date, end: current.start }
|
|
31
31
|
: { start: current.start, end: date };
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The end a range would CLOSE on if the pointer stopped at `hovered` — what a
|
|
36
|
+
* calendar fills in to show the span before it is committed.
|
|
37
|
+
*
|
|
38
|
+
* `null` unless a range is actually open, and open is tested exactly the way
|
|
39
|
+
* {@link nextRangeSelection} tests it. That shared test is the point of putting
|
|
40
|
+
* this here: a preview that disagreed with the click would show a span the next
|
|
41
|
+
* click does not produce, and the two rules drifting apart is precisely what a
|
|
42
|
+
* second copy of "is a range open" would eventually do.
|
|
43
|
+
*/
|
|
44
|
+
export function previewEndFor(
|
|
45
|
+
current: DateRangeSelection,
|
|
46
|
+
hovered: Date | null,
|
|
47
|
+
): Date | null {
|
|
48
|
+
if (!current.start || current.end) return null;
|
|
49
|
+
return hovered;
|
|
50
|
+
}
|
package/src/date_segments.ts
CHANGED
|
@@ -57,6 +57,20 @@ export function emptyBuffer(): SegmentBuffer {
|
|
|
57
57
|
// Distinct sample components (2023 / 01 / 02 / 13:45) so part mapping is reliable.
|
|
58
58
|
const SAMPLE = new Date(2023, 0, 2, 13, 45, 0);
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The Intl options a TIME reads under — 12-vs-24-hour follows from the locale's
|
|
62
|
+
* own resolution of these, never from a flag we set.
|
|
63
|
+
*
|
|
64
|
+
* Exported because a time is rendered in two places that must agree: these
|
|
65
|
+
* segments, and the picker list that fills them (`time_options`). Two literals
|
|
66
|
+
* would drift the first time either changed, and the mismatch would show as one
|
|
67
|
+
* control saying 01:45 PM while the other says 13:45.
|
|
68
|
+
*/
|
|
69
|
+
export const TIME_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
|
70
|
+
hour: "2-digit",
|
|
71
|
+
minute: "2-digit",
|
|
72
|
+
};
|
|
73
|
+
|
|
60
74
|
const FIELD_TYPE: Record<string, SegmentType | undefined> = {
|
|
61
75
|
year: "year",
|
|
62
76
|
month: "month",
|
|
@@ -123,7 +137,7 @@ export function getTimeLayout(locale: string): DateLayout {
|
|
|
123
137
|
const cached = layoutCache.get(key);
|
|
124
138
|
if (cached) return cached;
|
|
125
139
|
|
|
126
|
-
const dtf = new Intl.DateTimeFormat(locale,
|
|
140
|
+
const dtf = new Intl.DateTimeFormat(locale, TIME_FORMAT_OPTIONS);
|
|
127
141
|
const hour12 = dtf.resolvedOptions().hour12 ?? false;
|
|
128
142
|
const layout: DateLayout = {
|
|
129
143
|
segments: partsToSegments(dtf.formatToParts(SAMPLE), hour12),
|
package/src/file_dropzone.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { Icon } from "./icon";
|
|
|
5
5
|
import { Text } from "./text";
|
|
6
6
|
import { pickFiles } from "./file_picker";
|
|
7
7
|
import { FileDropTarget } from "./file_drop_target";
|
|
8
|
-
import { FOCUS_RING } from "./control_surface";
|
|
8
|
+
import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
|
|
9
9
|
import { useFocusRing } from "./use_focus_ring";
|
|
10
10
|
import { useLoticsLocale } from "./locale";
|
|
11
11
|
|
|
@@ -88,6 +88,7 @@ export function FileDropzone(props: FileDropzoneProps) {
|
|
|
88
88
|
onHoverIn={() => setHovered(true)}
|
|
89
89
|
onHoverOut={() => setHovered(false)}
|
|
90
90
|
style={[
|
|
91
|
+
{ cursor: CURSOR_DEFAULT },
|
|
91
92
|
styles.zone,
|
|
92
93
|
{ minHeight: height },
|
|
93
94
|
hovered && !dragging ? styles.zoneHovered : null,
|
package/src/file_row.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { Pressable, StyleSheet, View } from "react-native";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { FileBadge } from "./file_badge";
|
|
6
|
-
import { FOCUS_RING } from "./control_surface";
|
|
6
|
+
import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
|
|
7
7
|
import { useFocusRing } from "./use_focus_ring";
|
|
8
8
|
import { useLoticsLocale } from "./locale";
|
|
9
9
|
|
|
@@ -192,6 +192,6 @@ const styles = StyleSheet.create({
|
|
|
192
192
|
rowHovered: { backgroundColor: colors.zinc["50"] },
|
|
193
193
|
rowPressed: { backgroundColor: colors.zinc["100"] },
|
|
194
194
|
// The accessible "Open" door — fills the row left of the trailing sibling.
|
|
195
|
-
door: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 },
|
|
195
|
+
door: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12, cursor: CURSOR_DEFAULT },
|
|
196
196
|
text: { flex: 1, gap: 1 },
|
|
197
197
|
});
|
package/src/file_thumbnail.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { IconButton } from "./icon_button";
|
|
|
7
7
|
import { Checkbox } from "./checkbox";
|
|
8
8
|
import { ActivityIndicator } from "./activity_indicator";
|
|
9
9
|
import { colors } from "./colors";
|
|
10
|
-
import { FOCUS_RING } from "./control_surface";
|
|
10
|
+
import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
|
|
11
11
|
import { useFocusRing } from "./use_focus_ring";
|
|
12
12
|
import { useLoticsLocale } from "./locale";
|
|
13
13
|
import { useCallback, useState } from "react";
|
|
@@ -540,6 +540,7 @@ const styles = StyleSheet.create({
|
|
|
540
540
|
// Fills the tile so the fade covers the whole body and nothing reflows.
|
|
541
541
|
fadedBody: { width: "100%", height: "100%", opacity: 0.45 },
|
|
542
542
|
documentCard: {
|
|
543
|
+
cursor: CURSOR_DEFAULT,
|
|
543
544
|
borderRadius: 10,
|
|
544
545
|
backgroundColor: colors.white,
|
|
545
546
|
borderWidth: 1,
|
|
@@ -594,6 +595,7 @@ const styles = StyleSheet.create({
|
|
|
594
595
|
alignItems: "center",
|
|
595
596
|
},
|
|
596
597
|
mediaCardCompact: {
|
|
598
|
+
cursor: CURSOR_DEFAULT,
|
|
597
599
|
borderRadius: 7,
|
|
598
600
|
alignItems: "center",
|
|
599
601
|
justifyContent: "center",
|
|
@@ -626,6 +628,7 @@ const styles = StyleSheet.create({
|
|
|
626
628
|
letterSpacing: 0.3,
|
|
627
629
|
},
|
|
628
630
|
imageThumbnail: {
|
|
631
|
+
cursor: CURSOR_DEFAULT,
|
|
629
632
|
borderRadius: 10,
|
|
630
633
|
overflow: "hidden",
|
|
631
634
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Pressable, type PressableProps, type StyleProp, type View, type ViewStyle } from "react-native";
|
|
2
2
|
import { type Ref } from "react";
|
|
3
|
-
import { FOCUS_RING } from "./control_surface";
|
|
3
|
+
import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
|
|
4
4
|
import { useFocusRing, composeHandler } from "./use_focus_ring";
|
|
5
5
|
import { type PressableHighlightState } from "./pressable_highlight";
|
|
6
6
|
|
|
@@ -42,6 +42,11 @@ export function FocusRingPressable({ ref, style, onFocus, onBlur, userSelect, ..
|
|
|
42
42
|
onBlur={composeHandler(onBlur ?? undefined, focusProps.onBlur)}
|
|
43
43
|
style={(state) =>
|
|
44
44
|
[
|
|
45
|
+
// The ARROW by default — see `CURSOR_DEFAULT`. This base is for fields,
|
|
46
|
+
// cells and tiles, none of which act on their own; without it they took
|
|
47
|
+
// react-native-web's `Pressable` pointer, which is not a default we
|
|
48
|
+
// chose.
|
|
49
|
+
({ cursor: CURSOR_DEFAULT } as ViewStyle),
|
|
45
50
|
userSelect ? ({ userSelect } as ViewStyle) : null,
|
|
46
51
|
typeof style === "function" ? style({ ...state, focusVisible }) : style,
|
|
47
52
|
focusVisible ? { boxShadow: FOCUS_RING } : null,
|
package/src/icon_button.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { ActivityIndicator, GestureResponderEvent, StyleProp, StyleSheet, View,
|
|
|
2
2
|
import { Icon, IconName } from "./icon";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { PressableHighlight } from "./pressable_highlight";
|
|
5
|
-
import { ROW_CONTROL_HOVER, ROW_CONTROL_PRESS } from "./control_surface";
|
|
5
|
+
import { CURSOR_ACTION, ROW_CONTROL_HOVER, ROW_CONTROL_PRESS } from "./control_surface";
|
|
6
6
|
import { type ButtonColor } from "./button";
|
|
7
7
|
import { getButtonIconColor } from "./button_colors";
|
|
8
8
|
import { Ref } from "react";
|
|
@@ -114,6 +114,8 @@ export function IconButton(props: IconButtonProps) {
|
|
|
114
114
|
|
|
115
115
|
const styles = StyleSheet.create({
|
|
116
116
|
button: {
|
|
117
|
+
// An icon-only ACTION — see `CURSOR_ACTION`.
|
|
118
|
+
cursor: CURSOR_ACTION,
|
|
117
119
|
flexDirection: "row",
|
|
118
120
|
gap: 8,
|
|
119
121
|
alignItems: "center",
|
package/src/image_gallery.tsx
CHANGED
|
@@ -91,7 +91,7 @@ export function ImageGallery({
|
|
|
91
91
|
borderColor: colors.border,
|
|
92
92
|
backgroundColor: colors.zinc["100"],
|
|
93
93
|
overflow: "hidden",
|
|
94
|
-
cursor: "
|
|
94
|
+
cursor: "auto",
|
|
95
95
|
}}
|
|
96
96
|
>
|
|
97
97
|
<RotatableImage uri={main.url} alt={main.filename} rotation={rotation.rotationFor(main.id)} />
|
package/src/inline_button.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { type GestureResponderEvent } from "react-native";
|
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { PressableHighlight } from "./pressable_highlight";
|
|
5
|
-
import { CONTROL_CONTENT_HEIGHT, CONTROL_TRANSITION } from "./control_surface";
|
|
5
|
+
import { CONTROL_CONTENT_HEIGHT, CONTROL_TRANSITION, CURSOR_ACTION } from "./control_surface";
|
|
6
6
|
|
|
7
7
|
/** The height that fits INSIDE the 40px inline control band with 6px of air
|
|
8
8
|
* above and below — the same air a `DetailRow`'s label gets against its value.
|
|
@@ -65,6 +65,8 @@ export function InlineButton(props: InlineButtonProps) {
|
|
|
65
65
|
onPress();
|
|
66
66
|
}}
|
|
67
67
|
style={(state) => [
|
|
68
|
+
// A verb ACTS — see `CURSOR_ACTION`.
|
|
69
|
+
{ cursor: CURSOR_ACTION },
|
|
68
70
|
{
|
|
69
71
|
height: INLINE_BUTTON_HEIGHT,
|
|
70
72
|
paddingHorizontal: 10,
|