@lotics/ui 2.2.0 → 2.3.1
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/package.json +2 -1
- package/src/combobox.tsx +21 -0
- package/src/drawer.tsx +98 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -119,6 +119,7 @@
|
|
|
119
119
|
"./time_field": "./src/time_field.tsx",
|
|
120
120
|
"./date_calendar": "./src/date_calendar.tsx",
|
|
121
121
|
"./dialog": "./src/dialog.tsx",
|
|
122
|
+
"./drawer": "./src/drawer.tsx",
|
|
122
123
|
"./screen_router": "./src/screen_router.tsx",
|
|
123
124
|
"./route_matching": "./src/route_matching.ts",
|
|
124
125
|
"./menu_title": "./src/menu_title.tsx",
|
package/src/combobox.tsx
CHANGED
|
@@ -132,6 +132,13 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
132
132
|
const triggerRef = useRef<View>(null);
|
|
133
133
|
const inputRef = useRef<RNTextInput>(null);
|
|
134
134
|
const scrollRef = useRef<ScrollView>(null);
|
|
135
|
+
// Options are focusable (RN-Web Pressable), so a mouse-press on one blurs the
|
|
136
|
+
// input; commit then refocuses it to keep the textbox focus (aria pattern),
|
|
137
|
+
// which would re-fire onFocus and reopen the popover. This one-shot flag lets
|
|
138
|
+
// that programmatic refocus pass without reopening — set on a single-select
|
|
139
|
+
// commit, consumed by the next onFocus, and cleared on blur (the keyboard
|
|
140
|
+
// path never blurs, so the refocus is a no-op and onFocus never fires).
|
|
141
|
+
const suppressFocusOpenRef = useRef(false);
|
|
135
142
|
const baseId = useId();
|
|
136
143
|
const listboxId = `${baseId}-listbox`;
|
|
137
144
|
const optionId = (i: number) => `${baseId}-option-${i}`;
|
|
@@ -188,6 +195,9 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
188
195
|
setQuery("");
|
|
189
196
|
}
|
|
190
197
|
setOpen(false);
|
|
198
|
+
// Single-select: the refocus below must not reopen the just-closed menu.
|
|
199
|
+
// Multi-select keeps the menu open to add more, so it isn't suppressed.
|
|
200
|
+
if (!multi) suppressFocusOpenRef.current = true;
|
|
191
201
|
inputRef.current?.focus();
|
|
192
202
|
},
|
|
193
203
|
[onValueChange, isServer, debouncedSearch, onSearchChange, multi, reflectSelection],
|
|
@@ -278,8 +288,19 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
278
288
|
onClear={onClear}
|
|
279
289
|
onChangeText={handleChangeText}
|
|
280
290
|
onFocus={() => {
|
|
291
|
+
// A programmatic refocus right after a single-select commit must not
|
|
292
|
+
// reopen the menu; consume the one-shot flag instead of opening.
|
|
293
|
+
if (suppressFocusOpenRef.current) {
|
|
294
|
+
suppressFocusOpenRef.current = false;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
281
297
|
if (!disabled) setOpen(true);
|
|
282
298
|
}}
|
|
299
|
+
onBlur={() => {
|
|
300
|
+
// Clear a flag the keyboard path left set (its refocus was a no-op,
|
|
301
|
+
// so onFocus never consumed it) — the next real focus opens normally.
|
|
302
|
+
suppressFocusOpenRef.current = false;
|
|
303
|
+
}}
|
|
283
304
|
onKeyPress={handleKeyPress}
|
|
284
305
|
placeholder={chips.length > 0 ? undefined : placeholder}
|
|
285
306
|
placeholderTextColor={colors.zinc["400"]}
|
package/src/drawer.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { Modal, Pressable, StyleSheet, View } from "react-native";
|
|
3
|
+
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
4
|
+
import { PortalHost } from "@lotics/ui/portal";
|
|
5
|
+
import { colors } from "@lotics/ui/colors";
|
|
6
|
+
import { Button } from "@lotics/ui/button";
|
|
7
|
+
import { Text } from "@lotics/ui/text";
|
|
8
|
+
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
9
|
+
|
|
10
|
+
export interface DrawerProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onOpenChange: (open: boolean) => void;
|
|
13
|
+
/** Header title. A string renders as the standard title; a node renders as-is. */
|
|
14
|
+
title?: ReactNode;
|
|
15
|
+
/** Panel width on non-small screens (number = px, or a `%` string). Full-width on small screens. */
|
|
16
|
+
width?: number | `${number}%`;
|
|
17
|
+
/**
|
|
18
|
+
* Drawer body. Fills the panel below the header as a plain flex column — lay
|
|
19
|
+
* out your own scroll area and pinned footer (e.g. a thread that scrolls above
|
|
20
|
+
* a fixed composer).
|
|
21
|
+
*/
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
testID?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A right-docked overlay panel: a scrim over the surface plus a full-height panel
|
|
28
|
+
* pinned to the right edge. Use it for secondary content that should sit beside
|
|
29
|
+
* the main surface without leaving it — a comment thread, a record side-panel —
|
|
30
|
+
* where a centered Dialog would feel heavy. Controlled via `open`/`onOpenChange`;
|
|
31
|
+
* the scrim and the header close button both dismiss it. The panel goes
|
|
32
|
+
* full-width on small screens.
|
|
33
|
+
*/
|
|
34
|
+
export function Drawer(props: DrawerProps) {
|
|
35
|
+
const { open, onOpenChange, title, width = 420, children, testID } = props;
|
|
36
|
+
const screenSize = useScreenSize();
|
|
37
|
+
useOverlayScope(open);
|
|
38
|
+
const handleClose = () => onOpenChange(false);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Modal visible={open} onRequestClose={handleClose} transparent>
|
|
42
|
+
<View style={styles.base}>
|
|
43
|
+
{/* Scrim is a sibling of the panel, so tapping the panel never closes. */}
|
|
44
|
+
<Pressable style={styles.scrim} onPress={handleClose} accessibilityLabel="Close" />
|
|
45
|
+
<View style={[styles.panel, { width: screenSize.small ? "100%" : width }]}>
|
|
46
|
+
<PortalHost>
|
|
47
|
+
<View style={styles.header}>
|
|
48
|
+
{typeof title === "string" ? (
|
|
49
|
+
<Text size="lg" weight="semibold" style={{ flex: 1 }}>
|
|
50
|
+
{title}
|
|
51
|
+
</Text>
|
|
52
|
+
) : (
|
|
53
|
+
<View style={{ flex: 1 }}>{title}</View>
|
|
54
|
+
)}
|
|
55
|
+
<Button icon="x" accessibilityLabel="Close" onPress={handleClose} />
|
|
56
|
+
</View>
|
|
57
|
+
<View testID={testID} style={styles.body}>
|
|
58
|
+
{children}
|
|
59
|
+
</View>
|
|
60
|
+
</PortalHost>
|
|
61
|
+
</View>
|
|
62
|
+
</View>
|
|
63
|
+
</Modal>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const styles = StyleSheet.create({
|
|
68
|
+
base: {
|
|
69
|
+
height: "100%",
|
|
70
|
+
width: "100%",
|
|
71
|
+
flexDirection: "row",
|
|
72
|
+
},
|
|
73
|
+
scrim: {
|
|
74
|
+
flex: 1,
|
|
75
|
+
backgroundColor: "rgba(0, 0, 0, 0.15)",
|
|
76
|
+
backdropFilter: "blur(3px)",
|
|
77
|
+
},
|
|
78
|
+
panel: {
|
|
79
|
+
height: "100%",
|
|
80
|
+
backgroundColor: colors.background,
|
|
81
|
+
borderLeftWidth: 1,
|
|
82
|
+
borderLeftColor: colors.border,
|
|
83
|
+
boxShadow: "-8px 0 24px rgba(0, 0, 0, 0.08)",
|
|
84
|
+
},
|
|
85
|
+
header: {
|
|
86
|
+
flexDirection: "row",
|
|
87
|
+
alignItems: "center",
|
|
88
|
+
gap: 8,
|
|
89
|
+
paddingLeft: 20,
|
|
90
|
+
paddingRight: 12,
|
|
91
|
+
paddingTop: 16,
|
|
92
|
+
paddingBottom: 8,
|
|
93
|
+
minHeight: 56,
|
|
94
|
+
},
|
|
95
|
+
body: {
|
|
96
|
+
flex: 1,
|
|
97
|
+
},
|
|
98
|
+
});
|