@expokit/cli 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +32 -2
- package/bin/expo-kit.js +1204 -0
- package/bin/lib.mjs +343 -0
- package/bin/mcp.mjs +338 -0
- package/package.json +44 -4
- package/registry/_docs/accordion.json +99 -0
- package/registry/_docs/action-sheet.json +110 -0
- package/registry/_docs/alert.json +127 -0
- package/registry/_docs/avatar.json +203 -0
- package/registry/_docs/badge.json +117 -0
- package/registry/_docs/bottom-nav.json +119 -0
- package/registry/_docs/bottom-sheet.json +158 -0
- package/registry/_docs/bullet.json +24 -0
- package/registry/_docs/button-3d.json +122 -0
- package/registry/_docs/button.json +115 -0
- package/registry/_docs/calendar.json +182 -0
- package/registry/_docs/card-stack.json +108 -0
- package/registry/_docs/card.json +111 -0
- package/registry/_docs/carousel.json +102 -0
- package/registry/_docs/checkbox.json +93 -0
- package/registry/_docs/checkout.json +102 -0
- package/registry/_docs/chip-group.json +116 -0
- package/registry/_docs/color-swatch.json +66 -0
- package/registry/_docs/confetti.json +82 -0
- package/registry/_docs/coupon.json +176 -0
- package/registry/_docs/cube-pager.json +84 -0
- package/registry/_docs/dashboard.json +120 -0
- package/registry/_docs/date-picker.json +249 -0
- package/registry/_docs/dialog.json +162 -0
- package/registry/_docs/drawer.json +171 -0
- package/registry/_docs/fab.json +120 -0
- package/registry/_docs/flip-card.json +111 -0
- package/registry/_docs/floating-input.json +191 -0
- package/registry/_docs/gift-card.json +108 -0
- package/registry/_docs/glass-button.json +118 -0
- package/registry/_docs/glass-nav-bar.json +78 -0
- package/registry/_docs/glass-search-bar.json +90 -0
- package/registry/_docs/glass-segmented.json +66 -0
- package/registry/_docs/glass-sheet.json +108 -0
- package/registry/_docs/glass-tab-bar.json +76 -0
- package/registry/_docs/glass.json +78 -0
- package/registry/_docs/inbox.json +72 -0
- package/registry/_docs/isometric-bar.json +72 -0
- package/registry/_docs/kit.json +11 -0
- package/registry/_docs/kpi-card.json +11 -0
- package/registry/_docs/like-button.json +72 -0
- package/registry/_docs/liquidglass-tab.json +27 -0
- package/registry/_docs/list-item.json +200 -0
- package/registry/_docs/loyalty-card.json +108 -0
- package/registry/_docs/morph-button.json +108 -0
- package/registry/_docs/number-ticker.json +96 -0
- package/registry/_docs/offers.json +78 -0
- package/registry/_docs/onboarding.json +66 -0
- package/registry/_docs/otp-input.json +134 -0
- package/registry/_docs/pagination-dots.json +88 -0
- package/registry/_docs/parallax-layers.json +84 -0
- package/registry/_docs/parallax-scroll.json +186 -0
- package/registry/_docs/password-input.json +92 -0
- package/registry/_docs/payment-card.json +153 -0
- package/registry/_docs/pixel-bar.json +66 -0
- package/registry/_docs/polar-area.json +78 -0
- package/registry/_docs/profile.json +84 -0
- package/registry/_docs/progress-steps.json +92 -0
- package/registry/_docs/progress.json +132 -0
- package/registry/_docs/radio-group.json +92 -0
- package/registry/_docs/rating.json +90 -0
- package/registry/_docs/ring-carousel.json +102 -0
- package/registry/_docs/scratch-card.json +90 -0
- package/registry/_docs/search-bar.json +140 -0
- package/registry/_docs/search-results.json +120 -0
- package/registry/_docs/segmented-control.json +103 -0
- package/registry/_docs/select.json +252 -0
- package/registry/_docs/settings.json +72 -0
- package/registry/_docs/shimmer-button.json +78 -0
- package/registry/_docs/sign-in.json +198 -0
- package/registry/_docs/skeleton.json +142 -0
- package/registry/_docs/slide-to-confirm.json +84 -0
- package/registry/_docs/slider.json +157 -0
- package/registry/_docs/spin-wheel.json +114 -0
- package/registry/_docs/spinner.json +91 -0
- package/registry/_docs/stepper.json +168 -0
- package/registry/_docs/swipe-deck.json +102 -0
- package/registry/_docs/swipeable-row.json +72 -0
- package/registry/_docs/switch.json +104 -0
- package/registry/_docs/tabs.json +105 -0
- package/registry/_docs/tag-input.json +128 -0
- package/registry/_docs/textarea.json +175 -0
- package/registry/_docs/ticket-card.json +174 -0
- package/registry/_docs/ticket-shape.json +11 -0
- package/registry/_docs/tilt-card.json +141 -0
- package/registry/_docs/time-picker.json +253 -0
- package/registry/_docs/timeline-chart.json +30 -0
- package/registry/_docs/timeline.json +156 -0
- package/registry/_docs/toast.json +161 -0
- package/registry/_docs/treemap.json +30 -0
- package/registry/_docs/waterfall.json +36 -0
- package/registry/_docs/wheel-list.json +102 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
- package/registry/accordion/files/src/lib/kit.tsx +219 -0
- package/registry/accordion.json +17 -0
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
- package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/action-sheet.json +18 -0
- package/registry/alert/files/src/components/ui/alert.tsx +654 -0
- package/registry/alert/files/src/components/ui/button.tsx +274 -0
- package/registry/alert/files/src/lib/kit.tsx +219 -0
- package/registry/alert.json +20 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
- package/registry/avatar/files/src/lib/kit.tsx +219 -0
- package/registry/avatar.json +18 -0
- package/registry/badge/files/src/components/ui/badge.tsx +316 -0
- package/registry/badge/files/src/lib/kit.tsx +219 -0
- package/registry/badge.json +17 -0
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
- package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-nav.json +18 -0
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
- package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-sheet.json +17 -0
- package/registry/bullet.json +14 -0
- package/registry/button/files/src/components/ui/button.tsx +274 -0
- package/registry/button/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
- package/registry/button-3d/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d.json +18 -0
- package/registry/button.json +18 -0
- package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
- package/registry/calendar/files/src/lib/kit.tsx +219 -0
- package/registry/calendar.json +17 -0
- package/registry/card/files/src/components/ui/card.tsx +300 -0
- package/registry/card/files/src/lib/kit.tsx +219 -0
- package/registry/card-stack.json +15 -0
- package/registry/card.json +17 -0
- package/registry/carousel.json +15 -0
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
- package/registry/checkbox/files/src/lib/kit.tsx +219 -0
- package/registry/checkbox.json +17 -0
- package/registry/checkout.json +26 -0
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
- package/registry/chip-group/files/src/lib/kit.tsx +219 -0
- package/registry/chip-group.json +17 -0
- package/registry/color-swatch.json +17 -0
- package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
- package/registry/confetti/files/src/lib/kit.tsx +219 -0
- package/registry/confetti.json +15 -0
- package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
- package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/coupon/files/src/lib/kit.tsx +219 -0
- package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/coupon.json +23 -0
- package/registry/cube-pager.json +15 -0
- package/registry/dashboard.json +32 -0
- package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
- package/registry/date-picker/files/src/lib/kit.tsx +219 -0
- package/registry/date-picker.json +19 -0
- package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
- package/registry/dialog/files/src/lib/kit.tsx +219 -0
- package/registry/dialog.json +18 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
- package/registry/drawer/files/src/lib/kit.tsx +219 -0
- package/registry/drawer.json +18 -0
- package/registry/fab.json +18 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/flip-card/files/src/lib/kit.tsx +219 -0
- package/registry/flip-card.json +15 -0
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/floating-input/files/src/lib/kit.tsx +219 -0
- package/registry/floating-input.json +17 -0
- package/registry/gift-card.json +21 -0
- package/registry/glass-button.json +22 -0
- package/registry/glass-nav-bar.json +21 -0
- package/registry/glass-search-bar.json +21 -0
- package/registry/glass-segmented.json +22 -0
- package/registry/glass-sheet.json +22 -0
- package/registry/glass-tab-bar.json +22 -0
- package/registry/glass.json +19 -0
- package/registry/inbox.json +30 -0
- package/registry/isometric-bar.json +14 -0
- package/registry/kit/files/src/lib/kit.tsx +219 -0
- package/registry/kit.json +11 -0
- package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
- package/registry/kpi-card.json +14 -0
- package/registry/like-button.json +18 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
- package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
- package/registry/liquidglass-tab.json +23 -0
- package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
- package/registry/list-item/files/src/lib/kit.tsx +219 -0
- package/registry/list-item.json +17 -0
- package/registry/loyalty-card.json +23 -0
- package/registry/morph-button.json +18 -0
- package/registry/number-ticker.json +15 -0
- package/registry/offers.json +24 -0
- package/registry/onboarding.json +17 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
- package/registry/otp-input/files/src/lib/kit.tsx +219 -0
- package/registry/otp-input.json +17 -0
- package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
- package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
- package/registry/pagination-dots.json +15 -0
- package/registry/parallax-layers.json +15 -0
- package/registry/parallax-scroll.json +17 -0
- package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
- package/registry/password-input/files/src/lib/kit.tsx +219 -0
- package/registry/password-input.json +19 -0
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/payment-card/files/src/lib/kit.tsx +219 -0
- package/registry/payment-card.json +21 -0
- package/registry/pixel-bar.json +14 -0
- package/registry/polar-area.json +14 -0
- package/registry/profile.json +17 -0
- package/registry/progress/files/src/components/ui/progress.tsx +883 -0
- package/registry/progress/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
- package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps.json +18 -0
- package/registry/progress.json +17 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
- package/registry/radio-group/files/src/lib/kit.tsx +219 -0
- package/registry/radio-group.json +18 -0
- package/registry/rating.json +18 -0
- package/registry/ring-carousel.json +15 -0
- package/registry/scratch-card.json +18 -0
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
- package/registry/search-bar/files/src/lib/kit.tsx +219 -0
- package/registry/search-bar.json +17 -0
- package/registry/search-results.json +30 -0
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
- package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
- package/registry/segmented-control.json +18 -0
- package/registry/select/files/src/components/ui/select.tsx +1398 -0
- package/registry/select/files/src/lib/kit.tsx +219 -0
- package/registry/select.json +17 -0
- package/registry/settings.json +26 -0
- package/registry/shimmer-button.json +17 -0
- package/registry/sign-in.json +26 -0
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
- package/registry/skeleton/files/src/lib/kit.tsx +219 -0
- package/registry/skeleton.json +17 -0
- package/registry/slide-to-confirm.json +18 -0
- package/registry/slider/files/src/components/ui/slider.tsx +716 -0
- package/registry/slider/files/src/lib/kit.tsx +219 -0
- package/registry/slider.json +17 -0
- package/registry/spin-wheel.json +18 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +335 -0
- package/registry/spinner/files/src/lib/kit.tsx +219 -0
- package/registry/spinner.json +17 -0
- package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
- package/registry/stepper/files/src/lib/kit.tsx +219 -0
- package/registry/stepper.json +17 -0
- package/registry/swipe-deck.json +15 -0
- package/registry/swipeable-row.json +15 -0
- package/registry/switch/files/src/components/ui/switch.tsx +344 -0
- package/registry/switch/files/src/lib/kit.tsx +219 -0
- package/registry/switch.json +18 -0
- package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
- package/registry/tabs/files/src/lib/kit.tsx +219 -0
- package/registry/tabs.json +17 -0
- package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
- package/registry/tag-input/files/src/lib/kit.tsx +219 -0
- package/registry/tag-input.json +17 -0
- package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
- package/registry/textarea/files/src/lib/kit.tsx +219 -0
- package/registry/textarea.json +17 -0
- package/registry/ticket-card.json +24 -0
- package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
- package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/ticket-shape.json +17 -0
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
- package/registry/tilt-card.json +17 -0
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
- package/registry/time-picker/files/src/lib/kit.tsx +219 -0
- package/registry/time-picker.json +17 -0
- package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
- package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
- package/registry/timeline/files/src/lib/kit.tsx +219 -0
- package/registry/timeline-chart.json +14 -0
- package/registry/timeline.json +20 -0
- package/registry/toast/files/src/components/ui/button.tsx +274 -0
- package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
- package/registry/toast/files/src/lib/kit.tsx +219 -0
- package/registry/toast.json +20 -0
- package/registry/treemap.json +14 -0
- package/registry/waterfall.json +14 -0
- package/registry/wheel-list.json +17 -0
- package/schema/components.schema.json +13 -0
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
Dimensions,
|
|
5
|
+
Easing,
|
|
6
|
+
Keyboard,
|
|
7
|
+
KeyboardAvoidingView,
|
|
8
|
+
Modal,
|
|
9
|
+
PanResponder,
|
|
10
|
+
Platform,
|
|
11
|
+
Pressable,
|
|
12
|
+
ScrollView,
|
|
13
|
+
StyleSheet,
|
|
14
|
+
Text,
|
|
15
|
+
TextInput,
|
|
16
|
+
View,
|
|
17
|
+
useWindowDimensions,
|
|
18
|
+
type StyleProp,
|
|
19
|
+
type TextStyle,
|
|
20
|
+
type ViewStyle,
|
|
21
|
+
} from 'react-native';
|
|
22
|
+
import Svg, { Circle, Path } from 'react-native-svg';
|
|
23
|
+
|
|
24
|
+
import { alpha, motion, readableOn, useKit } from '@/lib/kit';
|
|
25
|
+
|
|
26
|
+
export type SelectPresentation = 'dropdown' | 'sheet';
|
|
27
|
+
/** @deprecated Use `SelectPresentation`. */
|
|
28
|
+
export type SelectVariant = SelectPresentation;
|
|
29
|
+
export type SelectSize = 'sm' | 'md' | 'lg';
|
|
30
|
+
|
|
31
|
+
export interface SelectOption<T extends string | number = string> {
|
|
32
|
+
label: string;
|
|
33
|
+
value: T;
|
|
34
|
+
/** Second line under the label. */
|
|
35
|
+
description?: string;
|
|
36
|
+
/** Icon, flag or avatar shown before the label. */
|
|
37
|
+
icon?: React.ReactNode;
|
|
38
|
+
/** Options sharing a group are listed together under a small heading. */
|
|
39
|
+
group?: string;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SelectProps<T extends string | number = string> {
|
|
44
|
+
/** Plain strings or `{ label, value, description?, icon?, group?, disabled? }`. */
|
|
45
|
+
options: (T | SelectOption<T>)[];
|
|
46
|
+
value?: T | null;
|
|
47
|
+
defaultValue?: T | null;
|
|
48
|
+
onValueChange?: (value: T, option: SelectOption<T>) => void;
|
|
49
|
+
/** Pick several options with checkboxes. Use `values` / `defaultValues` / `onValuesChange`. */
|
|
50
|
+
multiple?: boolean;
|
|
51
|
+
/** Controlled selection in `multiple` mode. */
|
|
52
|
+
values?: T[];
|
|
53
|
+
defaultValues?: T[];
|
|
54
|
+
onValuesChange?: (values: T[], options: SelectOption<T>[]) => void;
|
|
55
|
+
/** In `multiple` mode, up to this many selections show as chips; the rest collapse into a "+N" chip (0: one "N selected" chip). */
|
|
56
|
+
maxChips?: number;
|
|
57
|
+
/** Rests inside the field like a placeholder, floats onto the border once open or filled. */
|
|
58
|
+
label: string;
|
|
59
|
+
/** `false` puts the label above the field as static text (no notch) and shows `placeholder` inside. */
|
|
60
|
+
floatingLabel?: boolean;
|
|
61
|
+
/** Shown in the field while nothing is selected yet (only while open when the label floats). */
|
|
62
|
+
placeholder?: string;
|
|
63
|
+
/**
|
|
64
|
+
* 'dropdown' (default) anchors a menu right under the field (above it when there's no room) ·
|
|
65
|
+
* 'sheet' slides up from the bottom (a centred dialog on wide screens).
|
|
66
|
+
*/
|
|
67
|
+
presentation?: SelectPresentation;
|
|
68
|
+
/** @deprecated Use `presentation`. */
|
|
69
|
+
variant?: SelectVariant;
|
|
70
|
+
/** Trigger height, text and icon size. */
|
|
71
|
+
size?: SelectSize;
|
|
72
|
+
/** Adds a filter field above the options; matches are highlighted. */
|
|
73
|
+
searchable?: boolean;
|
|
74
|
+
searchPlaceholder?: string;
|
|
75
|
+
/** Hint under "No results" when the search matches nothing. */
|
|
76
|
+
emptyText?: string;
|
|
77
|
+
/** Sheet heading (default: label). */
|
|
78
|
+
title?: string;
|
|
79
|
+
error?: string;
|
|
80
|
+
helper?: string;
|
|
81
|
+
disabled?: boolean;
|
|
82
|
+
/** Focus, label and check colour (default: theme accent). */
|
|
83
|
+
accent?: string;
|
|
84
|
+
/** Background behind the field — the floating label cuts its notch with this colour. */
|
|
85
|
+
surface?: string;
|
|
86
|
+
/** Minimum dropdown width; the menu otherwise matches the field. */
|
|
87
|
+
minMenuWidth?: number;
|
|
88
|
+
/** Maximum dropdown height before it scrolls (it also shrinks to the room on screen). */
|
|
89
|
+
maxMenuHeight?: number;
|
|
90
|
+
/** In `multiple` mode, show a small ✕ on each chip in the field to remove it. */
|
|
91
|
+
removableChips?: boolean;
|
|
92
|
+
/** Root wrapper (the one that shakes) — margins, width, flex. */
|
|
93
|
+
style?: StyleProp<ViewStyle>;
|
|
94
|
+
/**
|
|
95
|
+
* The trigger box: background, border width / colour, radius, height, padding. Applied after the
|
|
96
|
+
* computed style, so it wins. A `borderColor` replaces the resting border only — the open
|
|
97
|
+
* (accent) and error colours still take over. A numeric `height` / `borderRadius` also
|
|
98
|
+
* re-centres the label and resizes the focus halo.
|
|
99
|
+
*/
|
|
100
|
+
fieldStyle?: StyleProp<ViewStyle>;
|
|
101
|
+
/** The selected value / placeholder text in the trigger. */
|
|
102
|
+
inputStyle?: StyleProp<TextStyle>;
|
|
103
|
+
/**
|
|
104
|
+
* The label, floating or above the field. Its `color` is the resting colour; open (accent) and
|
|
105
|
+
* error (danger) colours still take over. A `fontSize` sets the resting size and the label floats
|
|
106
|
+
* at the same ratio; the notch follows the rendered label width.
|
|
107
|
+
*/
|
|
108
|
+
labelStyle?: StyleProp<TextStyle>;
|
|
109
|
+
/** Helper / error text under the field. `color` applies to the helper; errors stay danger. */
|
|
110
|
+
messageStyle?: StyleProp<TextStyle>;
|
|
111
|
+
/** Colour of the placeholder in the trigger (default: theme mutedForeground). */
|
|
112
|
+
placeholderTextColor?: string;
|
|
113
|
+
/** Chevron colour at rest (it turns accent while open). */
|
|
114
|
+
iconColor?: string;
|
|
115
|
+
/** The dropdown / sheet panel — background, border, radius, padding, shadow. Don't pass `transform` or `opacity`: they animate. */
|
|
116
|
+
menuStyle?: StyleProp<ViewStyle>;
|
|
117
|
+
/** Each option row — padding, minHeight, radius, background (press / keyboard highlight still show). */
|
|
118
|
+
optionStyle?: StyleProp<ViewStyle>;
|
|
119
|
+
/** Each option's label text — fontSize, fontWeight, color… */
|
|
120
|
+
optionTextStyle?: StyleProp<TextStyle>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Heights and corners match FloatingInput so forms line up. */
|
|
124
|
+
const SIZES: Record<SelectSize, { h: number; font: number; pad: number; icon: number; chip: number; chipFont: number; corner: number }> = {
|
|
125
|
+
sm: { h: 44, font: 15, pad: 12, icon: 16, chip: 24, chipFont: 12.5, corner: -2 },
|
|
126
|
+
md: { h: 54, font: 16, pad: 16, icon: 18, chip: 28, chipFont: 13.5, corner: 0 },
|
|
127
|
+
lg: { h: 64, font: 17.5, pad: 18, icon: 20, chip: 32, chipFont: 15, corner: 4 },
|
|
128
|
+
};
|
|
129
|
+
const FLOAT_SCALE = 0.78;
|
|
130
|
+
/** Font size of the static label used when `floatingLabel` is false. */
|
|
131
|
+
const LABEL_ABOVE_FONT: Record<SelectSize, number> = { sm: 13, md: 14, lg: 14.5 };
|
|
132
|
+
const ICON_GAP = 32;
|
|
133
|
+
const GAP = 8; // space between field and dropdown
|
|
134
|
+
const EDGE = 12; // keep the dropdown this far from the window edges
|
|
135
|
+
const PANEL_PAD = 6;
|
|
136
|
+
const SEARCH_H = 46;
|
|
137
|
+
const FOOT_H = 48;
|
|
138
|
+
|
|
139
|
+
const normalize = <T extends string | number>(o: T | SelectOption<T>): SelectOption<T> =>
|
|
140
|
+
typeof o === 'object' ? o : { label: String(o), value: o };
|
|
141
|
+
const webInput = Platform.OS === 'web' ? ({ outlineStyle: 'none', outlineWidth: 0 } as object) : null;
|
|
142
|
+
const isWeb = Platform.OS === 'web';
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A form field that opens a list of options. By default it's a dropdown anchored right under
|
|
146
|
+
* the field (flipping above when there's no room, scrolling inside when long) that fades and
|
|
147
|
+
* scales out of the field's edge; `presentation="sheet"` slides up a drag-to-dismiss sheet
|
|
148
|
+
* (a centred dialog on wide screens). The label floats like FloatingInput, a focus halo glows
|
|
149
|
+
* while open and the chevron turns. Rows take icons, descriptions, groups and disabled states;
|
|
150
|
+
* `searchable` filters with highlighted matches, `multiple` switches to checkboxes with chips in
|
|
151
|
+
* the field. On web: arrow keys move the highlight, Enter picks, Escape closes.
|
|
152
|
+
*/
|
|
153
|
+
export function Select<T extends string | number = string>({
|
|
154
|
+
options,
|
|
155
|
+
value,
|
|
156
|
+
defaultValue = null,
|
|
157
|
+
onValueChange,
|
|
158
|
+
multiple,
|
|
159
|
+
values,
|
|
160
|
+
defaultValues,
|
|
161
|
+
onValuesChange,
|
|
162
|
+
maxChips = 2,
|
|
163
|
+
label,
|
|
164
|
+
floatingLabel = true,
|
|
165
|
+
placeholder = 'Select…',
|
|
166
|
+
presentation,
|
|
167
|
+
variant,
|
|
168
|
+
size = 'md',
|
|
169
|
+
searchable,
|
|
170
|
+
searchPlaceholder = 'Search',
|
|
171
|
+
emptyText,
|
|
172
|
+
title,
|
|
173
|
+
error,
|
|
174
|
+
helper,
|
|
175
|
+
disabled,
|
|
176
|
+
accent,
|
|
177
|
+
surface,
|
|
178
|
+
minMenuWidth = 200,
|
|
179
|
+
maxMenuHeight = 320,
|
|
180
|
+
removableChips = true,
|
|
181
|
+
style,
|
|
182
|
+
fieldStyle,
|
|
183
|
+
inputStyle,
|
|
184
|
+
labelStyle,
|
|
185
|
+
messageStyle,
|
|
186
|
+
placeholderTextColor,
|
|
187
|
+
iconColor,
|
|
188
|
+
menuStyle,
|
|
189
|
+
optionStyle,
|
|
190
|
+
optionTextStyle,
|
|
191
|
+
}: SelectProps<T>) {
|
|
192
|
+
const { colors, radius, dark, font } = useKit();
|
|
193
|
+
const ff = font.family ? { fontFamily: font.family } : null;
|
|
194
|
+
const tint = accent ?? colors.accent;
|
|
195
|
+
const bg = surface ?? colors.background;
|
|
196
|
+
const sz = SIZES[size];
|
|
197
|
+
// The user's border colour only replaces the resting one; numeric height / radius drive the layout maths.
|
|
198
|
+
const { borderColor: userBorder, ...userField } = StyleSheet.flatten(fieldStyle) ?? {};
|
|
199
|
+
const userLabelColor = StyleSheet.flatten(labelStyle)?.color as string | undefined;
|
|
200
|
+
const HEIGHT = typeof userField.height === 'number' ? userField.height : sz.h;
|
|
201
|
+
const fieldRadius = typeof userField.borderRadius === 'number' ? userField.borderRadius : radius.scale(12 + sz.corner);
|
|
202
|
+
const panelRadius = Math.max(fieldRadius, radius.scale(14));
|
|
203
|
+
const { width: winW, height: winH } = useWindowDimensions();
|
|
204
|
+
const items = useMemo(() => options.map(normalize), [options]);
|
|
205
|
+
const asSheet = (presentation ?? variant ?? 'dropdown') === 'sheet';
|
|
206
|
+
const wide = winW >= 640;
|
|
207
|
+
|
|
208
|
+
const [inner, setInner] = useState<T | null>(defaultValue);
|
|
209
|
+
const [innerMany, setInnerMany] = useState<T[]>(defaultValues ?? []);
|
|
210
|
+
const selected = value !== undefined ? value : inner;
|
|
211
|
+
const many = values ?? innerMany;
|
|
212
|
+
const current = multiple ? undefined : items.find((o) => o.value === selected);
|
|
213
|
+
const picked = multiple ? items.filter((o) => many.includes(o.value)) : current ? [current] : [];
|
|
214
|
+
const isSelected = (o: SelectOption<T>) => (multiple ? many.includes(o.value) : o.value === selected);
|
|
215
|
+
|
|
216
|
+
const [open, setOpen] = useState(false); // target state
|
|
217
|
+
const [mounted, setMounted] = useState(false); // Modal visible (stays true while closing)
|
|
218
|
+
const [query, setQuery] = useState('');
|
|
219
|
+
const [anchor, setAnchor] = useState({ x: 0, y: 0, w: 0, h: 0 });
|
|
220
|
+
const [hi, setHi] = useState(-1); // highlighted row (keyboard / hover, web)
|
|
221
|
+
const [kb, setKb] = useState(0); // on-screen keyboard height while the dropdown is open
|
|
222
|
+
const fieldRef = useRef<View>(null);
|
|
223
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
224
|
+
const rowBox = useRef<Record<string, { y: number; h: number }>>({});
|
|
225
|
+
const scrollY = useRef(0);
|
|
226
|
+
const viewH = useRef(0);
|
|
227
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
228
|
+
const alive = useRef(true);
|
|
229
|
+
|
|
230
|
+
const floating = open || picked.length > 0;
|
|
231
|
+
const float = useRef(new Animated.Value(floating ? 1 : 0)).current; // native
|
|
232
|
+
const focus = useRef(new Animated.Value(0)).current; // JS: colours
|
|
233
|
+
const halo = useRef(new Animated.Value(0)).current; // native: focus halo
|
|
234
|
+
const show = useRef(new Animated.Value(0)).current; // native: backdrop + sheet/dropdown
|
|
235
|
+
const drag = useRef(new Animated.Value(0)).current; // native: sheet drag offset
|
|
236
|
+
const shake = useRef(new Animated.Value(0)).current; // native
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
alive.current = true;
|
|
240
|
+
return () => {
|
|
241
|
+
alive.current = false;
|
|
242
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
243
|
+
};
|
|
244
|
+
}, []);
|
|
245
|
+
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
248
|
+
}, [floating, float]);
|
|
249
|
+
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
Animated.timing(focus, { toValue: open ? 1 : 0, duration: 160, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
|
|
252
|
+
Animated.spring(halo, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
253
|
+
}, [open, focus, halo]);
|
|
254
|
+
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (!error) return;
|
|
257
|
+
shake.setValue(0);
|
|
258
|
+
Animated.sequence(
|
|
259
|
+
[10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))
|
|
260
|
+
).start();
|
|
261
|
+
}, [error, shake]);
|
|
262
|
+
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (open) {
|
|
265
|
+
drag.setValue(0);
|
|
266
|
+
if (asSheet) Animated.spring(show, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start();
|
|
267
|
+
else Animated.timing(show, { toValue: 1, duration: 170, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
|
|
268
|
+
} else if (mounted) {
|
|
269
|
+
Animated.timing(show, { toValue: 0, duration: asSheet ? motion.fast + 40 : 120, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(
|
|
270
|
+
({ finished }) => finished && setMounted(false)
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
274
|
+
}, [open]);
|
|
275
|
+
|
|
276
|
+
// Track the on-screen keyboard so a dropdown with a search field shrinks instead of hiding behind it.
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
if (!open || asSheet || isWeb) return;
|
|
279
|
+
const a = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', (e) => setKb(e.endCoordinates.height));
|
|
280
|
+
const b = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', () => setKb(0));
|
|
281
|
+
return () => {
|
|
282
|
+
a.remove();
|
|
283
|
+
b.remove();
|
|
284
|
+
setKb(0);
|
|
285
|
+
};
|
|
286
|
+
}, [open, asSheet]);
|
|
287
|
+
|
|
288
|
+
// Where the field sits on screen. Only commits a change, so re-measuring is cheap.
|
|
289
|
+
const measure = useCallback((then?: () => void) => {
|
|
290
|
+
const node = fieldRef.current;
|
|
291
|
+
if (!node) return then?.();
|
|
292
|
+
node.measureInWindow((x, y, w, h) => {
|
|
293
|
+
if (!alive.current) return;
|
|
294
|
+
setAnchor((a) => (a.x === x && a.y === y && a.w === w && a.h === h ? a : { x, y, w, h }));
|
|
295
|
+
then?.();
|
|
296
|
+
});
|
|
297
|
+
}, []);
|
|
298
|
+
|
|
299
|
+
// Keep following the field while open: rotation, browser / iframe resize (a docs preview grows
|
|
300
|
+
// after the menu appears), the keyboard, and a couple of late passes for layout that settles
|
|
301
|
+
// after the first frame. The side and max height are derived from the anchor on every render.
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (!open || asSheet) return;
|
|
304
|
+
measure();
|
|
305
|
+
const t1 = setTimeout(() => measure(), 150);
|
|
306
|
+
const t2 = setTimeout(() => measure(), 400);
|
|
307
|
+
const sub = Dimensions.addEventListener('change', () => measure());
|
|
308
|
+
return () => {
|
|
309
|
+
clearTimeout(t1);
|
|
310
|
+
clearTimeout(t2);
|
|
311
|
+
sub.remove();
|
|
312
|
+
};
|
|
313
|
+
}, [open, asSheet, winW, winH, kb, measure]);
|
|
314
|
+
|
|
315
|
+
const q = query.trim().toLowerCase();
|
|
316
|
+
const filtered = useMemo(
|
|
317
|
+
() => (q ? items.filter((o) => `${o.label} ${o.description ?? ''}`.toLowerCase().includes(q)) : items),
|
|
318
|
+
[items, q]
|
|
319
|
+
);
|
|
320
|
+
const sections = useMemo(() => sectionsOf(filtered), [filtered]);
|
|
321
|
+
const flat = useMemo(() => sections.flatMap((s) => s.rows), [sections]);
|
|
322
|
+
|
|
323
|
+
// Estimated natural height of the dropdown, used to decide whether it fits below.
|
|
324
|
+
const estimate = (list: SelectOption<T>[], groups: number) =>
|
|
325
|
+
Math.min(
|
|
326
|
+
maxMenuHeight,
|
|
327
|
+
PANEL_PAD * 2 +
|
|
328
|
+
(searchable ? SEARCH_H : 0) +
|
|
329
|
+
(multiple ? FOOT_H : 0) +
|
|
330
|
+
groups * 30 +
|
|
331
|
+
(list.length ? list.reduce((n, o) => n + (o.description ? 56 : 44), 0) : 96)
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const firstEnabled = (list: SelectOption<T>[]) => list.findIndex((o) => !o.disabled);
|
|
335
|
+
|
|
336
|
+
const openMenu = () => {
|
|
337
|
+
if (disabled) return;
|
|
338
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
339
|
+
setQuery('');
|
|
340
|
+
rowBox.current = {};
|
|
341
|
+
scrollY.current = 0;
|
|
342
|
+
const all = sectionsOf(items).flatMap((s) => s.rows);
|
|
343
|
+
const sel = multiple ? -1 : all.findIndex((o) => o.value === selected);
|
|
344
|
+
setHi(isWeb ? sel : -1);
|
|
345
|
+
const go = () => {
|
|
346
|
+
setMounted(true);
|
|
347
|
+
setOpen(true);
|
|
348
|
+
};
|
|
349
|
+
if (asSheet) return go();
|
|
350
|
+
measure(go);
|
|
351
|
+
};
|
|
352
|
+
const close = () => {
|
|
353
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
354
|
+
setOpen(false);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const setMany = (next: T[]) => {
|
|
358
|
+
if (values === undefined) setInnerMany(next);
|
|
359
|
+
onValuesChange?.(next, items.filter((o) => next.includes(o.value)));
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const choose = (o: SelectOption<T>) => {
|
|
363
|
+
if (o.disabled) return;
|
|
364
|
+
if (multiple) {
|
|
365
|
+
setMany(many.includes(o.value) ? many.filter((v) => v !== o.value) : [...many, o.value]);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (value === undefined) setInner(o.value);
|
|
369
|
+
onValueChange?.(o.value, o);
|
|
370
|
+
// Let the check pop before the menu leaves.
|
|
371
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
372
|
+
closeTimer.current = setTimeout(() => setOpen(false), asSheet ? 160 : 90);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const reveal = (i: number) => {
|
|
376
|
+
const o = flat[i];
|
|
377
|
+
const box = o && rowBox.current[String(o.value)];
|
|
378
|
+
if (!box || !viewH.current) return;
|
|
379
|
+
if (box.y < scrollY.current) scrollRef.current?.scrollTo({ y: Math.max(0, box.y - PANEL_PAD), animated: false });
|
|
380
|
+
else if (box.y + box.h > scrollY.current + viewH.current)
|
|
381
|
+
scrollRef.current?.scrollTo({ y: box.y + box.h - viewH.current + PANEL_PAD, animated: false });
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Scroll the highlighted (selected) row into view once rows have laid out.
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
if (!open || hi < 0) return;
|
|
387
|
+
const t = setTimeout(() => reveal(hi), 60);
|
|
388
|
+
return () => clearTimeout(t);
|
|
389
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
390
|
+
}, [open]);
|
|
391
|
+
|
|
392
|
+
// Typing in the search keeps the first match highlighted (web) so Enter picks it.
|
|
393
|
+
const onSearch = (text: string) => {
|
|
394
|
+
setQuery(text);
|
|
395
|
+
if (!isWeb) return;
|
|
396
|
+
const t = text.trim().toLowerCase();
|
|
397
|
+
const next = t ? items.filter((o) => `${o.label} ${o.description ?? ''}`.toLowerCase().includes(t)) : items;
|
|
398
|
+
setHi(t ? firstEnabled(sectionsOf(next).flatMap((x) => x.rows)) : -1);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const step = (dir: 1 | -1, from = hi) => {
|
|
402
|
+
const n = flat.length;
|
|
403
|
+
if (!n) return;
|
|
404
|
+
let i = from < 0 ? (dir > 0 ? -1 : n) : from;
|
|
405
|
+
for (let k = 0; k < n; k++) {
|
|
406
|
+
i = (i + dir + n) % n;
|
|
407
|
+
if (!flat[i].disabled) {
|
|
408
|
+
setHi(i);
|
|
409
|
+
reveal(i);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// Web keyboard: ↑/↓ move, Home/End jump, Enter picks, Escape/Tab close.
|
|
416
|
+
const onKey = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
417
|
+
onKey.current = (e) => {
|
|
418
|
+
switch (e.key) {
|
|
419
|
+
case 'ArrowDown':
|
|
420
|
+
case 'ArrowUp':
|
|
421
|
+
e.preventDefault();
|
|
422
|
+
step(e.key === 'ArrowDown' ? 1 : -1);
|
|
423
|
+
break;
|
|
424
|
+
case 'Home':
|
|
425
|
+
case 'End':
|
|
426
|
+
if (searchable && query) return; // let the caret move in the search field
|
|
427
|
+
e.preventDefault();
|
|
428
|
+
step(e.key === 'Home' ? 1 : -1, e.key === 'Home' ? -1 : flat.length);
|
|
429
|
+
break;
|
|
430
|
+
case 'Enter':
|
|
431
|
+
if (hi >= 0 && flat[hi]) {
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
choose(flat[hi]);
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
case 'Escape':
|
|
437
|
+
e.preventDefault();
|
|
438
|
+
e.stopPropagation();
|
|
439
|
+
close();
|
|
440
|
+
break;
|
|
441
|
+
case 'Tab':
|
|
442
|
+
close();
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
if (!open || !isWeb || typeof document === 'undefined') return;
|
|
448
|
+
const handler = (e: KeyboardEvent) => onKey.current(e);
|
|
449
|
+
document.addEventListener('keydown', handler);
|
|
450
|
+
return () => document.removeEventListener('keydown', handler);
|
|
451
|
+
}, [open]);
|
|
452
|
+
|
|
453
|
+
// Drag the sheet's grabber down to dismiss.
|
|
454
|
+
const pan = useMemo(
|
|
455
|
+
() =>
|
|
456
|
+
PanResponder.create({
|
|
457
|
+
onMoveShouldSetPanResponder: (_, g) => g.dy > 4 && Math.abs(g.dy) > Math.abs(g.dx),
|
|
458
|
+
onPanResponderMove: (_, g) => drag.setValue(Math.max(0, g.dy)),
|
|
459
|
+
onPanResponderRelease: (_, g) => {
|
|
460
|
+
if (g.dy > 110 || g.vy > 1.1) setOpen(false);
|
|
461
|
+
else Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
462
|
+
},
|
|
463
|
+
onPanResponderTerminate: () => Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start(),
|
|
464
|
+
}),
|
|
465
|
+
[drag]
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const active = error ? colors.danger : tint;
|
|
469
|
+
const borderColor = focus.interpolate({
|
|
470
|
+
inputRange: [0, 1],
|
|
471
|
+
outputRange: [error ? colors.danger : ((userBorder as string | undefined) ?? colors.input), active],
|
|
472
|
+
});
|
|
473
|
+
const labelColor = focus.interpolate({ inputRange: [0, 1], outputRange: [error ? colors.danger : (userLabelColor ?? colors.mutedForeground), active] });
|
|
474
|
+
const topLabelColor = focus.interpolate({
|
|
475
|
+
inputRange: [0, 1],
|
|
476
|
+
outputRange: [error ? colors.danger : (userLabelColor ?? colors.mutedForeground), active],
|
|
477
|
+
});
|
|
478
|
+
const dense = !asSheet;
|
|
479
|
+
const rowRadius = Math.max(0, panelRadius - PANEL_PAD - 1);
|
|
480
|
+
|
|
481
|
+
const searchField = searchable ? (
|
|
482
|
+
<View
|
|
483
|
+
style={
|
|
484
|
+
dense
|
|
485
|
+
? [styles.searchDense, { borderBottomColor: colors.border }]
|
|
486
|
+
: [styles.search, { backgroundColor: colors.muted, borderRadius: radius.md }]
|
|
487
|
+
}
|
|
488
|
+
>
|
|
489
|
+
<Icon name="search" size={dense ? 16 : 17} color={colors.mutedForeground} />
|
|
490
|
+
<TextInput
|
|
491
|
+
value={query}
|
|
492
|
+
onChangeText={onSearch}
|
|
493
|
+
placeholder={searchPlaceholder}
|
|
494
|
+
placeholderTextColor={colors.mutedForeground}
|
|
495
|
+
selectionColor={tint}
|
|
496
|
+
cursorColor={tint}
|
|
497
|
+
autoCorrect={false}
|
|
498
|
+
autoCapitalize="none"
|
|
499
|
+
autoFocus={isWeb}
|
|
500
|
+
returnKeyType="done"
|
|
501
|
+
accessibilityLabel={searchPlaceholder}
|
|
502
|
+
style={[styles.searchInput, ff, { color: colors.foreground, fontSize: dense ? 15 : 16 }, webInput]}
|
|
503
|
+
/>
|
|
504
|
+
{query ? (
|
|
505
|
+
<Pressable
|
|
506
|
+
onPress={() => onSearch('')}
|
|
507
|
+
hitSlop={12}
|
|
508
|
+
accessibilityRole="button"
|
|
509
|
+
accessibilityLabel="Clear search"
|
|
510
|
+
style={[styles.clear, { backgroundColor: alpha(colors.mutedForeground, 0.22) }]}
|
|
511
|
+
>
|
|
512
|
+
<Icon name="x" size={11} color={colors.foreground} strokeWidth={2.4} />
|
|
513
|
+
</Pressable>
|
|
514
|
+
) : null}
|
|
515
|
+
</View>
|
|
516
|
+
) : null;
|
|
517
|
+
|
|
518
|
+
let rowIndex = -1;
|
|
519
|
+
const body = (
|
|
520
|
+
<>
|
|
521
|
+
{searchField}
|
|
522
|
+
<ScrollView
|
|
523
|
+
ref={scrollRef}
|
|
524
|
+
keyboardShouldPersistTaps="handled"
|
|
525
|
+
bounces={false}
|
|
526
|
+
style={styles.scroll}
|
|
527
|
+
contentContainerStyle={dense ? styles.listDense : styles.listContent}
|
|
528
|
+
scrollEventThrottle={32}
|
|
529
|
+
onScroll={(e) => (scrollY.current = e.nativeEvent.contentOffset.y)}
|
|
530
|
+
onLayout={(e) => (viewH.current = e.nativeEvent.layout.height)}
|
|
531
|
+
accessibilityRole="list"
|
|
532
|
+
>
|
|
533
|
+
{flat.length ? (
|
|
534
|
+
sections.map((sec, si) => (
|
|
535
|
+
<React.Fragment key={sec.title ?? `s${si}`}>
|
|
536
|
+
{sec.title ? (
|
|
537
|
+
<>
|
|
538
|
+
{si > 0 ? <View style={[styles.sep, { backgroundColor: colors.border }]} /> : null}
|
|
539
|
+
<Text style={[styles.groupTitle, dense ? styles.groupDense : null, ff, { color: colors.mutedForeground }]}>{sec.title}</Text>
|
|
540
|
+
</>
|
|
541
|
+
) : null}
|
|
542
|
+
{sec.rows.map((o) => {
|
|
543
|
+
rowIndex += 1;
|
|
544
|
+
const i = rowIndex;
|
|
545
|
+
return (
|
|
546
|
+
<OptionRow
|
|
547
|
+
key={String(o.value)}
|
|
548
|
+
option={o}
|
|
549
|
+
selected={isSelected(o)}
|
|
550
|
+
highlighted={isWeb && i === hi}
|
|
551
|
+
multiple={!!multiple}
|
|
552
|
+
dense={dense}
|
|
553
|
+
rowRadius={rowRadius}
|
|
554
|
+
query={q}
|
|
555
|
+
tint={tint}
|
|
556
|
+
rowStyle={optionStyle}
|
|
557
|
+
textStyle={optionTextStyle}
|
|
558
|
+
onPress={() => choose(o)}
|
|
559
|
+
onHover={() => !o.disabled && setHi(i)}
|
|
560
|
+
onLayout={(y, h) => (rowBox.current[String(o.value)] = { y, h })}
|
|
561
|
+
/>
|
|
562
|
+
);
|
|
563
|
+
})}
|
|
564
|
+
</React.Fragment>
|
|
565
|
+
))
|
|
566
|
+
) : (
|
|
567
|
+
<View style={dense ? styles.emptyDense : styles.empty}>
|
|
568
|
+
<View style={[styles.emptyIcon, dense && styles.emptyIconDense, { backgroundColor: colors.muted }]}>
|
|
569
|
+
<Icon name="search" size={dense ? 16 : 20} color={colors.mutedForeground} />
|
|
570
|
+
</View>
|
|
571
|
+
<Text style={[styles.emptyTitle, ff, { color: colors.foreground, fontSize: dense ? 14 : 15 }]}>
|
|
572
|
+
{q ? 'No results' : 'No options'}
|
|
573
|
+
</Text>
|
|
574
|
+
{q ? (
|
|
575
|
+
<Text style={[styles.emptyText, ff, { color: colors.mutedForeground }]}>
|
|
576
|
+
{emptyText ?? `Nothing matches “${query.trim()}”.`}
|
|
577
|
+
</Text>
|
|
578
|
+
) : null}
|
|
579
|
+
</View>
|
|
580
|
+
)}
|
|
581
|
+
</ScrollView>
|
|
582
|
+
{multiple ? (
|
|
583
|
+
dense ? (
|
|
584
|
+
<View style={[styles.footDense, { borderTopColor: colors.border }]}>
|
|
585
|
+
<Pressable
|
|
586
|
+
onPress={() => setMany([])}
|
|
587
|
+
disabled={!many.length}
|
|
588
|
+
accessibilityRole="button"
|
|
589
|
+
style={({ pressed }) => [
|
|
590
|
+
styles.footGhost,
|
|
591
|
+
{ borderRadius: radius.capsule(32), opacity: many.length ? 1 : 0.4, backgroundColor: pressed ? colors.muted : 'transparent' },
|
|
592
|
+
]}
|
|
593
|
+
>
|
|
594
|
+
<Text style={[styles.footDenseText, ff, { color: colors.mutedForeground }]}>Clear</Text>
|
|
595
|
+
</Pressable>
|
|
596
|
+
<View style={styles.footMid}>
|
|
597
|
+
<CountBadge count={many.length} tint={tint} />
|
|
598
|
+
</View>
|
|
599
|
+
<Pressable
|
|
600
|
+
onPress={close}
|
|
601
|
+
accessibilityRole="button"
|
|
602
|
+
style={({ pressed }) => [styles.footSolid, { backgroundColor: tint, borderRadius: radius.capsule(32), opacity: pressed ? 0.85 : 1 }]}
|
|
603
|
+
>
|
|
604
|
+
<Text style={[styles.footDenseText, styles.footStrong, ff, { color: readableOn(tint) }]}>Done</Text>
|
|
605
|
+
</Pressable>
|
|
606
|
+
</View>
|
|
607
|
+
) : (
|
|
608
|
+
<View style={[styles.footer, { borderTopColor: colors.border }]}>
|
|
609
|
+
<Pressable
|
|
610
|
+
onPress={() => setMany([])}
|
|
611
|
+
disabled={!many.length}
|
|
612
|
+
accessibilityRole="button"
|
|
613
|
+
style={({ pressed }) => [styles.footBtn, { borderRadius: radius.md, opacity: many.length ? (pressed ? 0.6 : 1) : 0.4 }]}
|
|
614
|
+
>
|
|
615
|
+
<Text style={[styles.footText, ff, { color: colors.foreground }]}>Clear</Text>
|
|
616
|
+
</Pressable>
|
|
617
|
+
<Pressable
|
|
618
|
+
onPress={close}
|
|
619
|
+
accessibilityRole="button"
|
|
620
|
+
style={({ pressed }) => [styles.footBtn, styles.footMain, { backgroundColor: tint, borderRadius: radius.md, opacity: pressed ? 0.85 : 1 }]}
|
|
621
|
+
>
|
|
622
|
+
<Text style={[styles.footText, styles.footStrong, styles.tabular, ff, { color: readableOn(tint) }]}>
|
|
623
|
+
{many.length ? `Done · ${many.length}` : 'Done'}
|
|
624
|
+
</Text>
|
|
625
|
+
</Pressable>
|
|
626
|
+
</View>
|
|
627
|
+
)
|
|
628
|
+
) : null}
|
|
629
|
+
</>
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
// Dropdown geometry: match the field, never narrower than minMenuWidth, clamped to the window.
|
|
633
|
+
const popW = Math.max(0, Math.min(winW - EDGE * 2, Math.max(anchor.w, minMenuWidth)));
|
|
634
|
+
const popLeft = Math.max(EDGE, Math.min(anchor.x, winW - popW - EDGE));
|
|
635
|
+
const roomBelow = winH - kb - (anchor.y + anchor.h + GAP) - EDGE;
|
|
636
|
+
const roomAbove = anchor.y - GAP - EDGE;
|
|
637
|
+
// Sized from all options (not the filtered ones) so typing a search never flips the menu.
|
|
638
|
+
const need = estimate(items, new Set(items.map((o) => o.group).filter(Boolean)).size);
|
|
639
|
+
// Prefer below — the list scrolls inside maxHeight. Only flip above when below can't fit a
|
|
640
|
+
// usable strip (~120pt) and above clearly has more room (e.g. field near the screen bottom).
|
|
641
|
+
const minUsable = Math.min(120, Math.max(72, Math.floor(need * 0.35)));
|
|
642
|
+
const below = roomBelow >= minUsable || roomBelow + 32 >= roomAbove;
|
|
643
|
+
const popMaxH = Math.max(96, Math.min(maxMenuHeight, below ? Math.max(0, roomBelow) : Math.max(0, roomAbove)));
|
|
644
|
+
|
|
645
|
+
const hasIcon = !multiple && !!current?.icon;
|
|
646
|
+
const textLeft = hasIcon ? sz.pad + ICON_GAP : sz.pad;
|
|
647
|
+
const message = error ?? helper;
|
|
648
|
+
const remove = (v: T) => setMany(many.filter((x) => x !== v));
|
|
649
|
+
// Multiple mode: the first `maxChips` picks as chips, the rest as a "+N" chip. Removed chips stay
|
|
650
|
+
// rendered (collapsing) until their exit animation finishes.
|
|
651
|
+
const chipItems: { key: string; item: { option?: SelectOption<T>; more: number } }[] = [];
|
|
652
|
+
if (multiple) {
|
|
653
|
+
picked.slice(0, maxChips).forEach((o) => chipItems.push({ key: `o:${String(o.value)}`, item: { option: o, more: 0 } }));
|
|
654
|
+
if (picked.length > maxChips) chipItems.push({ key: 'more', item: { more: picked.length - maxChips } });
|
|
655
|
+
}
|
|
656
|
+
const [chips, chipGone] = usePresence(chipItems);
|
|
657
|
+
const showChips = chips.length > 0;
|
|
658
|
+
const chipBg = alpha(tint, dark ? 0.2 : 0.12);
|
|
659
|
+
|
|
660
|
+
return (
|
|
661
|
+
<Animated.View style={[{ transform: [{ translateX: shake }] }, style]}>
|
|
662
|
+
{floatingLabel ? null : (
|
|
663
|
+
<Animated.Text
|
|
664
|
+
numberOfLines={1}
|
|
665
|
+
// The trigger already carries the label for screen readers.
|
|
666
|
+
accessible={false}
|
|
667
|
+
importantForAccessibility="no"
|
|
668
|
+
accessibilityElementsHidden
|
|
669
|
+
style={[styles.topLabel, ff, { fontSize: LABEL_ABOVE_FONT[size] }, labelStyle, { color: topLabelColor, opacity: disabled ? 0.5 : 1 }]}
|
|
670
|
+
>
|
|
671
|
+
{label}
|
|
672
|
+
</Animated.Text>
|
|
673
|
+
)}
|
|
674
|
+
<Pressable
|
|
675
|
+
ref={fieldRef}
|
|
676
|
+
onPress={() => (open ? close() : openMenu())}
|
|
677
|
+
disabled={disabled}
|
|
678
|
+
accessibilityRole="combobox"
|
|
679
|
+
accessibilityLabel={label}
|
|
680
|
+
accessibilityValue={{ text: picked.length ? picked.map((o) => o.label).join(', ') : 'None selected' }}
|
|
681
|
+
accessibilityState={{ expanded: open, disabled: !!disabled }}
|
|
682
|
+
style={{ opacity: disabled ? 0.5 : 1 }}
|
|
683
|
+
>
|
|
684
|
+
{({ hovered }: { pressed: boolean; hovered?: boolean }) => (
|
|
685
|
+
<>
|
|
686
|
+
<Animated.View
|
|
687
|
+
pointerEvents="none"
|
|
688
|
+
style={[
|
|
689
|
+
styles.halo,
|
|
690
|
+
{
|
|
691
|
+
borderRadius: fieldRadius + 4,
|
|
692
|
+
borderColor: alpha(active, 0.2),
|
|
693
|
+
opacity: halo,
|
|
694
|
+
transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }],
|
|
695
|
+
},
|
|
696
|
+
]}
|
|
697
|
+
/>
|
|
698
|
+
<Animated.View
|
|
699
|
+
style={[
|
|
700
|
+
styles.field,
|
|
701
|
+
{ height: HEIGHT, borderRadius: fieldRadius, backgroundColor: bg },
|
|
702
|
+
userField,
|
|
703
|
+
{ borderColor: hovered && !open && !error ? colors.mutedForeground : borderColor },
|
|
704
|
+
]}
|
|
705
|
+
>
|
|
706
|
+
{hasIcon ? <View style={[styles.icon, { left: sz.pad - 2 }]}>{current!.icon}</View> : null}
|
|
707
|
+
{showChips ? (
|
|
708
|
+
<View style={[styles.chips, { paddingLeft: sz.pad - 6, paddingRight: sz.pad + sz.icon + 8 }]}>
|
|
709
|
+
{chips.map(({ key, item, leaving, appear }) => (
|
|
710
|
+
<AnimatedChip key={key} leaving={leaving} appear={appear} gap={6} shrink={!!item.option} onGone={() => chipGone(key)}>
|
|
711
|
+
{item.option ? (
|
|
712
|
+
<View
|
|
713
|
+
style={[
|
|
714
|
+
styles.chip,
|
|
715
|
+
{
|
|
716
|
+
height: sz.chip,
|
|
717
|
+
paddingLeft: item.option.icon ? 3 : 10,
|
|
718
|
+
paddingRight: removableChips && !disabled ? 4 : 10,
|
|
719
|
+
backgroundColor: chipBg,
|
|
720
|
+
borderRadius: radius.capsule(sz.chip),
|
|
721
|
+
},
|
|
722
|
+
]}
|
|
723
|
+
>
|
|
724
|
+
{item.option.icon ? (
|
|
725
|
+
<View style={[styles.chipIcon, { width: sz.chip - 6, height: sz.chip - 6, borderRadius: radius.capsule(sz.chip - 6) }]}>
|
|
726
|
+
{item.option.icon}
|
|
727
|
+
</View>
|
|
728
|
+
) : null}
|
|
729
|
+
<Text numberOfLines={1} style={[styles.chipText, ff, { color: tint, fontSize: sz.chipFont }]}>
|
|
730
|
+
{item.option.label}
|
|
731
|
+
</Text>
|
|
732
|
+
{removableChips && !disabled ? (
|
|
733
|
+
<Pressable
|
|
734
|
+
onPress={() => item.option && remove(item.option.value)}
|
|
735
|
+
hitSlop={12}
|
|
736
|
+
accessibilityRole="button"
|
|
737
|
+
accessibilityLabel={`Remove ${item.option.label}`}
|
|
738
|
+
style={({ pressed }) => [
|
|
739
|
+
styles.chipX,
|
|
740
|
+
{
|
|
741
|
+
width: sz.chip - 8,
|
|
742
|
+
height: sz.chip - 8,
|
|
743
|
+
borderRadius: radius.capsule(sz.chip - 8),
|
|
744
|
+
backgroundColor: pressed ? alpha(tint, 0.2) : 'transparent',
|
|
745
|
+
},
|
|
746
|
+
]}
|
|
747
|
+
>
|
|
748
|
+
<Icon name="x" size={Math.round(sz.chip * 0.45)} color={tint} strokeWidth={2.2} />
|
|
749
|
+
</Pressable>
|
|
750
|
+
) : null}
|
|
751
|
+
</View>
|
|
752
|
+
) : (
|
|
753
|
+
<View
|
|
754
|
+
accessible
|
|
755
|
+
accessibilityLabel={maxChips > 0 ? `${item.more} more` : `${item.more} selected`}
|
|
756
|
+
style={[styles.chip, styles.more, { height: sz.chip, backgroundColor: chipBg, borderRadius: radius.capsule(sz.chip) }]}
|
|
757
|
+
>
|
|
758
|
+
{maxChips > 0 ? <Text style={[styles.chipText, styles.tabular, ff, { color: tint, fontSize: sz.chipFont }]}>+</Text> : null}
|
|
759
|
+
<RollingNumber value={item.more} distance={sz.chipFont} style={[styles.chipText, ff, { color: tint, fontSize: sz.chipFont }]} />
|
|
760
|
+
{maxChips > 0 ? null : (
|
|
761
|
+
<Text style={[styles.chipText, ff, { color: tint, fontSize: sz.chipFont, marginLeft: 3 }]}>selected</Text>
|
|
762
|
+
)}
|
|
763
|
+
</View>
|
|
764
|
+
)}
|
|
765
|
+
</AnimatedChip>
|
|
766
|
+
))}
|
|
767
|
+
</View>
|
|
768
|
+
) : (
|
|
769
|
+
<Text
|
|
770
|
+
numberOfLines={1}
|
|
771
|
+
style={[
|
|
772
|
+
ff,
|
|
773
|
+
{
|
|
774
|
+
fontSize: sz.font,
|
|
775
|
+
fontWeight: '400',
|
|
776
|
+
color: current ? colors.foreground : colors.mutedForeground,
|
|
777
|
+
paddingLeft: textLeft,
|
|
778
|
+
paddingRight: sz.pad + sz.icon + 8,
|
|
779
|
+
},
|
|
780
|
+
inputStyle,
|
|
781
|
+
!current && { color: placeholderTextColor ?? colors.mutedForeground },
|
|
782
|
+
]}
|
|
783
|
+
>
|
|
784
|
+
{current ? current.label : open || !floatingLabel ? placeholder : ''}
|
|
785
|
+
</Text>
|
|
786
|
+
)}
|
|
787
|
+
{floatingLabel ? (
|
|
788
|
+
<Animated.View
|
|
789
|
+
pointerEvents="none"
|
|
790
|
+
style={[
|
|
791
|
+
styles.labelWrap,
|
|
792
|
+
{
|
|
793
|
+
left: textLeft,
|
|
794
|
+
right: sz.pad + sz.icon + 8,
|
|
795
|
+
height: HEIGHT - 3,
|
|
796
|
+
transform: [
|
|
797
|
+
{ translateY: float.interpolate({ inputRange: [0, 1], outputRange: [0, -HEIGHT / 2] }) },
|
|
798
|
+
{ translateX: float.interpolate({ inputRange: [0, 1], outputRange: [0, -(textLeft - sz.pad) - 4] }) },
|
|
799
|
+
{ scale: float.interpolate({ inputRange: [0, 1], outputRange: [1, FLOAT_SCALE] }) },
|
|
800
|
+
],
|
|
801
|
+
},
|
|
802
|
+
]}
|
|
803
|
+
>
|
|
804
|
+
{/* The notch: the label paints the surface over the border, so it matches the rendered width. */}
|
|
805
|
+
<Animated.Text numberOfLines={1} style={[styles.label, ff, { fontSize: sz.font, backgroundColor: bg }, labelStyle, { color: labelColor }]}>
|
|
806
|
+
{label}
|
|
807
|
+
</Animated.Text>
|
|
808
|
+
</Animated.View>
|
|
809
|
+
) : null}
|
|
810
|
+
<Chevron open={open} size={sz.icon} right={sz.pad - 4} color={open ? active : (iconColor ?? colors.mutedForeground)} />
|
|
811
|
+
</Animated.View>
|
|
812
|
+
</>
|
|
813
|
+
)}
|
|
814
|
+
</Pressable>
|
|
815
|
+
{message ? <FieldMessage text={message} error={!!error} style={messageStyle} /> : null}
|
|
816
|
+
|
|
817
|
+
<Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={close}>
|
|
818
|
+
<Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: asSheet ? colors.overlay : 'transparent', opacity: show }]}>
|
|
819
|
+
<Pressable style={StyleSheet.absoluteFill} onPress={close} accessibilityLabel="Close" accessibilityRole="button" />
|
|
820
|
+
</Animated.View>
|
|
821
|
+
|
|
822
|
+
{asSheet ? (
|
|
823
|
+
<KeyboardAvoidingView
|
|
824
|
+
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
825
|
+
style={[styles.sheetHost, wide && styles.centered]}
|
|
826
|
+
pointerEvents="box-none"
|
|
827
|
+
>
|
|
828
|
+
<Animated.View
|
|
829
|
+
accessibilityViewIsModal
|
|
830
|
+
style={[
|
|
831
|
+
styles.sheet,
|
|
832
|
+
{ backgroundColor: colors.popover, maxHeight: winH * (wide ? 0.7 : 0.78), borderColor: colors.border },
|
|
833
|
+
wide
|
|
834
|
+
? { width: Math.min(460, winW - 48), borderRadius: radius.xl, paddingBottom: 8 }
|
|
835
|
+
: { alignSelf: 'stretch', borderTopLeftRadius: radius.xl + 4, borderTopRightRadius: radius.xl + 4, paddingBottom: 28 },
|
|
836
|
+
menuStyle,
|
|
837
|
+
wide
|
|
838
|
+
? {
|
|
839
|
+
opacity: show,
|
|
840
|
+
transform: [
|
|
841
|
+
{ scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.94, 1] }) },
|
|
842
|
+
{ translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [16, 0] }), drag) },
|
|
843
|
+
],
|
|
844
|
+
}
|
|
845
|
+
: { transform: [{ translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [winH * 0.8, 0] }), drag) }] },
|
|
846
|
+
]}
|
|
847
|
+
>
|
|
848
|
+
<View {...pan.panHandlers} style={styles.sheetHeader}>
|
|
849
|
+
{!wide ? <View style={[styles.grabber, { backgroundColor: colors.border }]} /> : null}
|
|
850
|
+
<Text style={[styles.title, ff, { color: colors.foreground }]}>{title ?? label}</Text>
|
|
851
|
+
{multiple ? (
|
|
852
|
+
<View style={styles.subtitleRow}>
|
|
853
|
+
{many.length ? (
|
|
854
|
+
<CountBadge count={many.length} tint={tint} />
|
|
855
|
+
) : (
|
|
856
|
+
<Text style={[styles.subtitle, ff, { color: colors.mutedForeground }]}>Choose one or more</Text>
|
|
857
|
+
)}
|
|
858
|
+
</View>
|
|
859
|
+
) : null}
|
|
860
|
+
</View>
|
|
861
|
+
{body}
|
|
862
|
+
</Animated.View>
|
|
863
|
+
</KeyboardAvoidingView>
|
|
864
|
+
) : (
|
|
865
|
+
<Animated.View
|
|
866
|
+
accessibilityViewIsModal
|
|
867
|
+
style={[
|
|
868
|
+
styles.dropdown,
|
|
869
|
+
{
|
|
870
|
+
left: popLeft,
|
|
871
|
+
width: popW,
|
|
872
|
+
maxHeight: popMaxH,
|
|
873
|
+
backgroundColor: colors.popover,
|
|
874
|
+
borderColor: colors.border,
|
|
875
|
+
borderRadius: panelRadius,
|
|
876
|
+
shadowOpacity: dark ? 0.5 : 0.12,
|
|
877
|
+
transformOrigin: below ? 'top' : 'bottom',
|
|
878
|
+
},
|
|
879
|
+
menuStyle,
|
|
880
|
+
{
|
|
881
|
+
opacity: show,
|
|
882
|
+
transform: [
|
|
883
|
+
{ translateY: show.interpolate({ inputRange: [0, 1], outputRange: [below ? -6 : 6, 0] }) },
|
|
884
|
+
{ scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) },
|
|
885
|
+
],
|
|
886
|
+
},
|
|
887
|
+
below ? { top: anchor.y + anchor.h + GAP } : { bottom: Math.max(EDGE, winH - anchor.y + GAP) },
|
|
888
|
+
]}
|
|
889
|
+
>
|
|
890
|
+
{body}
|
|
891
|
+
</Animated.View>
|
|
892
|
+
)}
|
|
893
|
+
</Modal>
|
|
894
|
+
</Animated.View>
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Groups options by `group` (first-appearance order); a single untitled section when none are grouped. */
|
|
899
|
+
function sectionsOf<T extends string | number>(list: SelectOption<T>[]): { title?: string; rows: SelectOption<T>[] }[] {
|
|
900
|
+
if (!list.some((o) => o.group)) return [{ rows: list }];
|
|
901
|
+
const map = new Map<string, SelectOption<T>[]>();
|
|
902
|
+
list.forEach((o) => map.set(o.group ?? '', [...(map.get(o.group ?? '') ?? []), o]));
|
|
903
|
+
return [...map].map(([title, rows]) => ({ title: title || undefined, rows }));
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function OptionRow<T extends string | number>({
|
|
907
|
+
option,
|
|
908
|
+
selected,
|
|
909
|
+
highlighted,
|
|
910
|
+
multiple,
|
|
911
|
+
dense,
|
|
912
|
+
rowRadius,
|
|
913
|
+
query,
|
|
914
|
+
tint,
|
|
915
|
+
rowStyle,
|
|
916
|
+
textStyle,
|
|
917
|
+
onPress,
|
|
918
|
+
onHover,
|
|
919
|
+
onLayout,
|
|
920
|
+
}: {
|
|
921
|
+
option: SelectOption<T>;
|
|
922
|
+
selected: boolean;
|
|
923
|
+
highlighted: boolean;
|
|
924
|
+
multiple: boolean;
|
|
925
|
+
dense: boolean;
|
|
926
|
+
rowRadius: number;
|
|
927
|
+
query: string;
|
|
928
|
+
tint: string;
|
|
929
|
+
rowStyle?: StyleProp<ViewStyle>;
|
|
930
|
+
textStyle?: StyleProp<TextStyle>;
|
|
931
|
+
onPress: () => void;
|
|
932
|
+
onHover: () => void;
|
|
933
|
+
onLayout: (y: number, h: number) => void;
|
|
934
|
+
}) {
|
|
935
|
+
const { colors, radius, font, dark } = useKit();
|
|
936
|
+
const ff = font.family ? { fontFamily: font.family } : null;
|
|
937
|
+
const check = useRef(new Animated.Value(selected ? 1 : 0)).current; // native: scale + opacity
|
|
938
|
+
useEffect(() => {
|
|
939
|
+
Animated.spring(check, { toValue: selected ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
|
|
940
|
+
}, [selected, check]);
|
|
941
|
+
const on = readableOn(tint);
|
|
942
|
+
const box = dense ? 18 : 22;
|
|
943
|
+
const hoverBg = alpha(colors.foreground, 0.06);
|
|
944
|
+
const pressBg = alpha(colors.foreground, 0.1);
|
|
945
|
+
const selectedBg = alpha(tint, dark ? 0.14 : 0.08);
|
|
946
|
+
|
|
947
|
+
return (
|
|
948
|
+
<Pressable
|
|
949
|
+
onPress={onPress}
|
|
950
|
+
onHoverIn={onHover}
|
|
951
|
+
onLayout={(e) => onLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)}
|
|
952
|
+
disabled={option.disabled}
|
|
953
|
+
accessibilityRole={multiple ? 'checkbox' : 'menuitem'}
|
|
954
|
+
accessibilityState={multiple ? { checked: selected, disabled: !!option.disabled } : { selected, disabled: !!option.disabled }}
|
|
955
|
+
style={({ pressed }) => [
|
|
956
|
+
dense ? styles.rowDense : styles.row,
|
|
957
|
+
{
|
|
958
|
+
borderRadius: dense ? rowRadius : radius.scale(10),
|
|
959
|
+
backgroundColor: pressed ? pressBg : highlighted ? hoverBg : selected ? selectedBg : 'transparent',
|
|
960
|
+
opacity: option.disabled ? 0.4 : 1,
|
|
961
|
+
},
|
|
962
|
+
rowStyle,
|
|
963
|
+
// Press and keyboard highlight still show over a custom row background.
|
|
964
|
+
(pressed || highlighted) && { backgroundColor: pressed ? pressBg : hoverBg },
|
|
965
|
+
]}
|
|
966
|
+
>
|
|
967
|
+
{multiple ? (
|
|
968
|
+
<View style={[styles.box, { width: box, height: box, borderColor: selected ? tint : colors.input, borderRadius: radius.scale(5) }]}>
|
|
969
|
+
<Animated.View style={[styles.boxFill, { backgroundColor: tint, borderRadius: radius.scale(5), opacity: check, transform: [{ scale: check }] }]}>
|
|
970
|
+
<Icon name="check" size={dense ? 12 : 14} color={on} strokeWidth={2.8} />
|
|
971
|
+
</Animated.View>
|
|
972
|
+
</View>
|
|
973
|
+
) : null}
|
|
974
|
+
{option.icon ? <View style={styles.rowIcon}>{option.icon}</View> : null}
|
|
975
|
+
<View style={styles.rowText}>
|
|
976
|
+
<Highlight
|
|
977
|
+
text={option.label}
|
|
978
|
+
query={query}
|
|
979
|
+
tint={tint}
|
|
980
|
+
style={[styles.rowLabel, ff, { color: colors.foreground, fontSize: dense ? 15 : 16 }, selected && styles.rowLabelOn, textStyle]}
|
|
981
|
+
/>
|
|
982
|
+
{option.description ? (
|
|
983
|
+
<Highlight
|
|
984
|
+
text={option.description}
|
|
985
|
+
query={query}
|
|
986
|
+
tint={tint}
|
|
987
|
+
style={[styles.rowDesc, ff, { color: colors.mutedForeground, fontSize: dense ? 12.5 : 13 }]}
|
|
988
|
+
/>
|
|
989
|
+
) : null}
|
|
990
|
+
</View>
|
|
991
|
+
{!multiple ? (
|
|
992
|
+
<Animated.View
|
|
993
|
+
style={[
|
|
994
|
+
styles.checkWrap,
|
|
995
|
+
{
|
|
996
|
+
opacity: check,
|
|
997
|
+
transform: [{ scale: check }, { rotate: check.interpolate({ inputRange: [0, 1], outputRange: ['-30deg', '0deg'] }) }],
|
|
998
|
+
},
|
|
999
|
+
]}
|
|
1000
|
+
>
|
|
1001
|
+
<Icon name="check" size={dense ? 17 : 18} color={tint} strokeWidth={2.4} />
|
|
1002
|
+
</Animated.View>
|
|
1003
|
+
) : null}
|
|
1004
|
+
</Pressable>
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
type Present<V> = { key: string; item: V; leaving: boolean; appear: boolean };
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Keeps entries that drop out of `items` rendered in their old place, flagged `leaving`, until
|
|
1012
|
+
* `done(key)` says their exit animation finished. Entries present on the first render don't animate in.
|
|
1013
|
+
*/
|
|
1014
|
+
function usePresence<V>(items: { key: string; item: V }[]) {
|
|
1015
|
+
const prev = useRef<Present<V>[]>([]);
|
|
1016
|
+
const finished = useRef(new Set<string>());
|
|
1017
|
+
const ready = useRef(false);
|
|
1018
|
+
const alive = useRef(true);
|
|
1019
|
+
const [, rerender] = useState(0);
|
|
1020
|
+
useEffect(() => {
|
|
1021
|
+
ready.current = true;
|
|
1022
|
+
alive.current = true;
|
|
1023
|
+
return () => {
|
|
1024
|
+
alive.current = false;
|
|
1025
|
+
};
|
|
1026
|
+
}, []);
|
|
1027
|
+
|
|
1028
|
+
const live = new Set(items.map((e) => e.key));
|
|
1029
|
+
live.forEach((k) => finished.current.delete(k));
|
|
1030
|
+
const out: Present<V>[] = [];
|
|
1031
|
+
let next = 0;
|
|
1032
|
+
const emitUpTo = (end: number) => {
|
|
1033
|
+
for (; next <= end; next++) out.push({ ...items[next], leaving: false, appear: ready.current });
|
|
1034
|
+
};
|
|
1035
|
+
for (const e of prev.current) {
|
|
1036
|
+
if (live.has(e.key)) emitUpTo(items.findIndex((x) => x.key === e.key));
|
|
1037
|
+
else if (!finished.current.has(e.key)) out.push({ ...e, leaving: true });
|
|
1038
|
+
}
|
|
1039
|
+
emitUpTo(items.length - 1);
|
|
1040
|
+
prev.current = out;
|
|
1041
|
+
|
|
1042
|
+
const done = useCallback((key: string) => {
|
|
1043
|
+
finished.current.add(key);
|
|
1044
|
+
if (alive.current) rerender((n) => n + 1);
|
|
1045
|
+
}, []);
|
|
1046
|
+
return [out, done] as const;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* A chip that pops in (native scale + opacity spring) and, when `leaving`, fades and shrinks while
|
|
1051
|
+
* an outer view collapses its width and margin on the JS driver so neighbours slide over smoothly.
|
|
1052
|
+
*/
|
|
1053
|
+
function AnimatedChip({
|
|
1054
|
+
leaving,
|
|
1055
|
+
appear,
|
|
1056
|
+
gap,
|
|
1057
|
+
shrink,
|
|
1058
|
+
onGone,
|
|
1059
|
+
children,
|
|
1060
|
+
}: {
|
|
1061
|
+
leaving: boolean;
|
|
1062
|
+
appear: boolean;
|
|
1063
|
+
gap: number;
|
|
1064
|
+
/** Whether the slot may shrink when the row is tight (chips yes, the "+N" counter no). */
|
|
1065
|
+
shrink: boolean;
|
|
1066
|
+
onGone: () => void;
|
|
1067
|
+
children: React.ReactNode;
|
|
1068
|
+
}) {
|
|
1069
|
+
const pop = useRef(new Animated.Value(appear ? 0 : 1)).current; // native: scale + opacity
|
|
1070
|
+
const size = useRef(new Animated.Value(1)).current; // JS: width + margin of the outer slot
|
|
1071
|
+
const [w, setW] = useState(0);
|
|
1072
|
+
const gone = useRef(onGone);
|
|
1073
|
+
gone.current = onGone;
|
|
1074
|
+
|
|
1075
|
+
useEffect(() => {
|
|
1076
|
+
let alive = true;
|
|
1077
|
+
let anims: Animated.CompositeAnimation[];
|
|
1078
|
+
if (leaving) {
|
|
1079
|
+
let left = 2;
|
|
1080
|
+
const end = ({ finished }: { finished: boolean }) => {
|
|
1081
|
+
if (finished && alive && --left === 0) gone.current();
|
|
1082
|
+
};
|
|
1083
|
+
anims = [
|
|
1084
|
+
Animated.timing(pop, { toValue: 0, duration: 150, easing: Easing.out(Easing.quad), useNativeDriver: true }),
|
|
1085
|
+
Animated.timing(size, { toValue: 0, duration: 230, easing: Easing.inOut(Easing.cubic), useNativeDriver: false }),
|
|
1086
|
+
];
|
|
1087
|
+
anims.forEach((a) => a.start(end));
|
|
1088
|
+
} else {
|
|
1089
|
+
size.setValue(1);
|
|
1090
|
+
anims = [Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.snappy })];
|
|
1091
|
+
anims.forEach((a) => a.start());
|
|
1092
|
+
}
|
|
1093
|
+
return () => {
|
|
1094
|
+
alive = false;
|
|
1095
|
+
anims.forEach((a) => a.stop());
|
|
1096
|
+
};
|
|
1097
|
+
}, [leaving, pop, size]);
|
|
1098
|
+
|
|
1099
|
+
const collapsing = leaving && w > 0;
|
|
1100
|
+
return (
|
|
1101
|
+
<Animated.View
|
|
1102
|
+
pointerEvents={leaving ? 'none' : 'auto'}
|
|
1103
|
+
style={[
|
|
1104
|
+
styles.chipSlot,
|
|
1105
|
+
!shrink && { flexShrink: 0 },
|
|
1106
|
+
collapsing
|
|
1107
|
+
? {
|
|
1108
|
+
overflow: 'hidden',
|
|
1109
|
+
width: size.interpolate({ inputRange: [0, 1], outputRange: [0, w] }),
|
|
1110
|
+
marginRight: size.interpolate({ inputRange: [0, 1], outputRange: [0, gap] }),
|
|
1111
|
+
}
|
|
1112
|
+
: { marginRight: gap },
|
|
1113
|
+
]}
|
|
1114
|
+
>
|
|
1115
|
+
<Animated.View
|
|
1116
|
+
onLayout={leaving ? undefined : (e) => setW(e.nativeEvent.layout.width)}
|
|
1117
|
+
style={[
|
|
1118
|
+
styles.chipPop,
|
|
1119
|
+
collapsing && { width: w, flexShrink: 0 },
|
|
1120
|
+
{
|
|
1121
|
+
opacity: pop.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
1122
|
+
transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.85, 1] }) }],
|
|
1123
|
+
},
|
|
1124
|
+
]}
|
|
1125
|
+
>
|
|
1126
|
+
{children}
|
|
1127
|
+
</Animated.View>
|
|
1128
|
+
</Animated.View>
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** A number that rolls (native translateY + opacity) up when it grows and down when it shrinks. */
|
|
1133
|
+
function RollingNumber({ value, style, distance = 10 }: { value: number; style?: StyleProp<TextStyle>; distance?: number }) {
|
|
1134
|
+
const [state, setState] = useState({ cur: value, prev: value, dir: 1 });
|
|
1135
|
+
if (state.cur !== value) setState({ cur: value, prev: state.cur, dir: value > state.cur ? 1 : -1 });
|
|
1136
|
+
// A fresh value per change starts at 0 in the same render, so there's no one-frame flash.
|
|
1137
|
+
const roll = useMemo(() => new Animated.Value(state.cur === state.prev ? 1 : 0), [state]);
|
|
1138
|
+
useEffect(() => {
|
|
1139
|
+
const a = Animated.spring(roll, { toValue: 1, useNativeDriver: true, ...motion.snappy });
|
|
1140
|
+
a.start();
|
|
1141
|
+
return () => a.stop();
|
|
1142
|
+
}, [roll]);
|
|
1143
|
+
const moving = state.cur !== state.prev;
|
|
1144
|
+
return (
|
|
1145
|
+
<View style={styles.roll}>
|
|
1146
|
+
{moving ? (
|
|
1147
|
+
<Animated.Text
|
|
1148
|
+
accessible={false}
|
|
1149
|
+
importantForAccessibility="no"
|
|
1150
|
+
style={[
|
|
1151
|
+
style,
|
|
1152
|
+
styles.tabular,
|
|
1153
|
+
styles.rollPrev,
|
|
1154
|
+
{
|
|
1155
|
+
opacity: roll.interpolate({ inputRange: [0, 1], outputRange: [1, 0], extrapolate: 'clamp' }),
|
|
1156
|
+
transform: [{ translateY: roll.interpolate({ inputRange: [0, 1], outputRange: [0, -state.dir * distance] }) }],
|
|
1157
|
+
},
|
|
1158
|
+
]}
|
|
1159
|
+
>
|
|
1160
|
+
{state.prev}
|
|
1161
|
+
</Animated.Text>
|
|
1162
|
+
) : null}
|
|
1163
|
+
<Animated.Text
|
|
1164
|
+
style={[
|
|
1165
|
+
style,
|
|
1166
|
+
styles.tabular,
|
|
1167
|
+
{
|
|
1168
|
+
opacity: roll.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
1169
|
+
transform: [{ translateY: roll.interpolate({ inputRange: [0, 1], outputRange: [state.dir * distance, 0] }) }],
|
|
1170
|
+
},
|
|
1171
|
+
]}
|
|
1172
|
+
>
|
|
1173
|
+
{state.cur}
|
|
1174
|
+
</Animated.Text>
|
|
1175
|
+
</View>
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/** "N selected" pill whose number rolls; it pops out when the count drops to 0. */
|
|
1180
|
+
function CountBadge({ count, tint }: { count: number; tint: string }) {
|
|
1181
|
+
const { radius, font } = useKit();
|
|
1182
|
+
const ff = font.family ? { fontFamily: font.family } : null;
|
|
1183
|
+
const on = count > 0;
|
|
1184
|
+
const vis = useRef(new Animated.Value(on ? 1 : 0)).current; // native
|
|
1185
|
+
const last = useRef(count);
|
|
1186
|
+
if (on) last.current = count;
|
|
1187
|
+
useEffect(() => {
|
|
1188
|
+
const a = Animated.spring(vis, { toValue: on ? 1 : 0, useNativeDriver: true, ...motion.snappy });
|
|
1189
|
+
a.start();
|
|
1190
|
+
return () => a.stop();
|
|
1191
|
+
}, [on, vis]);
|
|
1192
|
+
const text = [styles.badgeText, ff, { color: tint }];
|
|
1193
|
+
return (
|
|
1194
|
+
<Animated.View
|
|
1195
|
+
accessible
|
|
1196
|
+
accessibilityLabel={`${last.current} selected`}
|
|
1197
|
+
accessibilityLiveRegion="polite"
|
|
1198
|
+
style={[
|
|
1199
|
+
styles.badge,
|
|
1200
|
+
{
|
|
1201
|
+
backgroundColor: alpha(tint, 0.12),
|
|
1202
|
+
borderRadius: radius.capsule(24),
|
|
1203
|
+
opacity: vis.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
1204
|
+
transform: [{ scale: vis.interpolate({ inputRange: [0, 1], outputRange: [0.85, 1] }) }],
|
|
1205
|
+
},
|
|
1206
|
+
]}
|
|
1207
|
+
>
|
|
1208
|
+
<RollingNumber value={last.current} distance={9} style={text} />
|
|
1209
|
+
<Text style={[text, styles.badgeSuffix]}>selected</Text>
|
|
1210
|
+
</Animated.View>
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Renders `text` with the first match of `query` highlighted. */
|
|
1215
|
+
function Highlight({ text, query, tint, style }: { text: string; query: string; tint: string; style: StyleProp<TextStyle> }) {
|
|
1216
|
+
const i = query ? text.toLowerCase().indexOf(query) : -1;
|
|
1217
|
+
if (i < 0) {
|
|
1218
|
+
return (
|
|
1219
|
+
<Text numberOfLines={1} style={style}>
|
|
1220
|
+
{text}
|
|
1221
|
+
</Text>
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
return (
|
|
1225
|
+
<Text numberOfLines={1} style={style}>
|
|
1226
|
+
{text.slice(0, i)}
|
|
1227
|
+
<Text style={[styles.match, { color: tint, backgroundColor: alpha(tint, 0.14) }]}>{text.slice(i, i + query.length)}</Text>
|
|
1228
|
+
{text.slice(i + query.length)}
|
|
1229
|
+
</Text>
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Helper / error line that fades and slides in; errors get an icon and are announced. */
|
|
1234
|
+
function FieldMessage({ text, error, style }: { text: string; error: boolean; style?: StyleProp<TextStyle> }) {
|
|
1235
|
+
const { colors, font } = useKit();
|
|
1236
|
+
const enter = useRef(new Animated.Value(0)).current; // native
|
|
1237
|
+
useEffect(() => {
|
|
1238
|
+
enter.setValue(0);
|
|
1239
|
+
Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
|
|
1240
|
+
}, [text, error, enter]);
|
|
1241
|
+
const color = error ? colors.danger : colors.mutedForeground;
|
|
1242
|
+
return (
|
|
1243
|
+
<Animated.View
|
|
1244
|
+
accessibilityLiveRegion="polite"
|
|
1245
|
+
style={[
|
|
1246
|
+
styles.message,
|
|
1247
|
+
{ opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] },
|
|
1248
|
+
]}
|
|
1249
|
+
>
|
|
1250
|
+
{error ? <Icon name="alert" size={14} color={color} /> : null}
|
|
1251
|
+
<Text style={[styles.helper, font.family ? { fontFamily: font.family } : null, { color }, style, error && { color }]}>{text}</Text>
|
|
1252
|
+
</Animated.View>
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function Chevron({ open, color, size, right }: { open: boolean; color: string; size: number; right: number }) {
|
|
1257
|
+
const turn = useRef(new Animated.Value(open ? 1 : 0)).current;
|
|
1258
|
+
useEffect(() => {
|
|
1259
|
+
Animated.spring(turn, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
1260
|
+
}, [open, turn]);
|
|
1261
|
+
return (
|
|
1262
|
+
<Animated.View
|
|
1263
|
+
pointerEvents="none"
|
|
1264
|
+
style={[
|
|
1265
|
+
styles.chevronWrap,
|
|
1266
|
+
{ right, width: size + 4, height: size + 4, transform: [{ rotate: turn.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] }) }] },
|
|
1267
|
+
]}
|
|
1268
|
+
>
|
|
1269
|
+
<Icon name="chevron" size={size} color={color} />
|
|
1270
|
+
</Animated.View>
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function Icon({ name, size, color, strokeWidth = 1.8 }: { name: 'chevron' | 'check' | 'search' | 'x' | 'alert'; size: number; color: string; strokeWidth?: number }) {
|
|
1275
|
+
const p = { stroke: color, strokeWidth, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
|
|
1276
|
+
return (
|
|
1277
|
+
<Svg width={size} height={size} viewBox="0 0 24 24">
|
|
1278
|
+
{name === 'chevron' ? <Path d="m6 9 6 6 6-6" {...p} /> : null}
|
|
1279
|
+
{name === 'check' ? <Path d="m5 12.5 4.5 4.5L19 7.5" {...p} /> : null}
|
|
1280
|
+
{name === 'x' ? <Path d="M6 6l12 12M18 6 6 18" {...p} /> : null}
|
|
1281
|
+
{name === 'search' ? (
|
|
1282
|
+
<>
|
|
1283
|
+
<Circle cx={11} cy={11} r={6.5} {...p} />
|
|
1284
|
+
<Path d="m20 20-4.2-4.2" {...p} />
|
|
1285
|
+
</>
|
|
1286
|
+
) : null}
|
|
1287
|
+
{name === 'alert' ? (
|
|
1288
|
+
<>
|
|
1289
|
+
<Circle cx={12} cy={12} r={9} {...p} />
|
|
1290
|
+
<Path d="M12 7.5v5.5M12 16.5v.01" {...p} strokeWidth={2.2} />
|
|
1291
|
+
</>
|
|
1292
|
+
) : null}
|
|
1293
|
+
</Svg>
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const styles = StyleSheet.create({
|
|
1298
|
+
field: { borderWidth: 1.5, justifyContent: 'center' },
|
|
1299
|
+
halo: { position: 'absolute', top: -5, left: -5, right: -5, bottom: -5, borderWidth: 3.5 },
|
|
1300
|
+
icon: { position: 'absolute', top: 0, bottom: 0, justifyContent: 'center' },
|
|
1301
|
+
chips: { flexDirection: 'row', alignItems: 'center', overflow: 'hidden' },
|
|
1302
|
+
chipSlot: { flexDirection: 'row', flexShrink: 1, minWidth: 0 },
|
|
1303
|
+
chipPop: { flexDirection: 'row', flexShrink: 1, minWidth: 0 },
|
|
1304
|
+
chip: { flexDirection: 'row', alignItems: 'center', gap: 4, maxWidth: 150, flexShrink: 1 },
|
|
1305
|
+
more: { gap: 0, paddingHorizontal: 9, flexShrink: 0 },
|
|
1306
|
+
chipIcon: { overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
|
|
1307
|
+
chipText: { fontWeight: '400', flexShrink: 1 },
|
|
1308
|
+
tabular: { fontVariant: ['tabular-nums'], fontWeight: '400' },
|
|
1309
|
+
roll: { overflow: 'hidden' },
|
|
1310
|
+
rollPrev: { position: 'absolute', left: 0, top: 0 },
|
|
1311
|
+
badge: { flexDirection: 'row', alignItems: 'center', height: 24, paddingHorizontal: 9 },
|
|
1312
|
+
badgeText: { fontSize: 12.5, fontWeight: '400' },
|
|
1313
|
+
badgeSuffix: { marginLeft: 3 },
|
|
1314
|
+
chipX: { alignItems: 'center', justifyContent: 'center' },
|
|
1315
|
+
labelWrap: { position: 'absolute', top: 0, justifyContent: 'center', alignItems: 'flex-start', transformOrigin: 'left center' },
|
|
1316
|
+
label: { fontWeight: '400', paddingHorizontal: 5, marginLeft: -5 },
|
|
1317
|
+
topLabel: { fontWeight: '400', marginBottom: 7, marginHorizontal: 1, letterSpacing: 0.01 },
|
|
1318
|
+
message: { flexDirection: 'row', alignItems: 'flex-start', gap: 5, marginTop: 7, marginLeft: 2 },
|
|
1319
|
+
helper: { flex: 1, fontSize: 12.5, lineHeight: 17 },
|
|
1320
|
+
chevronWrap: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
|
|
1321
|
+
sheetHost: { flex: 1, justifyContent: 'flex-end' },
|
|
1322
|
+
centered: { justifyContent: 'center', alignItems: 'center' },
|
|
1323
|
+
sheet: {
|
|
1324
|
+
paddingHorizontal: 12,
|
|
1325
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
1326
|
+
shadowColor: '#000',
|
|
1327
|
+
shadowOpacity: 0.18,
|
|
1328
|
+
shadowRadius: 24,
|
|
1329
|
+
shadowOffset: { width: 0, height: -4 },
|
|
1330
|
+
elevation: 16,
|
|
1331
|
+
},
|
|
1332
|
+
sheetHeader: { alignItems: 'center', paddingTop: 10, paddingBottom: 12 },
|
|
1333
|
+
grabber: { width: 40, height: 5, borderRadius: 3, marginBottom: 12 },
|
|
1334
|
+
title: { fontSize: 17, fontWeight: '400', letterSpacing: -0.2, marginTop: 4 },
|
|
1335
|
+
subtitleRow: { height: 26, marginTop: 4, alignItems: 'center', justifyContent: 'center' },
|
|
1336
|
+
subtitle: { fontSize: 13 },
|
|
1337
|
+
search: { height: 44, marginBottom: 8, marginHorizontal: 4, flexDirection: 'row', alignItems: 'center', paddingLeft: 12, paddingRight: 10, gap: 8 },
|
|
1338
|
+
searchDense: {
|
|
1339
|
+
height: SEARCH_H,
|
|
1340
|
+
flexDirection: 'row',
|
|
1341
|
+
alignItems: 'center',
|
|
1342
|
+
paddingLeft: 13,
|
|
1343
|
+
paddingRight: 11,
|
|
1344
|
+
gap: 8,
|
|
1345
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
1346
|
+
},
|
|
1347
|
+
searchInput: { flex: 1, height: 44, paddingVertical: 0 },
|
|
1348
|
+
clear: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
|
|
1349
|
+
scroll: { flexGrow: 0, flexShrink: 1 },
|
|
1350
|
+
listContent: { paddingBottom: 6 },
|
|
1351
|
+
listDense: { padding: PANEL_PAD },
|
|
1352
|
+
sep: { height: StyleSheet.hairlineWidth, marginVertical: 4, marginHorizontal: -PANEL_PAD },
|
|
1353
|
+
groupTitle: { fontSize: 12, fontWeight: '400', letterSpacing: 0.2, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6 },
|
|
1354
|
+
groupDense: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 4 },
|
|
1355
|
+
empty: { alignItems: 'center', paddingVertical: 24, paddingHorizontal: 16 },
|
|
1356
|
+
emptyDense: { alignItems: 'center', paddingVertical: 18, paddingHorizontal: 12 },
|
|
1357
|
+
emptyIcon: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: 10 },
|
|
1358
|
+
emptyIconDense: { width: 34, height: 34, borderRadius: 17, marginBottom: 8 },
|
|
1359
|
+
emptyTitle: { fontWeight: '400', textAlign: 'center' },
|
|
1360
|
+
emptyText: { fontSize: 13, marginTop: 3, textAlign: 'center' },
|
|
1361
|
+
footer: { flexDirection: 'row', gap: 8, paddingTop: 10, paddingHorizontal: 4, borderTopWidth: StyleSheet.hairlineWidth },
|
|
1362
|
+
footBtn: { height: 44, paddingHorizontal: 18, alignItems: 'center', justifyContent: 'center' },
|
|
1363
|
+
footMain: { flex: 1 },
|
|
1364
|
+
footText: { fontSize: 15, fontWeight: '400' },
|
|
1365
|
+
footStrong: { fontWeight: '400' },
|
|
1366
|
+
footDense: {
|
|
1367
|
+
height: FOOT_H,
|
|
1368
|
+
flexDirection: 'row',
|
|
1369
|
+
alignItems: 'center',
|
|
1370
|
+
paddingHorizontal: 8,
|
|
1371
|
+
gap: 8,
|
|
1372
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
1373
|
+
},
|
|
1374
|
+
footGhost: { height: 32, paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center' },
|
|
1375
|
+
footSolid: { height: 32, paddingHorizontal: 16, alignItems: 'center', justifyContent: 'center' },
|
|
1376
|
+
footMid: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
1377
|
+
footDenseText: { fontSize: 14, fontWeight: '400' },
|
|
1378
|
+
dropdown: {
|
|
1379
|
+
position: 'absolute',
|
|
1380
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
1381
|
+
shadowColor: '#000',
|
|
1382
|
+
shadowRadius: 28,
|
|
1383
|
+
shadowOffset: { width: 0, height: 10 },
|
|
1384
|
+
elevation: 10,
|
|
1385
|
+
overflow: 'hidden',
|
|
1386
|
+
},
|
|
1387
|
+
row: { minHeight: 52, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, gap: 12 },
|
|
1388
|
+
rowDense: { minHeight: 42, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, gap: 10 },
|
|
1389
|
+
rowIcon: { minWidth: 24, alignItems: 'center' },
|
|
1390
|
+
rowText: { flex: 1, paddingVertical: 8 },
|
|
1391
|
+
rowLabel: { fontWeight: '400' },
|
|
1392
|
+
rowLabelOn: { fontWeight: '400' },
|
|
1393
|
+
rowDesc: { marginTop: 1, fontWeight: '400' },
|
|
1394
|
+
match: { fontWeight: '400' },
|
|
1395
|
+
checkWrap: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center' },
|
|
1396
|
+
box: { borderWidth: 1.6, alignItems: 'center', justifyContent: 'center' },
|
|
1397
|
+
boxFill: { position: 'absolute', top: -1.6, left: -1.6, right: -1.6, bottom: -1.6, alignItems: 'center', justifyContent: 'center' },
|
|
1398
|
+
});
|