@lotics/ui 42.4.0 → 43.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +74 -0
- package/docs/catalog.md +38 -6
- package/docs/composition.md +45 -3
- package/docs/data_entry.md +9 -4
- package/package.json +12 -2
- package/src/back_button.tsx +4 -1
- package/src/badge.tsx +10 -2
- package/src/button.tsx +69 -8
- package/src/check_circle.tsx +1 -2
- package/src/checkbox_input.tsx +2 -2
- package/src/choice_list.tsx +2 -2
- package/src/color_tokens.ts +27 -3
- package/src/colors.web.ts +4 -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/display_font.ts +27 -0
- package/src/display_font.web.ts +31 -0
- package/src/file_dropzone.tsx +2 -1
- package/src/file_row.tsx +2 -2
- package/src/file_thumbnail.tsx +4 -1
- package/src/filter_chip.tsx +12 -2
- package/src/focus_ring_pressable.tsx +6 -1
- package/src/font_family.ts +26 -0
- package/src/font_family.web.ts +29 -0
- package/src/icon_button.tsx +3 -1
- package/src/image_gallery.tsx +1 -1
- package/src/index.css +0 -2
- 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 +2 -2
- package/src/menu_button.tsx +19 -0
- package/src/option_list.tsx +8 -0
- package/src/pressable_highlight.tsx +20 -8
- package/src/pressable_row.tsx +7 -3
- package/src/scroll_to_bottom.tsx +3 -0
- package/src/slider.tsx +2 -2
- package/src/summary.tsx +28 -4
- package/src/switch.tsx +3 -1
- package/src/table.tsx +46 -4
- package/src/text.tsx +32 -11
- package/src/text_utils.ts +3 -11
- package/src/theme.web.tsx +16 -1
- package/src/theme_context.ts +63 -1
- 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
|
@@ -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,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE BODY FACE, as three weight rungs.
|
|
3
|
+
*
|
|
4
|
+
* Weight is a FAMILY in this kit, not a `font-weight`: Inter ships as three
|
|
5
|
+
* separate `@font-face` families (`Inter_400Regular` / `Inter_500Medium` /
|
|
6
|
+
* `Inter_600SemiBold`), which is the React Native convention and the reason a
|
|
7
|
+
* single family token cannot express the ladder on its own.
|
|
8
|
+
*
|
|
9
|
+
* So the tokens are per-rung, and `LoticsThemeProvider` FANS ONE VALUE OUT
|
|
10
|
+
* across all three when an app sets `bodyFont`. That is what keeps the app-facing
|
|
11
|
+
* surface at one parameter while the kit keeps a rung it can default correctly:
|
|
12
|
+
* unthemed, each rung resolves to its own Inter face; themed, all three resolve
|
|
13
|
+
* to the app's family and the numeric `font-weight` already on every style makes
|
|
14
|
+
* the weights. The fan-out lives in the provider, where it is explained once,
|
|
15
|
+
* rather than at 11 call sites that would each have to remember it.
|
|
16
|
+
*/
|
|
17
|
+
/** Native: literals. `var()` means nothing off the web. */
|
|
18
|
+
export const fontFamilyRegular = `Inter_400Regular, "apple-system", "BlinkMacSystemFont",
|
|
19
|
+
"Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
|
20
|
+
"Droid Sans", "Helvetica Neue", sans-serif`;
|
|
21
|
+
export const fontFamilyMedium = `Inter_500Medium, "apple-system", "BlinkMacSystemFont",
|
|
22
|
+
"Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
|
23
|
+
"Droid Sans", "Helvetica Neue", sans-serif`;
|
|
24
|
+
export const fontFamilySemiBold = `Inter_600SemiBold, "apple-system", "BlinkMacSystemFont",
|
|
25
|
+
"Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
|
26
|
+
"Droid Sans", "Helvetica Neue", sans-serif`;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE BODY FACE, as three weight rungs.
|
|
3
|
+
*
|
|
4
|
+
* Weight is a FAMILY in this kit, not a `font-weight`: Inter ships as three
|
|
5
|
+
* separate `@font-face` families (`Inter_400Regular` / `Inter_500Medium` /
|
|
6
|
+
* `Inter_600SemiBold`), which is the React Native convention and the reason a
|
|
7
|
+
* single family token cannot express the ladder on its own.
|
|
8
|
+
*
|
|
9
|
+
* So the tokens are per-rung, and `LoticsThemeProvider` FANS ONE VALUE OUT
|
|
10
|
+
* across all three when an app sets `bodyFont`. That is what keeps the app-facing
|
|
11
|
+
* surface at one parameter while the kit keeps a rung it can default correctly:
|
|
12
|
+
* unthemed, each rung resolves to its own Inter face; themed, all three resolve
|
|
13
|
+
* to the app's family and the numeric `font-weight` already on every style makes
|
|
14
|
+
* the weights. The fan-out lives in the provider, where it is explained once,
|
|
15
|
+
* rather than at 11 call sites that would each have to remember it.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Each rung carries its own Inter face as the inline fallback, so an app that
|
|
19
|
+
* themes nothing renders byte-identically to before these variables existed.
|
|
20
|
+
*/
|
|
21
|
+
export const fontFamilyRegular = `var(--lotics-font-regular, Inter_400Regular, "apple-system", "BlinkMacSystemFont",
|
|
22
|
+
"Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
|
23
|
+
"Droid Sans", "Helvetica Neue", sans-serif)`;
|
|
24
|
+
export const fontFamilyMedium = `var(--lotics-font-medium, Inter_500Medium, "apple-system", "BlinkMacSystemFont",
|
|
25
|
+
"Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
|
26
|
+
"Droid Sans", "Helvetica Neue", sans-serif)`;
|
|
27
|
+
export const fontFamilySemiBold = `var(--lotics-font-semibold, Inter_600SemiBold, "apple-system", "BlinkMacSystemFont",
|
|
28
|
+
"Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
|
29
|
+
"Droid Sans", "Helvetica Neue", sans-serif)`;
|
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/index.css
CHANGED
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,
|
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
import { useCallback, useRef, type ReactNode } from "react";
|
|
1
|
+
import { useCallback, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { ActivityIndicator } from "./activity_indicator";
|
|
2
4
|
import { Icon } from "./icon";
|
|
5
|
+
import { Text } from "./text";
|
|
3
6
|
import { colors } from "./colors";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
8
|
+
import { TimeColumns } from "./time_columns";
|
|
9
|
+
import { formatTimeOfDay } from "./time_options";
|
|
10
|
+
import { type InlineEditVariant, InlineEditView } from "./inline_edit";
|
|
11
|
+
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
7
12
|
|
|
8
13
|
export interface InlineTimePickerProps {
|
|
9
14
|
/** Canonical 24-hour "HH:mm", "" when empty. */
|
|
10
15
|
value: string;
|
|
11
16
|
onSave: (next: string) => void | Promise<void>;
|
|
12
17
|
placeholder?: string;
|
|
13
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
|
|
18
|
+
/** BCP-47 locale deciding 12- vs 24-hour display and whether a period column
|
|
19
|
+
* shows. Defaults to the active `LoticsLocaleProvider` locale. */
|
|
20
|
+
locale?: string;
|
|
16
21
|
disabled?: boolean;
|
|
17
22
|
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
18
23
|
variant?: InlineEditVariant;
|
|
@@ -25,51 +30,95 @@ export interface InlineTimePickerProps {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
|
-
* An inline-editable time of day
|
|
29
|
-
*
|
|
30
|
-
*
|
|
33
|
+
* An inline-editable time of day: the value at rest, the hour / minute / period
|
|
34
|
+
* columns in a popover anchored to the field. The pick commits when the popover
|
|
35
|
+
* closes; dismissing without changing anything writes nothing.
|
|
36
|
+
*
|
|
37
|
+
* On the POPOVER inline shell, beside `InlineDatePicker` — not the typed one it
|
|
38
|
+
* used to sit on. `useInlineEdit` commits when the field BLURS, and a picker
|
|
39
|
+
* cannot live there: opening one moves focus into it, which reads as a blur,
|
|
40
|
+
* commits and unmounts the editor before the picker can paint. A pick is not a
|
|
41
|
+
* keystroke, and the kit keeps two shells precisely to hold that line.
|
|
31
42
|
*/
|
|
32
43
|
export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
33
|
-
const { value, onSave, placeholder,
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
// no minute) reaches the draft as "" and would otherwise commit as a silent
|
|
38
|
-
// clear. Blocking on it keeps the entry fixable instead of wiping the value.
|
|
39
|
-
const incomplete = useRef(false);
|
|
44
|
+
const { value, onSave, placeholder, locale, disabled, accessibilityLabel, variant, actions } = props;
|
|
45
|
+
const localeTag = useLocaleTag(locale);
|
|
46
|
+
const loc = useLoticsLocale().datePicker;
|
|
47
|
+
const anchorRef = useRef<View>(null);
|
|
40
48
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
const [open, setOpen] = useState(false);
|
|
50
|
+
const [draft, setDraft] = useState(value);
|
|
51
|
+
const [saving, setSaving] = useState(false);
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
|
|
54
|
+
// Closing IS the commit — every column emits a COMPLETE time, so there is
|
|
55
|
+
// never a half-picked draft to reject and no "incomplete" state to guard. An
|
|
56
|
+
// unchanged draft writes nothing rather than sending a no-op update.
|
|
57
|
+
const handleOpenChange = useCallback(
|
|
58
|
+
(next: boolean) => {
|
|
59
|
+
if (next) {
|
|
60
|
+
setDraft(value);
|
|
61
|
+
setError(null);
|
|
62
|
+
setOpen(true);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setOpen(false);
|
|
66
|
+
if (draft === value) return;
|
|
67
|
+
setSaving(true);
|
|
68
|
+
void (async () => {
|
|
69
|
+
try {
|
|
70
|
+
await onSave(draft);
|
|
71
|
+
setError(null);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
74
|
+
setDraft(value);
|
|
75
|
+
} finally {
|
|
76
|
+
setSaving(false);
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
},
|
|
80
|
+
[draft, value, onSave],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const trailing = saving ? (
|
|
84
|
+
<ActivityIndicator size={16} color={colors.zinc[400]} />
|
|
85
|
+
) : (
|
|
86
|
+
<Icon name="clock" size={18} color={colors.zinc[400]} />
|
|
87
|
+
);
|
|
46
88
|
|
|
47
89
|
return (
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
<View>
|
|
91
|
+
<Popover
|
|
92
|
+
open={open && !disabled}
|
|
93
|
+
onOpenChange={handleOpenChange}
|
|
94
|
+
triggerRef={anchorRef}
|
|
95
|
+
side="bottom"
|
|
96
|
+
align="start"
|
|
97
|
+
>
|
|
98
|
+
<PopoverTrigger>
|
|
99
|
+
<InlineEditView
|
|
100
|
+
variant={variant}
|
|
101
|
+
actions={actions}
|
|
102
|
+
anchorRef={anchorRef}
|
|
103
|
+
display={value ? formatTimeOfDay(value, localeTag) : ""}
|
|
104
|
+
placeholder={placeholder ?? loc.chooseTime}
|
|
105
|
+
disabled={disabled}
|
|
106
|
+
active={open && !disabled}
|
|
107
|
+
accessibilityLabel={accessibilityLabel ?? loc.chooseTime}
|
|
108
|
+
// The resting clock glyph marks an empty field as a TIME control —
|
|
109
|
+
// the same promise the standalone picker's trigger makes.
|
|
110
|
+
trailing={trailing}
|
|
111
|
+
/>
|
|
112
|
+
</PopoverTrigger>
|
|
113
|
+
<PopoverContent disableBodyScroll>
|
|
114
|
+
<TimeColumns value={draft} onValueChange={setDraft} locale={locale} />
|
|
115
|
+
</PopoverContent>
|
|
116
|
+
</Popover>
|
|
117
|
+
{error ? (
|
|
118
|
+
<Text size="xs" color="danger" style={{ marginTop: 4 }}>
|
|
119
|
+
{error}
|
|
120
|
+
</Text>
|
|
121
|
+
) : null}
|
|
122
|
+
</View>
|
|
74
123
|
);
|
|
75
124
|
}
|
package/src/list_item.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Ref, useCallback } from "react";
|
|
1
|
+
import { Ref, useCallback, useState } from "react";
|
|
2
2
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
-
import {
|
|
5
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
6
6
|
|
|
7
7
|
export interface ListItemProps {
|
|
8
8
|
ref?: Ref<View>;
|
|
@@ -21,6 +21,29 @@ export interface ListItemProps {
|
|
|
21
21
|
* A settings / detail ROW — optional `left` (icon/avatar), `title` + `description`, a `right`
|
|
22
22
|
* control, optional `onPress`/`selected`. For settings lists and detail rows; NOT a data register
|
|
23
23
|
* row (`PressableRow` / `Table`) and NOT a menu/listbox row (`MenuListItem`).
|
|
24
|
+
*
|
|
25
|
+
* **A pressable row always presses BESIDE its `right` slot, never around it.** The slot is
|
|
26
|
+
* documented as holding a control, and a button may not contain one: `<button>` inside
|
|
27
|
+
* `<button>` is invalid HTML that React warns about, and it puts a second tab stop and a
|
|
28
|
+
* second name inside a control that is supposed to be one thing. So the surface is a wrapper,
|
|
29
|
+
* the press target is the region holding `left` and the text, and the slot sits outside it —
|
|
30
|
+
* the anatomy `InlineEditView` uses for its `actions`.
|
|
31
|
+
*
|
|
32
|
+
* It does NOT fix a double press: react-native-web's press responder calls
|
|
33
|
+
* `stopPropagation`, so a nested control's press never reached the row in the first place.
|
|
34
|
+
* The nesting alone is the defect.
|
|
35
|
+
*
|
|
36
|
+
* ALWAYS, not only when a slot is passed. `right` is routinely conditional
|
|
37
|
+
* (`updateAvailable ? <Badge/> : undefined`), and an anatomy that flipped on that would make
|
|
38
|
+
* two rows in one list press differently — the badge row's right edge dead, its neighbour's
|
|
39
|
+
* live — for a reason a reader cannot see. One shape, whatever the data does. With no slot
|
|
40
|
+
* the region takes the whole row, so the press target is unchanged.
|
|
41
|
+
*
|
|
42
|
+
* That the anatomy carries this rather than a prop opting into safety is the point: every
|
|
43
|
+
* call site already passing a control here is fixed without being touched, and a caller
|
|
44
|
+
* cannot reintroduce the nesting by forgetting a flag. The cost is that a decorative `right`
|
|
45
|
+
* (a badge, a chevron) is not part of the press target — uniformly, which is what makes it
|
|
46
|
+
* readable rather than surprising.
|
|
24
47
|
*/
|
|
25
48
|
export function ListItem(props: ListItemProps) {
|
|
26
49
|
const { ref, left, title, description, right, onPress, selected, disabled, style, testID } =
|
|
@@ -30,7 +53,8 @@ export function ListItem(props: ListItemProps) {
|
|
|
30
53
|
onPress?.();
|
|
31
54
|
}, [title, testID, onPress]);
|
|
32
55
|
|
|
33
|
-
|
|
56
|
+
// Everything the row is ABOUT — never the `right` slot, which may hold a control.
|
|
57
|
+
const body = (
|
|
34
58
|
<>
|
|
35
59
|
{left}
|
|
36
60
|
<View style={styles.textContainer}>
|
|
@@ -48,7 +72,6 @@ export function ListItem(props: ListItemProps) {
|
|
|
48
72
|
description
|
|
49
73
|
))}
|
|
50
74
|
</View>
|
|
51
|
-
{right}
|
|
52
75
|
</>
|
|
53
76
|
);
|
|
54
77
|
|
|
@@ -56,21 +79,79 @@ export function ListItem(props: ListItemProps) {
|
|
|
56
79
|
|
|
57
80
|
if (onPress) {
|
|
58
81
|
return (
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
<PressRow
|
|
83
|
+
ref={ref}
|
|
84
|
+
testID={testID}
|
|
85
|
+
containerStyle={containerStyle}
|
|
62
86
|
onPress={handlePress}
|
|
63
87
|
disabled={disabled}
|
|
64
|
-
|
|
88
|
+
right={right}
|
|
65
89
|
>
|
|
66
|
-
{
|
|
67
|
-
</
|
|
90
|
+
{body}
|
|
91
|
+
</PressRow>
|
|
68
92
|
);
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
return (
|
|
72
96
|
<View ref={ref} testID={testID} style={containerStyle}>
|
|
73
|
-
{
|
|
97
|
+
{body}
|
|
98
|
+
{right}
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The pressable anatomy: a non-pressable surface, the press target inside it, the
|
|
105
|
+
* slot beside that.
|
|
106
|
+
*
|
|
107
|
+
* The wash lives on the WRAPPER and is driven by the press region's own hover and
|
|
108
|
+
* press, so the whole row lights up as one thing — the region alone would light up
|
|
109
|
+
* to the edge of the slot and stop, which reads as two rows. It uses
|
|
110
|
+
* `FocusRingPressable` for exactly that reason: the wash-free base, for a control
|
|
111
|
+
* that paints its own surface.
|
|
112
|
+
*
|
|
113
|
+
* `ref` and `testID` go on the REGION, not the wrapper, because they identify the
|
|
114
|
+
* thing that presses: a test that clicks the row by `testID` must land on the
|
|
115
|
+
* press target rather than on a box that happens to contain it. The region also
|
|
116
|
+
* keeps the focus ring and the accessible name, since the text it wraps is what
|
|
117
|
+
* names the row.
|
|
118
|
+
*/
|
|
119
|
+
function PressRow(props: {
|
|
120
|
+
ref?: Ref<View>;
|
|
121
|
+
testID?: string;
|
|
122
|
+
containerStyle: StyleProp<ViewStyle>;
|
|
123
|
+
onPress: () => void;
|
|
124
|
+
disabled?: boolean;
|
|
125
|
+
right?: React.ReactNode;
|
|
126
|
+
children: React.ReactNode;
|
|
127
|
+
}) {
|
|
128
|
+
const { ref, testID, containerStyle, onPress, disabled, right, children } = props;
|
|
129
|
+
const [hovered, setHovered] = useState(false);
|
|
130
|
+
const [pressed, setPressed] = useState(false);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<View
|
|
134
|
+
style={[
|
|
135
|
+
containerStyle,
|
|
136
|
+
!disabled && pressed && styles.pressed,
|
|
137
|
+
!disabled && !pressed && hovered && styles.hovered,
|
|
138
|
+
]}
|
|
139
|
+
>
|
|
140
|
+
<FocusRingPressable
|
|
141
|
+
ref={ref}
|
|
142
|
+
testID={testID}
|
|
143
|
+
style={styles.pressRegion}
|
|
144
|
+
onPress={onPress}
|
|
145
|
+
disabled={disabled}
|
|
146
|
+
accessibilityRole="button"
|
|
147
|
+
onHoverIn={() => setHovered(true)}
|
|
148
|
+
onHoverOut={() => setHovered(false)}
|
|
149
|
+
onPressIn={() => setPressed(true)}
|
|
150
|
+
onPressOut={() => setPressed(false)}
|
|
151
|
+
>
|
|
152
|
+
{children}
|
|
153
|
+
</FocusRingPressable>
|
|
154
|
+
{right}
|
|
74
155
|
</View>
|
|
75
156
|
);
|
|
76
157
|
}
|
|
@@ -93,4 +174,21 @@ const styles = StyleSheet.create({
|
|
|
93
174
|
selected: {
|
|
94
175
|
backgroundColor: colors.zinc["100"],
|
|
95
176
|
},
|
|
177
|
+
// The wash `PressableHighlight` would have painted, moved to the wrapper so it
|
|
178
|
+
// covers the row rather than stopping at the slot.
|
|
179
|
+
hovered: {
|
|
180
|
+
backgroundColor: colors.zinc["100"],
|
|
181
|
+
},
|
|
182
|
+
pressed: {
|
|
183
|
+
backgroundColor: colors.zinc["200"],
|
|
184
|
+
},
|
|
185
|
+
// Takes the slack, so the press target reaches the slot and the row presses
|
|
186
|
+
// everywhere the row is ABOUT.
|
|
187
|
+
pressRegion: {
|
|
188
|
+
flex: 1,
|
|
189
|
+
flexDirection: "row",
|
|
190
|
+
alignItems: "center",
|
|
191
|
+
gap: 8,
|
|
192
|
+
alignSelf: "stretch",
|
|
193
|
+
},
|
|
96
194
|
});
|
package/src/locale.tsx
CHANGED
|
@@ -272,7 +272,7 @@ export const en: LoticsLocale = {
|
|
|
272
272
|
referenceField: { open: "Open", change: "Change", clear: "Clear", edit: "Edit", save: "Save", saving: "Saving…", cancel: "Cancel" },
|
|
273
273
|
optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
|
|
274
274
|
picker: { emptyOption: "None" },
|
|
275
|
-
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date", invalidTime: "Enter a complete time" },
|
|
275
|
+
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", chooseTime: "Choose a time", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date", invalidTime: "Enter a complete time" },
|
|
276
276
|
calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
|
|
277
277
|
filterChip: { clear: "Clear" },
|
|
278
278
|
floatingActionBar: { clear: "Clear" },
|
|
@@ -444,7 +444,7 @@ export const vi: LoticsLocale = {
|
|
|
444
444
|
referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "Huỷ" },
|
|
445
445
|
optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
|
|
446
446
|
picker: { emptyOption: "Không có" },
|
|
447
|
-
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
|
|
447
|
+
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", chooseTime: "Chọn giờ", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
|
|
448
448
|
calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
|
|
449
449
|
filterChip: { clear: "Xóa" },
|
|
450
450
|
floatingActionBar: { clear: "Bỏ chọn" },
|
package/src/menu_button.tsx
CHANGED
|
@@ -47,6 +47,21 @@ export interface MenuButtonProps {
|
|
|
47
47
|
* the common popover-menu usage.
|
|
48
48
|
*/
|
|
49
49
|
role?: "menuitem" | "button" | "option";
|
|
50
|
+
/**
|
|
51
|
+
* Roving tabindex for a listbox this row belongs to: the selected row passes
|
|
52
|
+
* `0` and every other row `-1`, so Tab reaches the list ONCE and arrows move
|
|
53
|
+
* within it (the composite-widget rule — sixty rows must not be sixty stops).
|
|
54
|
+
*
|
|
55
|
+
* Here rather than hand-rolled beside it because this component already claims
|
|
56
|
+
* to BE the listbox row — it owns `selected` and the `aria-selected` that
|
|
57
|
+
* carries the state — and a row that cannot take the tab stop or the keys is
|
|
58
|
+
* only half of one. `OptionList` does not need it: that body drives keys from
|
|
59
|
+
* a hidden input, the combobox pattern, where the rows are never focused.
|
|
60
|
+
*/
|
|
61
|
+
tabIndex?: 0 | -1;
|
|
62
|
+
/** Web-only key handler, forwarded to the DOM by react-native-web. Pairs with
|
|
63
|
+
* {@link MenuButtonProps.tabIndex} to move the roving focus. */
|
|
64
|
+
onKeyDown?: (event: { key: string; preventDefault?: () => void }) => void;
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
export function MenuButton(props: MenuButtonProps) {
|
|
@@ -68,6 +83,8 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
68
83
|
accessibilityLabel,
|
|
69
84
|
role = "menuitem",
|
|
70
85
|
nativeID,
|
|
86
|
+
tabIndex,
|
|
87
|
+
onKeyDown,
|
|
71
88
|
} = props;
|
|
72
89
|
|
|
73
90
|
const resolvedLabel = accessibilityLabel ?? (typeof title === "string" ? title : undefined) ?? tooltip;
|
|
@@ -126,6 +143,8 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
126
143
|
onPress?.();
|
|
127
144
|
}}
|
|
128
145
|
onHoverIn={onHoverIn}
|
|
146
|
+
onKeyDown={onKeyDown}
|
|
147
|
+
tabIndex={tabIndex}
|
|
129
148
|
disabled={disabled}
|
|
130
149
|
tooltip={tooltip}
|
|
131
150
|
style={containerStyle}
|
package/src/option_list.tsx
CHANGED
|
@@ -139,6 +139,14 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
|
|
|
139
139
|
!props.multi && row.selected ? (
|
|
140
140
|
<Icon name="check" size={18} color={colors.zinc["950"]} />
|
|
141
141
|
) : undefined,
|
|
142
|
+
// A chosen row is `selected`, which is what puts `aria-selected` on
|
|
143
|
+
// it — the property an `option` carries its state in. Until now the
|
|
144
|
+
// only signal was the check GLYPH beside it (and a checkbox in
|
|
145
|
+
// multi), so which option was chosen was information available to
|
|
146
|
+
// whoever could see the row and to nobody else. It also brings the
|
|
147
|
+
// resting `zinc.100` highlight, one weight above the `zinc.50` the
|
|
148
|
+
// keyboard/hover row takes, so the two states stay distinguishable.
|
|
149
|
+
selected: row.selected,
|
|
142
150
|
focused: row.index === list.activeIndex,
|
|
143
151
|
disabled: opt.disabled,
|
|
144
152
|
onPress: () => list.pickRow(row.index),
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { Ref, useCallback } from "react";
|
|
11
11
|
import { TooltipSide, useTooltip } from "./tooltip";
|
|
12
12
|
import { colors } from "./colors";
|
|
13
|
-
import { FOCUS_RING } from "./control_surface";
|
|
13
|
+
import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
|
|
14
14
|
import { composeHandler, useFocusRing } from "./use_focus_ring";
|
|
15
15
|
|
|
16
16
|
/** The pressable state, plus the web-only `hovered` flag react-native-web adds and
|
|
@@ -58,11 +58,19 @@ export interface PressableHighlightProps extends PressableProps {
|
|
|
58
58
|
*/
|
|
59
59
|
"aria-current"?: "true" | "page" | "step" | "location";
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
* text-selection surface — drag jitter on
|
|
63
|
-
* selection and can swallow the click.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
61
|
+
* Defaults to `"none"`, because this component IS a button (see the role note
|
|
62
|
+
* below) and a button's label is not a text-selection surface — drag jitter on
|
|
63
|
+
* selectable text starts a selection and can swallow the click.
|
|
64
|
+
*
|
|
65
|
+
* It was opt-in, and the result was what an opt-in rule always produces:
|
|
66
|
+
* `Switcher`, `Chip`, `CardSelectItem` and `Stepper` had simply never been
|
|
67
|
+
* told, so their labels dragged into a selection. Pass `"auto"` where the
|
|
68
|
+
* content genuinely is text to read and copy — nothing in the kit does, and the
|
|
69
|
+
* register row that DOES want selectable text is `PressableRow`, which is a
|
|
70
|
+
* role-less bare `Pressable` and unaffected by this.
|
|
71
|
+
*
|
|
72
|
+
* Exposed as a prop at all because RN types only carry `userSelect` on
|
|
73
|
+
* TextStyle, while react-native-web applies it to any element.
|
|
66
74
|
*/
|
|
67
75
|
userSelect?: "auto" | "none";
|
|
68
76
|
}
|
|
@@ -89,7 +97,7 @@ export function PressableHighlight(props: PressableHighlightProps) {
|
|
|
89
97
|
tooltip,
|
|
90
98
|
tooltipSide = "top",
|
|
91
99
|
onPress,
|
|
92
|
-
userSelect,
|
|
100
|
+
userSelect = "none",
|
|
93
101
|
focusRing,
|
|
94
102
|
...restPressableProps
|
|
95
103
|
} = props;
|
|
@@ -114,7 +122,11 @@ export function PressableHighlight(props: PressableHighlightProps) {
|
|
|
114
122
|
const { pressed, hovered } = state;
|
|
115
123
|
return [
|
|
116
124
|
{
|
|
117
|
-
|
|
125
|
+
// The ARROW by default — see `CURSOR_DEFAULT`. Rows, menu options and
|
|
126
|
+
// cards are what this base is for, and none of them act on their own;
|
|
127
|
+
// the handful that do set `CURSOR_ACTION` in their own style, which
|
|
128
|
+
// lands after this one.
|
|
129
|
+
...({ touchAction: "manipulation", cursor: CURSOR_DEFAULT, transitionDuration: "0.1s", transitionProperty: "background-color", userSelect } as ViewStyle),
|
|
118
130
|
},
|
|
119
131
|
{
|
|
120
132
|
backgroundColor: pressed ? colors.zinc["200"] : hovered ? colors.zinc["100"] : null,
|
package/src/pressable_row.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ReactNode, Ref, useCallback, useState } from "react";
|
|
2
|
-
import { ROW_WASH_BLEED } from "./control_surface";
|
|
2
|
+
import { CURSOR_DEFAULT, ROW_WASH_BLEED } from "./control_surface";
|
|
3
3
|
import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { pressSelectedText } from "./press_selection";
|
|
@@ -86,7 +86,11 @@ export function PressableRow(props: PressableRowProps) {
|
|
|
86
86
|
styles.row,
|
|
87
87
|
variant === "inset" ? styles.inset : variant === "bleed" ? styles.bleed : styles.register,
|
|
88
88
|
{
|
|
89
|
-
|
|
89
|
+
// Hover and selection wear the brand's WASH, whose default IS the zinc-100
|
|
90
|
+
// these states already used — so an unthemed app is unchanged and a themed
|
|
91
|
+
// one warms every row it touches. `pressed` stays neutral and one step
|
|
92
|
+
// darker: it is a momentary depth cue, not an identity.
|
|
93
|
+
backgroundColor: pressed ? colors.zinc[200] : selected ? colors.accent_wash : hovered ? colors.accent_wash : marked ? colors.blue[50] : undefined,
|
|
90
94
|
},
|
|
91
95
|
style,
|
|
92
96
|
]}
|
|
@@ -107,7 +111,7 @@ const styles = StyleSheet.create({
|
|
|
107
111
|
// furniture that must not be dragged over already opts out for itself
|
|
108
112
|
// (`Badge`, `Avatar`, `Button` each set none), so the blanket only ever cost
|
|
109
113
|
// the text it had no business covering.
|
|
110
|
-
...({ cursor:
|
|
114
|
+
...({ cursor: CURSOR_DEFAULT, transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
|
|
111
115
|
},
|
|
112
116
|
// THE register row — FULL-WIDTH rounded highlight that BLEEDS past the content
|
|
113
117
|
// (`ROW_WASH_BLEED`, net-zero margin+padding), so the row's content sits on the
|
package/src/scroll_to_bottom.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { colors } from "./colors";
|
|
2
2
|
import { StyleSheet } from "react-native";
|
|
3
|
+
import { CURSOR_ACTION } from "./control_surface";
|
|
3
4
|
import { Icon } from "./icon";
|
|
4
5
|
import { PressableHighlight } from "./pressable_highlight";
|
|
5
6
|
import { useLoticsLocale } from "./locale";
|
|
@@ -30,6 +31,8 @@ export function ScrollToBottom(props: ScrollToBottomProps) {
|
|
|
30
31
|
|
|
31
32
|
const styles = StyleSheet.create({
|
|
32
33
|
button: {
|
|
34
|
+
// Jumping to the bottom ACTS — see `CURSOR_ACTION`.
|
|
35
|
+
cursor: CURSOR_ACTION,
|
|
33
36
|
borderWidth: 1,
|
|
34
37
|
borderColor: colors.border,
|
|
35
38
|
backgroundColor: colors.background,
|