@lotics/ui 1.26.0 → 2.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/src/chart_bar.tsx DELETED
@@ -1,154 +0,0 @@
1
- import { View } from "react-native";
2
- import {
3
- Bar,
4
- BarChart,
5
- CartesianGrid,
6
- Cell,
7
- ResponsiveContainer,
8
- Tooltip,
9
- XAxis,
10
- YAxis,
11
- } from "recharts";
12
- import { colors } from "./colors";
13
- import { useLoticsTheme } from "./theme";
14
- import {
15
- chartAxisTickStyle,
16
- chartTooltipLabelStyle,
17
- chartTooltipStyle,
18
- chartYAxisCategoryTickStyle,
19
- } from "./chart_internals";
20
-
21
- interface ChartBarProps<T> {
22
- data: T[];
23
- /** Property to use as the category axis. */
24
- categoryKey: keyof T & string;
25
- /** Property to use as the value axis. */
26
- valueKey: keyof T & string;
27
- /** Per-row fill color. Optional — when omitted, all bars use the theme
28
- * accent. Set this when each category needs its own color (funnel
29
- * stages, status breakdowns, etc.). */
30
- fillKey?: keyof T & string;
31
- /** Orientation. Default `vertical` (category on X, value on Y). Use
32
- * `horizontal` for "rank by value" lists where category names are
33
- * long (member names, account labels). */
34
- orientation?: "vertical" | "horizontal";
35
- /** Pixel height. Default 220 vertical / row-count × 56 horizontal.
36
- * Pass explicit value for fixed-height layouts. */
37
- height?: number;
38
- /** Override the theme accent for this chart instance. */
39
- color?: string;
40
- /** Format the value in tooltips + value labels. */
41
- formatValue?: (value: number) => string;
42
- /** Label shown next to the value in the tooltip. Default `Value`. */
43
- valueLabel?: string;
44
- }
45
-
46
- /**
47
- * Shadcn-style bar chart. Vertical (categories on X axis) is the default;
48
- * pass `orientation="horizontal"` for rank-by-value lists where labels are
49
- * long.
50
- *
51
- * Vertical: rounded TOP corners only (radius 6) — the shadcn pattern.
52
- * Horizontal: rounded RIGHT corners only.
53
- *
54
- * Per-bar colors via `fillKey`: each data row carries its own color. Use
55
- * for funnel/category breakdowns. Omit for single-hue bar charts (the
56
- * theme accent fills every bar).
57
- */
58
- export function ChartBar<T extends Record<string, unknown>>(props: ChartBarProps<T>) {
59
- const {
60
- data,
61
- categoryKey,
62
- valueKey,
63
- fillKey,
64
- orientation = "vertical",
65
- height,
66
- color,
67
- formatValue,
68
- valueLabel = "Value",
69
- } = props;
70
- const theme = useLoticsTheme();
71
- const fill = color ?? theme.accent;
72
- const isHorizontal = orientation === "horizontal";
73
- const resolvedHeight = height ?? (isHorizontal ? Math.max(120, data.length * 56) : 220);
74
- return (
75
- <View style={{ height: resolvedHeight }}>
76
- <ResponsiveContainer width="100%" height="100%">
77
- <BarChart
78
- data={data}
79
- layout={isHorizontal ? "vertical" : "horizontal"}
80
- margin={
81
- isHorizontal
82
- ? { top: 4, right: 40, left: 4, bottom: 4 }
83
- : { top: 8, right: 0, left: 0, bottom: 0 }
84
- }
85
- barCategoryGap={isHorizontal ? "35%" : undefined}
86
- >
87
- <CartesianGrid
88
- vertical={isHorizontal ? false : false}
89
- horizontal={isHorizontal ? false : true}
90
- stroke={colors.zinc[200]}
91
- strokeDasharray="3 3"
92
- />
93
- {isHorizontal ? (
94
- <>
95
- <XAxis type="number" hide />
96
- <YAxis
97
- type="category"
98
- dataKey={categoryKey as string}
99
- axisLine={false}
100
- tickLine={false}
101
- width={160}
102
- tick={chartYAxisCategoryTickStyle}
103
- />
104
- </>
105
- ) : (
106
- <>
107
- {/* Recharts v3's dataKey expects a TypedDataKey resolved from the
108
- generic; our wrapper is generic over T so we pass the raw
109
- string key. Cast at the boundary — consumers' data shape is
110
- validated via the categoryKey: keyof T constraint. */}
111
- <XAxis
112
- dataKey={categoryKey as string}
113
- axisLine={false}
114
- tickLine={false}
115
- tickMargin={8}
116
- tick={chartAxisTickStyle}
117
- />
118
- <YAxis hide />
119
- </>
120
- )}
121
- <Tooltip
122
- cursor={{ fill: colors.zinc[100] }}
123
- contentStyle={chartTooltipStyle}
124
- labelStyle={chartTooltipLabelStyle}
125
- formatter={(val: unknown) => {
126
- if (typeof val !== "number") return [String(val), valueLabel];
127
- return [formatValue ? formatValue(val) : val.toLocaleString(), valueLabel];
128
- }}
129
- />
130
- <Bar
131
- dataKey={valueKey as string}
132
- fill={fill}
133
- radius={isHorizontal ? [0, 4, 4, 0] : [6, 6, 0, 0]}
134
- label={
135
- isHorizontal
136
- ? {
137
- position: "right",
138
- fill: colors.zinc[700],
139
- fontSize: 13,
140
- fontWeight: 500,
141
- }
142
- : undefined
143
- }
144
- >
145
- {fillKey &&
146
- data.map((row, i) => (
147
- <Cell key={i} fill={String(row[fillKey] ?? fill)} />
148
- ))}
149
- </Bar>
150
- </BarChart>
151
- </ResponsiveContainer>
152
- </View>
153
- );
154
- }
@@ -1,43 +0,0 @@
1
- import { colors } from "./colors";
2
-
3
- /**
4
- * Shared chart styling helpers + the shadcn-style tooltip box style.
5
- * Centralized so every @lotics/ui chart wrapper looks identical without
6
- * duplicating the magic numbers across files.
7
- *
8
- * Pure constants + a type — no JSX, no peer dep impact. The chart wrappers
9
- * that import this also import Recharts directly; this file is safe to
10
- * import from anywhere.
11
- */
12
-
13
- /** Tooltip popover box. Matches shadcn's `<ChartTooltipContent />` chrome:
14
- * white card, hairline border, soft warm-neutral shadow, tight padding. */
15
- export const chartTooltipStyle = {
16
- backgroundColor: colors.white,
17
- border: `1px solid ${colors.zinc[200]}`,
18
- borderRadius: 10,
19
- // Two-layer shadow: tight first stop for definition, soft second stop
20
- // for atmosphere. Stripe / Linear pattern — single-shadow tooltips read
21
- // as "default Recharts", layered shadow reads as "designed".
22
- boxShadow: "0 1px 2px rgba(38,38,38,0.04), 0 8px 20px -4px rgba(38,38,38,0.08)",
23
- fontSize: 13,
24
- padding: "10px 14px",
25
- } as const;
26
-
27
- export const chartTooltipLabelStyle = {
28
- color: colors.zinc[600],
29
- fontWeight: 500,
30
- } as const;
31
-
32
- /** Axis tick text — small, muted, no rotation. */
33
- export const chartAxisTickStyle = {
34
- fontSize: 12,
35
- fill: colors.zinc[500],
36
- } as const;
37
-
38
- /** Y-axis label text on horizontal bar charts — slightly heavier than X
39
- * because it's reading a name, not a number. */
40
- export const chartYAxisCategoryTickStyle = {
41
- fontSize: 13,
42
- fill: colors.zinc[700],
43
- } as const;
@@ -1,50 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { customOptionFor } from "./custom_option";
3
-
4
- const opts = [
5
- { value: "BX1N", label: "BX1N" },
6
- { value: "UX1N", label: "UX1N" },
7
- ];
8
-
9
- describe("customOptionFor", () => {
10
- it("offers the trimmed query when nothing matches", () => {
11
- expect(customOptionFor({ allowCustom: true, multi: false, query: " ABCD ", options: opts }))
12
- .toEqual({ value: "ABCD", label: "ABCD" });
13
- });
14
-
15
- it("suppressed when free entry is off", () => {
16
- expect(customOptionFor({ allowCustom: false, multi: false, query: "ABCD", options: opts })).toBeNull();
17
- expect(customOptionFor({ allowCustom: undefined, multi: false, query: "ABCD", options: opts })).toBeNull();
18
- });
19
-
20
- it("suppressed for multi-select", () => {
21
- expect(customOptionFor({ allowCustom: true, multi: true, query: "ABCD", options: opts })).toBeNull();
22
- });
23
-
24
- it("suppressed for an empty / whitespace query", () => {
25
- expect(customOptionFor({ allowCustom: true, multi: false, query: " ", options: opts })).toBeNull();
26
- });
27
-
28
- it("suppressed when the query matches an option value (case-insensitive)", () => {
29
- expect(customOptionFor({ allowCustom: true, multi: false, query: "bx1n", options: opts })).toBeNull();
30
- });
31
-
32
- it("uses customOptionLabel for the row label but keeps the raw value", () => {
33
- expect(customOptionFor({
34
- allowCustom: true, multi: false, query: "ABCD", options: opts,
35
- customOptionLabel: (q) => `Add "${q}"`,
36
- })).toEqual({ value: "ABCD", label: 'Add "ABCD"' });
37
- });
38
-
39
- it("suppressed when customOptionLabel returns null", () => {
40
- expect(customOptionFor({
41
- allowCustom: true, multi: false, query: "AB", options: opts,
42
- customOptionLabel: () => null,
43
- })).toBeNull();
44
- });
45
-
46
- it("ignores undefined / false holes in the options list", () => {
47
- expect(customOptionFor({ allowCustom: true, multi: false, query: "ABCD", options: [undefined, false, ...opts] }))
48
- .toEqual({ value: "ABCD", label: "ABCD" });
49
- });
50
- });
@@ -1,30 +0,0 @@
1
- import type { PickerOption } from "./picker";
2
-
3
- /**
4
- * Resolve the free-entry ("custom") option a search picker should offer for the
5
- * current query — or `null` when it should not be offered. The option's `value`
6
- * stays the raw trimmed query; consumers decide what an unknown value means.
7
- *
8
- * Suppressed when: free entry is disabled, the picker is multi-select, the query
9
- * is empty, the query already matches an existing option's value
10
- * (case-insensitive), or `customOptionLabel` returns `null` for it.
11
- *
12
- * Pure and RN-free so it is unit-testable in isolation from `PickerMenu`.
13
- */
14
- export function customOptionFor<T extends string>(args: {
15
- allowCustom: boolean | undefined;
16
- multi: boolean;
17
- query: string;
18
- options: (PickerOption<T> | undefined | false)[];
19
- customOptionLabel?: (query: string) => string | null;
20
- }): PickerOption<T> | null {
21
- const { allowCustom, multi, query, options, customOptionLabel } = args;
22
- if (!allowCustom || multi) return null;
23
- const q = query.trim();
24
- if (!q || options.some((o) => o && String(o.value).toLowerCase() === q.toLowerCase())) {
25
- return null;
26
- }
27
- const label = customOptionLabel ? customOptionLabel(q) : q;
28
- if (label == null) return null;
29
- return { value: q as T, label };
30
- }
@@ -1,348 +0,0 @@
1
- import {
2
- View,
3
- ScrollView,
4
- StyleSheet,
5
- type StyleProp,
6
- type ViewStyle,
7
- type TextInput as RNTextInput,
8
- } from "react-native";
9
- import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
10
- import { colors } from "./colors";
11
- import { Text } from "./text";
12
- import { TextInputField } from "./text_input_field";
13
- import { MenuListItem } from "./menu_list_item";
14
- import { ActivityIndicator } from "./activity_indicator";
15
- import { Popover, PopoverContent } from "./popover";
16
- import type { PickerOption } from "./picker";
17
- import type { TextColor } from "./text_utils";
18
- import { useDebouncedCallback } from "./use_debounced_callback";
19
- import { useListKeyboardNav } from "./use_list_keyboard_nav";
20
-
21
- /** A pinned action row in the dropdown — not a search result, an action. */
22
- export interface SearchSelectAction {
23
- key: string;
24
- label: string;
25
- icon?: ReactNode;
26
- /** Tints the label so it reads as an action (match it to the icon's color). */
27
- color?: TextColor;
28
- onPress: () => void;
29
- }
30
-
31
- export interface SearchSelectProps<T extends string = string, D = unknown> {
32
- /** Result options for the current query. The consumer refreshes these in
33
- * response to `onSearchChange` — search runs server-side, so the whole table
34
- * is never shipped to the client. */
35
- options: PickerOption<T, D>[];
36
- /** Pinned action rows shown at the very top of the dropdown (above results and
37
- * recents), always available while open and keyboard-navigable alongside the
38
- * options — e.g. a "Browse all" that opens a full picker. */
39
- leadingActions?: SearchSelectAction[];
40
- /** Debounced; fires as the user types. Drive a server re-query from it. */
41
- onSearchChange: (query: string) => void;
42
- /** Fires when the user picks a result or a recent. */
43
- onValueChange: (option: PickerOption<T, D>) => void;
44
- /** Shown — under a heading — when the box is focused but empty (e.g. the
45
- * output of `useRecents`). Omit for no recents. */
46
- recentOptions?: PickerOption<T, D>[];
47
- /** Row subtitle, read from the option's `data` payload. The row's structure
48
- * and height are owned by the component (a MenuListItem) so every result
49
- * reads consistently — consumers supply data, not markup. */
50
- getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
51
- /** Leading accessory (e.g. a status dot / avatar). */
52
- renderOptionIcon?: (option: PickerOption<T, D>) => ReactNode;
53
- /** Trailing accessory (e.g. a `Badge`). */
54
- renderOptionRight?: (option: PickerOption<T, D>) => ReactNode;
55
- /** Marks the matching row as the current selection (a highlight). The box
56
- * itself stays a search affordance — the selection is shown by the host. */
57
- selectedValue?: T | null;
58
- /** True only on the initial load (no results yet) — show a spinner. During
59
- * revalidation / typing, pass the query's (SWR-style) `loading`, which stays
60
- * false while previous rows are kept, so the list never blanks. */
61
- loading?: boolean;
62
- /** Debounce for `onSearchChange`, in ms. Default 200. */
63
- searchDebounceMs?: number;
64
- placeholder?: string;
65
- /** Heading above the recents list. Default "Recent". Pass a translated string. */
66
- recentsLabel?: string;
67
- /** No-results-for-query text. Default "No results". Pass a translated string. */
68
- emptyText?: string;
69
- /** Accessible name for the results listbox. Default "Results". */
70
- accessibilityLabel?: string;
71
- disabled?: boolean;
72
- autoFocus?: boolean;
73
- testID?: string;
74
- style?: StyleProp<ViewStyle>;
75
- }
76
-
77
- // Approximate row height (MenuListItem: title + description) for scroll-into-view.
78
- const OPTION_HEIGHT = 52;
79
-
80
- /**
81
- * Search-first combobox: an always-visible white search panel whose typing
82
- * drives a debounced, server-side search; results drop into a Popover listbox
83
- * of MenuListItem rows below. Focused while empty, it offers `recentOptions`.
84
- * Picking a row fires `onValueChange` and clears the box for the next search —
85
- * the selection itself is rendered by the host (a summary above, details below).
86
- *
87
- * Focus stays on the input the whole time (Popover `manageFocus={false}`); the
88
- * input owns the keyboard (↑/↓ move the active row via `aria-activedescendant`,
89
- * Enter selects, Esc closes) — the ARIA combobox pattern, not a button trigger.
90
- */
91
- export function SearchSelect<T extends string = string, D = unknown>(
92
- props: SearchSelectProps<T, D>,
93
- ) {
94
- const {
95
- options,
96
- leadingActions = [],
97
- onSearchChange,
98
- onValueChange,
99
- recentOptions,
100
- getOptionDescription,
101
- renderOptionIcon,
102
- renderOptionRight,
103
- selectedValue = null,
104
- loading = false,
105
- searchDebounceMs = 200,
106
- placeholder,
107
- recentsLabel = "Recent",
108
- emptyText = "No results",
109
- accessibilityLabel = "Results",
110
- disabled = false,
111
- autoFocus = false,
112
- testID,
113
- style,
114
- } = props;
115
-
116
- const [query, setQuery] = useState("");
117
- const [open, setOpen] = useState(autoFocus);
118
- const triggerRef = useRef<View>(null);
119
- const inputRef = useRef<RNTextInput>(null);
120
- const scrollRef = useRef<ScrollView>(null);
121
- const baseId = useId();
122
- const listboxId = `${baseId}-listbox`;
123
- const optionId = (i: number) => `${baseId}-option-${i}`;
124
-
125
- const searching = query.trim().length > 0;
126
- const list = searching ? options : (recentOptions ?? []);
127
- // The navigable set is the leading actions followed by the option list, so
128
- // arrow keys + aria-activedescendant span both. `nLead` is the boundary.
129
- const nLead = leadingActions.length;
130
-
131
- const debouncedSearch = useDebouncedCallback(onSearchChange, searchDebounceMs);
132
-
133
- // Only the option list scrolls (actions are pinned above it), so map the
134
- // combined index back into the list before scrolling.
135
- const scrollToIndex = useCallback(
136
- (index: number) => {
137
- if (index < nLead) return;
138
- scrollRef.current?.scrollTo({ y: Math.max(0, (index - nLead) * OPTION_HEIGHT - 80), animated: false });
139
- },
140
- [nLead],
141
- );
142
-
143
- const handleSelect = useCallback(
144
- (index: number) => {
145
- if (index < nLead) {
146
- leadingActions[index].onPress();
147
- setOpen(false);
148
- return;
149
- }
150
- const opt = list[index - nLead];
151
- if (!opt || opt.disabled) return;
152
- onValueChange(opt);
153
- // The trailing search is irrelevant once a pick is made. Cancel it and
154
- // tell the consumer the term is now empty (the box clears) so its query
155
- // state matches and any search-driven fetch goes idle.
156
- debouncedSearch.cancel();
157
- setQuery("");
158
- onSearchChange("");
159
- setOpen(false);
160
- // A click on a row blurs the input; keep it focused so the user can
161
- // search again without re-clicking.
162
- inputRef.current?.focus();
163
- },
164
- [list, leadingActions, nLead, onValueChange, onSearchChange, debouncedSearch],
165
- );
166
-
167
- const { activeIndex, setActiveIndex, handleKey } = useListKeyboardNav({
168
- count: nLead + list.length,
169
- isDisabled: (i) => (i >= nLead ? (list[i - nLead]?.disabled ?? false) : false),
170
- onSelect: handleSelect,
171
- onClose: () => setOpen(false),
172
- onActiveChange: scrollToIndex,
173
- });
174
-
175
- // Reset the active row when the visible list changes (new results, or toggling
176
- // between results and recents). Keyed on primitives so a consumer re-render
177
- // with an equal-but-new array doesn't reset mid-navigation.
178
- const firstValue = list[0]?.value;
179
- useEffect(() => {
180
- setActiveIndex(0);
181
- }, [searching, list.length, firstValue, setActiveIndex]);
182
-
183
- const handleChangeText = useCallback(
184
- (text: string) => {
185
- setQuery(text);
186
- if (!disabled) setOpen(true);
187
- debouncedSearch(text);
188
- },
189
- [disabled, debouncedSearch],
190
- );
191
-
192
- const handleKeyPress = useCallback(
193
- (e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
194
- const key = e.nativeEvent.key;
195
- if (!open && (key === "ArrowDown" || key === "ArrowUp")) setOpen(true);
196
- if (handleKey(key)) e.preventDefault();
197
- },
198
- [open, handleKey],
199
- );
200
-
201
- // Nothing to show ⇒ don't open an empty popover. Leading actions (e.g.
202
- // "Browse all") always give the popover something to show on focus.
203
- const showList = open && !disabled && (nLead > 0 || searching || list.length > 0);
204
- const activeOptionId =
205
- activeIndex >= 0 && activeIndex < nLead + list.length ? optionId(activeIndex) : undefined;
206
- const listLabel = searching ? accessibilityLabel : recentsLabel;
207
-
208
- return (
209
- <View style={style}>
210
- <View ref={triggerRef}>
211
- <TextInputField
212
- ref={inputRef}
213
- testID={testID}
214
- icon="search"
215
- clearable
216
- value={query}
217
- onChangeText={handleChangeText}
218
- onFocus={() => {
219
- if (!disabled) setOpen(true);
220
- }}
221
- onKeyPress={handleKeyPress}
222
- placeholder={placeholder}
223
- placeholderTextColor={colors.zinc["400"]}
224
- editable={!disabled}
225
- autoFocus={autoFocus}
226
- autoCapitalize="none"
227
- autoCorrect={false}
228
- style={styles.input}
229
- role="combobox"
230
- aria-expanded={showList}
231
- aria-controls={listboxId}
232
- aria-activedescendant={activeOptionId}
233
- aria-autocomplete="list"
234
- />
235
- </View>
236
- <Popover
237
- open={showList}
238
- onOpenChange={setOpen}
239
- triggerRef={triggerRef}
240
- side="bottom"
241
- align="start"
242
- offset={4}
243
- inheritTriggerWidth
244
- >
245
- <PopoverContent
246
- manageFocus={false}
247
- disableBodyScroll
248
- testID={testID ? `${testID}-popover` : undefined}
249
- >
250
- <View style={styles.menu}>
251
- {nLead > 0
252
- ? leadingActions.map((action, i) => (
253
- <MenuListItem
254
- key={action.key}
255
- nativeID={optionId(i)}
256
- testID={`search-action-${action.key}`}
257
- role="option"
258
- icon={action.icon}
259
- title={action.label}
260
- titleColor={action.color}
261
- focused={i === activeIndex}
262
- onPress={() => handleSelect(i)}
263
- onHoverIn={() => setActiveIndex(i)}
264
- />
265
- ))
266
- : null}
267
- {!searching && list.length > 0 ? (
268
- <View style={styles.sectionHeader}>
269
- <Text size="xs" weight="medium" color="zinc-500">
270
- {recentsLabel}
271
- </Text>
272
- </View>
273
- ) : null}
274
- <ScrollView
275
- ref={scrollRef}
276
- style={styles.scroll}
277
- nativeID={listboxId}
278
- accessibilityLabel={listLabel}
279
- keyboardShouldPersistTaps="handled"
280
- // `listbox` is valid ARIA but absent from React Native's Role enum;
281
- // react-native-web forwards it verbatim for assistive tech.
282
- role={"listbox" as "list"}
283
- >
284
- {loading ? (
285
- <View style={styles.statusRow}>
286
- <ActivityIndicator />
287
- </View>
288
- ) : list.length === 0 ? (
289
- // Only "no results" while searching — an empty recents list (no
290
- // query) just shows the leading actions above, nothing more.
291
- searching ? (
292
- <View style={styles.statusRow}>
293
- <Text size="sm" color="zinc-500">
294
- {emptyText}
295
- </Text>
296
- </View>
297
- ) : null
298
- ) : (
299
- list.map((opt, i) => (
300
- <MenuListItem
301
- key={opt.value}
302
- nativeID={optionId(nLead + i)}
303
- testID={`search-option-${opt.value}`}
304
- role="option"
305
- icon={renderOptionIcon?.(opt)}
306
- title={opt.label ?? opt.value}
307
- description={getOptionDescription?.(opt)}
308
- right={renderOptionRight?.(opt)}
309
- focused={nLead + i === activeIndex}
310
- selected={opt.value === selectedValue}
311
- disabled={opt.disabled}
312
- onPress={() => handleSelect(nLead + i)}
313
- onHoverIn={() => setActiveIndex(nLead + i)}
314
- />
315
- ))
316
- )}
317
- </ScrollView>
318
- </View>
319
- </PopoverContent>
320
- </Popover>
321
- </View>
322
- );
323
- }
324
-
325
- const styles = StyleSheet.create({
326
- input: {
327
- backgroundColor: colors.background,
328
- boxShadow: colors.border_shadow,
329
- // A pill, matching the search input inside the picker dialog.
330
- borderRadius: 999,
331
- },
332
- menu: {
333
- gap: 2,
334
- },
335
- sectionHeader: {
336
- paddingHorizontal: 8,
337
- paddingTop: 4,
338
- paddingBottom: 2,
339
- },
340
- scroll: {
341
- maxHeight: 320,
342
- },
343
- statusRow: {
344
- alignItems: "center",
345
- justifyContent: "center",
346
- paddingVertical: 16,
347
- },
348
- });
@@ -1,55 +0,0 @@
1
- import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from "react-native";
2
- import { colors } from "./colors";
3
-
4
- interface SelectItemProps {
5
- children: React.ReactNode;
6
- testID?: string;
7
- onPress?: () => void;
8
- style?: StyleProp<ViewStyle>;
9
- }
10
-
11
- /**
12
- * A bordered, optionally-pressable container — the selectable-row look used on
13
- * auth screens (organization picker, login choices). Frozen at the original
14
- * Card styling (white, 1px border, radius 8, padding 16) so those screens stay
15
- * visually stable while Card itself evolves toward a flatter dashboard look.
16
- */
17
- export function SelectItem(props: SelectItemProps) {
18
- const { children, testID, onPress, style } = props;
19
-
20
- if (onPress) {
21
- return (
22
- <Pressable
23
- testID={testID}
24
- onPress={() => {
25
- onPress();
26
- }}
27
- style={(state) => {
28
- const hovered = (state as { hovered?: boolean }).hovered;
29
- return [styles.container, hovered && styles.hovered, style];
30
- }}
31
- >
32
- {children}
33
- </Pressable>
34
- );
35
- }
36
-
37
- return (
38
- <View testID={testID} style={[styles.container, style]}>
39
- {children}
40
- </View>
41
- );
42
- }
43
-
44
- const styles = StyleSheet.create({
45
- container: {
46
- padding: 16,
47
- borderRadius: 8,
48
- backgroundColor: colors.background,
49
- borderColor: colors.border,
50
- borderWidth: 1,
51
- },
52
- hovered: {
53
- borderColor: colors.zinc["900"],
54
- },
55
- });