@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "2.2.0",
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
+ });