@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/README.md +34 -0
- package/package.json +15 -11
- package/src/app_icon.tsx +59 -0
- package/src/card.tsx +7 -23
- package/src/card_select_item.tsx +52 -0
- package/src/column_filter.tsx +0 -1
- package/src/combobox.tsx +365 -109
- package/src/dynamic_icon.tsx +20 -0
- package/src/dynamic_icon.web.tsx +40 -0
- package/src/legend_item.tsx +2 -2
- package/src/picker.tsx +2 -17
- package/src/picker_menu.tsx +84 -121
- package/src/switcher.tsx +4 -3
- package/src/chart_area.tsx +0 -105
- package/src/chart_bar.tsx +0 -154
- package/src/chart_internals.tsx +0 -43
- package/src/custom_option.test.ts +0 -50
- package/src/custom_option.ts +0 -30
- package/src/search_select.tsx +0 -348
- package/src/select_item.tsx +0 -55
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
|
-
}
|
package/src/chart_internals.tsx
DELETED
|
@@ -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
|
-
});
|
package/src/custom_option.ts
DELETED
|
@@ -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
|
-
}
|
package/src/search_select.tsx
DELETED
|
@@ -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
|
-
});
|
package/src/select_item.tsx
DELETED
|
@@ -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
|
-
});
|