@butternutbox/pawprint-native 1.6.2 → 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.
Files changed (44) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/CHANGELOG.md +60 -0
  3. package/COMPONENT_GUIDELINES.md +57 -1
  4. package/dist/index.cjs +1459 -1380
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +81 -117
  7. package/dist/index.d.ts +81 -117
  8. package/dist/index.js +1206 -1125
  9. package/dist/index.js.map +1 -1
  10. package/package.json +3 -3
  11. package/src/__mocks__/react-native.tsx +33 -5
  12. package/src/__mocks__/rn-primitives/portal.tsx +71 -0
  13. package/src/components/atoms/Input/InputField.tsx +12 -2
  14. package/src/components/molecules/Drawer/Drawer.test.tsx +54 -2
  15. package/src/components/molecules/Drawer/DrawerBody.tsx +2 -17
  16. package/src/components/molecules/Drawer/DrawerContent.tsx +49 -2
  17. package/src/components/molecules/Drawer/DrawerOverlay.tsx +28 -16
  18. package/src/components/molecules/SelectField/AsyncSearch.test.tsx +373 -0
  19. package/src/components/molecules/SelectField/SelectDrawerField.stories.tsx +192 -0
  20. package/src/components/molecules/SelectField/SelectDrawerField.test.tsx +210 -0
  21. package/src/components/molecules/SelectField/SelectDrawerField.tsx +245 -0
  22. package/src/components/molecules/SelectField/SelectField.stories.tsx +221 -421
  23. package/src/components/molecules/SelectField/SelectField.test.tsx +214 -662
  24. package/src/components/molecules/SelectField/SelectField.tsx +269 -539
  25. package/src/components/molecules/SelectField/SelectFieldDropdown.tsx +90 -0
  26. package/src/components/molecules/SelectField/SelectFieldList.tsx +88 -0
  27. package/src/components/molecules/SelectField/SelectFieldOption.tsx +90 -0
  28. package/src/components/molecules/SelectField/core/index.ts +8 -0
  29. package/src/components/molecules/SelectField/core/listHeight.test.ts +22 -0
  30. package/src/components/molecules/SelectField/core/listHeight.ts +8 -0
  31. package/src/components/molecules/SelectField/core/options.ts +35 -0
  32. package/src/components/molecules/SelectField/core/useSelectOptions.ts +116 -0
  33. package/src/components/molecules/SelectField/index.ts +12 -9
  34. package/src/index.ts +0 -1
  35. package/src/test-utils.tsx +5 -1
  36. package/vitest.config.ts +7 -0
  37. package/src/components/molecules/SelectField/SelectFieldContent.tsx +0 -81
  38. package/src/components/molecules/SelectField/SelectFieldItem.tsx +0 -133
  39. package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +0 -188
  40. package/src/components/molecules/SelectField/SelectFieldValue.tsx +0 -33
  41. package/src/components/molecules/SelectField/hooks/index.ts +0 -2
  42. package/src/components/molecules/SelectField/hooks/useSelectField.ts +0 -84
  43. package/src/hooks/index.ts +0 -1
  44. package/src/hooks/useDrawerKeyboardAvoidance.ts +0 -29
@@ -0,0 +1,210 @@
1
+ import React from "react"
2
+ import { screen, fireEvent } from "@testing-library/react"
3
+ import { describe, it, expect, vi } from "vitest"
4
+ import { renderWithTheme } from "../../../test-utils"
5
+ import { SelectDrawerField } from "./SelectDrawerField"
6
+ import type { SelectFieldOptionData } from "./core"
7
+
8
+ const COUNTRIES: SelectFieldOptionData[] = [
9
+ { value: "uk", label: "United Kingdom" },
10
+ { value: "us", label: "United States" },
11
+ { value: "ca", label: "Canada" }
12
+ ]
13
+
14
+ type Props = Partial<React.ComponentProps<typeof SelectDrawerField>>
15
+
16
+ const renderField = (props: Props = {}) => {
17
+ renderWithTheme(
18
+ <SelectDrawerField
19
+ label="Country"
20
+ placeholder="Select a country"
21
+ options={COUNTRIES}
22
+ {...props}
23
+ />
24
+ )
25
+
26
+ return screen.getByRole("combobox") as HTMLInputElement
27
+ }
28
+
29
+ const openDrawer = () => {
30
+ const trigger = screen
31
+ .getAllByRole("button")
32
+ .find((candidate) => candidate.querySelector("input"))
33
+
34
+ if (!trigger) throw new Error("field trigger not found")
35
+ fireEvent.click(trigger)
36
+ }
37
+
38
+ describe("SelectDrawerField", () => {
39
+ it("renders the label and description", () => {
40
+ renderField({ description: "Pick one" })
41
+
42
+ expect(screen.getByText("Country")).toBeInTheDocument()
43
+ expect(screen.getByText("Pick one")).toBeInTheDocument()
44
+ })
45
+
46
+ it("keeps the drawer closed until the field is pressed", () => {
47
+ renderField()
48
+
49
+ expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
50
+ })
51
+
52
+ it("opens the drawer when the field is pressed", () => {
53
+ renderField()
54
+ openDrawer()
55
+
56
+ COUNTRIES.forEach((option) => {
57
+ expect(screen.getByText(option.label)).toBeInTheDocument()
58
+ })
59
+ })
60
+
61
+ it("never makes the field editable", () => {
62
+ const input = renderField()
63
+
64
+ expect(input).toBeDisabled()
65
+ })
66
+
67
+ it("filters the list from the search input", () => {
68
+ renderField({ searchPlaceholder: "Search countries" })
69
+ openDrawer()
70
+
71
+ const search = screen.getByPlaceholderText("Search countries")
72
+ fireEvent.change(search, { target: { value: "united" } })
73
+
74
+ expect(screen.getByText("United Kingdom")).toBeInTheDocument()
75
+ expect(screen.queryByText("Canada")).not.toBeInTheDocument()
76
+ })
77
+
78
+ it("shows the no-results row when nothing matches", () => {
79
+ renderField({
80
+ searchPlaceholder: "Search countries",
81
+ noResultsText: "No countries found"
82
+ })
83
+ openDrawer()
84
+
85
+ fireEvent.change(screen.getByPlaceholderText("Search countries"), {
86
+ target: { value: "zzzz" }
87
+ })
88
+
89
+ expect(screen.getByText("No countries found")).toBeInTheDocument()
90
+ })
91
+
92
+ it("omits the search input when searchable is false", () => {
93
+ renderField({ searchable: false, searchPlaceholder: "Search countries" })
94
+ openDrawer()
95
+
96
+ expect(
97
+ screen.queryByPlaceholderText("Search countries")
98
+ ).not.toBeInTheDocument()
99
+ })
100
+
101
+ it("commits the selected value and closes the drawer", () => {
102
+ const onValueChange = vi.fn()
103
+ const input = renderField({ onValueChange })
104
+ openDrawer()
105
+ fireEvent.click(screen.getByText("Canada"))
106
+
107
+ expect(onValueChange).toHaveBeenCalledWith("ca")
108
+ expect(input).toHaveValue("Canada")
109
+ expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
110
+ })
111
+
112
+ it("does not commit a disabled option", () => {
113
+ const onValueChange = vi.fn()
114
+ renderField({
115
+ onValueChange,
116
+ options: [{ value: "uk", label: "United Kingdom", disabled: true }]
117
+ })
118
+ openDrawer()
119
+ fireEvent.click(screen.getByText("United Kingdom"))
120
+
121
+ expect(onValueChange).not.toHaveBeenCalled()
122
+ })
123
+
124
+ it("shows the committed label and clears it", () => {
125
+ const onValueChange = vi.fn()
126
+ const input = renderField({ onValueChange, defaultValue: "ca" })
127
+
128
+ expect(input).toHaveValue("Canada")
129
+
130
+ fireEvent.click(screen.getByLabelText("Clear selection"))
131
+
132
+ expect(input).toHaveValue("")
133
+ expect(onValueChange).toHaveBeenCalledWith(null)
134
+ })
135
+
136
+ it("closes when the overlay is tapped", () => {
137
+ renderField()
138
+ openDrawer()
139
+
140
+ expect(screen.getByText("United Kingdom")).toBeInTheDocument()
141
+
142
+ fireEvent.click(screen.getByLabelText("Close drawer"))
143
+
144
+ expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
145
+ })
146
+
147
+ it("shows the label of an option that is not in options", () => {
148
+ const onValueChange = vi.fn()
149
+ renderWithTheme(
150
+ <SelectDrawerField
151
+ label="Breed"
152
+ placeholder="Select a breed"
153
+ options={[]}
154
+ searchable={false}
155
+ onValueChange={onValueChange}
156
+ />
157
+ )
158
+
159
+ const input = screen.getByRole("combobox") as HTMLInputElement
160
+
161
+ expect(input).toHaveValue("")
162
+ })
163
+
164
+ it("keeps a selected label that options cannot resolve", () => {
165
+ const remote: SelectFieldOptionData[] = [
166
+ { value: "lab", label: "Labrador Retriever" }
167
+ ]
168
+ const onValueChange = vi.fn()
169
+
170
+ renderWithTheme(
171
+ <SelectDrawerField
172
+ label="Breed"
173
+ placeholder="Select a breed"
174
+ options={remote}
175
+ onValueChange={onValueChange}
176
+ />
177
+ )
178
+
179
+ const input = screen.getByRole("combobox") as HTMLInputElement
180
+ const trigger = screen
181
+ .getAllByRole("button")
182
+ .find((candidate) => candidate.querySelector("input"))
183
+ if (!trigger) throw new Error("trigger not found")
184
+
185
+ fireEvent.click(trigger)
186
+ fireEvent.click(screen.getByText("Labrador Retriever"))
187
+
188
+ expect(onValueChange).toHaveBeenCalledWith("lab")
189
+ expect(input).toHaveValue("Labrador Retriever")
190
+ })
191
+
192
+ it("does not open when disabled", () => {
193
+ renderField({ disabled: true })
194
+ const trigger = screen
195
+ .getAllByRole("button")
196
+ .find((candidate) => candidate.querySelector("input"))
197
+
198
+ if (trigger) fireEvent.click(trigger)
199
+
200
+ expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
201
+ })
202
+
203
+ it("does not change its own value when controlled", () => {
204
+ const input = renderField({ value: "uk", onValueChange: vi.fn() })
205
+ openDrawer()
206
+ fireEvent.click(screen.getByText("Canada"))
207
+
208
+ expect(input).toHaveValue("United Kingdom")
209
+ })
210
+ })
@@ -0,0 +1,245 @@
1
+ import React from "react"
2
+ import { Pressable, useWindowDimensions, View, ViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { Cancel, Search } from "@butternutbox/pawprint-icons/core"
5
+ import { Icon } from "../../atoms/Icon"
6
+ import { InputField } from "../../atoms/Input/InputField"
7
+ import type { InputState } from "../../atoms/Input/InputField"
8
+ import { InputLabel } from "../../atoms/Input/InputLabel"
9
+ import { InputDescription } from "../../atoms/Input/InputDescription"
10
+ import { InputError } from "../../atoms/Input/InputError"
11
+ import { parseTokenValue } from "../../../utils"
12
+ import { Drawer } from "../Drawer"
13
+ import { SelectFieldList } from "./SelectFieldList"
14
+ import { drawerListMaxHeight, labelFor, useSelectOptions } from "./core"
15
+ import type { SelectFieldOptionData, SelectOptionsResolver } from "./core"
16
+
17
+ type SelectDrawerFieldOwnProps = {
18
+ options: SelectFieldOptionData[]
19
+ label?: string
20
+ description?: string
21
+ error?: string
22
+ state?: InputState
23
+ optionalText?: string
24
+ placeholder?: string
25
+ leadingIcon?: React.ReactNode
26
+ drawerTitle?: string
27
+ searchable?: boolean
28
+ searchPlaceholder?: string
29
+ noResultsText?: string
30
+ errorText?: string
31
+ onSearchQuery?: SelectOptionsResolver
32
+ debounceMs?: number
33
+ minQueryLength?: number
34
+ searchHintText?: string
35
+ clearAccessibilityLabel?: string
36
+ listMaxHeight?: number
37
+ disabled?: boolean
38
+ value?: string | null
39
+ defaultValue?: string | null
40
+ onValueChange?: (value: string | null) => void
41
+ }
42
+
43
+ export type SelectDrawerFieldProps = SelectDrawerFieldOwnProps &
44
+ Omit<ViewProps, keyof SelectDrawerFieldOwnProps>
45
+
46
+ const StyledRoot = styled(View)(({ theme }) => ({
47
+ gap: parseTokenValue(theme.tokens.components.inputs.spacing.gap)
48
+ }))
49
+
50
+ const StyledSearchRow = styled(View)(({ theme }) => {
51
+ const { colour } = theme.tokens.semantics
52
+
53
+ return {
54
+ backgroundColor: colour.background.container.default,
55
+ paddingBottom: parseTokenValue(theme.tokens.components.inputs.spacing.gap)
56
+ }
57
+ })
58
+
59
+ export const SelectDrawerField = React.forwardRef<View, SelectDrawerFieldProps>(
60
+ (
61
+ {
62
+ options,
63
+ label,
64
+ description,
65
+ error,
66
+ state = "default",
67
+ optionalText,
68
+ placeholder,
69
+ leadingIcon,
70
+ drawerTitle,
71
+ searchable = true,
72
+ searchPlaceholder = "Search",
73
+ noResultsText = "No results found",
74
+ errorText = "Something went wrong",
75
+ onSearchQuery,
76
+ debounceMs = 300,
77
+ minQueryLength = 1,
78
+ searchHintText = "Type to search",
79
+ clearAccessibilityLabel = "Clear selection",
80
+ listMaxHeight,
81
+ disabled = false,
82
+ value,
83
+ defaultValue,
84
+ onValueChange,
85
+ ...rest
86
+ },
87
+ ref
88
+ ) => {
89
+ const { height: windowHeight } = useWindowDimensions()
90
+ const isControlled = value !== undefined
91
+ const [internalValue, setInternalValue] = React.useState<string | null>(
92
+ defaultValue ?? null
93
+ )
94
+ const selectedValue = isControlled ? value : internalValue
95
+
96
+ const [isOpen, setIsOpen] = React.useState(false)
97
+ const [searchQuery, setSearchQuery] = React.useState("")
98
+
99
+ const [lastSelected, setLastSelected] =
100
+ React.useState<SelectFieldOptionData | null>(null)
101
+
102
+ const selectedLabel =
103
+ labelFor(options, selectedValue) ||
104
+ (lastSelected && lastSelected.value === selectedValue
105
+ ? lastSelected.label
106
+ : "")
107
+
108
+ const optionsState = useSelectOptions({
109
+ options,
110
+ query: searchQuery,
111
+ onSearchQuery,
112
+ debounceMs,
113
+ minQueryLength,
114
+ enabled: searchable
115
+ })
116
+
117
+ const commitValue = (nextValue: string | null) => {
118
+ if (!isControlled) setInternalValue(nextValue)
119
+ if (nextValue !== selectedValue) onValueChange?.(nextValue)
120
+ }
121
+
122
+ const handleOpenChange = (open: boolean) => {
123
+ setIsOpen(open)
124
+ if (!open) setSearchQuery("")
125
+ }
126
+
127
+ const handleSelect = (option: SelectFieldOptionData) => {
128
+ setLastSelected(option)
129
+ commitValue(option.value)
130
+ handleOpenChange(false)
131
+ }
132
+
133
+ const handleClear = () => {
134
+ setLastSelected(null)
135
+ commitValue(null)
136
+ }
137
+
138
+ const bodyMaxHeight = drawerListMaxHeight(windowHeight, listMaxHeight)
139
+
140
+ const fieldState: InputState = error ? "error" : state
141
+
142
+ const clearAction =
143
+ !disabled && selectedLabel.length > 0 ? (
144
+ <Pressable
145
+ onPress={handleClear}
146
+ accessibilityRole="button"
147
+ accessibilityLabel={clearAccessibilityLabel}
148
+ hitSlop={8}
149
+ >
150
+ <Icon icon={Cancel} size="md" />
151
+ </Pressable>
152
+ ) : undefined
153
+
154
+ const searchClearAction =
155
+ searchQuery.length > 0 ? (
156
+ <Pressable
157
+ onPress={() => setSearchQuery("")}
158
+ accessibilityRole="button"
159
+ accessibilityLabel="Clear search"
160
+ hitSlop={8}
161
+ >
162
+ <Icon icon={Cancel} size="md" />
163
+ </Pressable>
164
+ ) : undefined
165
+
166
+ return (
167
+ <StyledRoot ref={ref} {...rest}>
168
+ {label && (
169
+ <InputLabel optionalText={optionalText} state={fieldState}>
170
+ {label}
171
+ </InputLabel>
172
+ )}
173
+ <InputField
174
+ value={selectedLabel}
175
+ placeholder={placeholder}
176
+ editable={false}
177
+ onPress={disabled ? undefined : () => setIsOpen(true)}
178
+ state={fieldState}
179
+ hideStateIcons={Boolean(clearAction)}
180
+ leadingIcon={leadingIcon}
181
+ actionIcon={clearAction}
182
+ accessibilityRole="combobox"
183
+ accessibilityState={{ disabled, expanded: isOpen }}
184
+ />
185
+ {description && (
186
+ <InputDescription state={fieldState}>{description}</InputDescription>
187
+ )}
188
+ {error && <InputError>{error}</InputError>}
189
+
190
+ <Drawer.Root open={isOpen} onOpenChange={handleOpenChange}>
191
+ <Drawer.Portal>
192
+ <Drawer.Overlay />
193
+ <Drawer.Content>
194
+ <Drawer.Header>
195
+ <Drawer.Title>{drawerTitle ?? label ?? "Select"}</Drawer.Title>
196
+ <Drawer.Close />
197
+ </Drawer.Header>
198
+ {/* The host screen's outermost ScrollView must also set
199
+ `keyboardShouldPersistTaps`. Responder capture follows the
200
+ React tree root-downwards, so an outer ScrollView left at the
201
+ default "never" swallows taps on these rows while the search
202
+ input holds focus — and a Modal does not isolate its contents
203
+ from it, because the tree is unchanged. */}
204
+ <Drawer.Body
205
+ style={{ maxHeight: bodyMaxHeight }}
206
+ keyboardShouldPersistTaps="always"
207
+ stickyHeaderIndices={searchable ? [0] : undefined}
208
+ >
209
+ {searchable ? (
210
+ <StyledSearchRow>
211
+ <InputField
212
+ value={searchQuery}
213
+ onChangeText={setSearchQuery}
214
+ placeholder={searchPlaceholder}
215
+ leadingIcon={<Icon icon={Search} size="md" />}
216
+ actionIcon={searchClearAction}
217
+ autoFocus
218
+ selectTextOnFocus
219
+ autoCorrect={false}
220
+ autoCapitalize="none"
221
+ returnKeyType="search"
222
+ accessibilityLabel={searchPlaceholder}
223
+ />
224
+ </StyledSearchRow>
225
+ ) : null}
226
+ <View>
227
+ <SelectFieldList
228
+ state={optionsState}
229
+ selectedValue={selectedValue}
230
+ onSelect={handleSelect}
231
+ noResultsText={noResultsText}
232
+ errorText={errorText}
233
+ searchHintText={searchHintText}
234
+ />
235
+ </View>
236
+ </Drawer.Body>
237
+ </Drawer.Content>
238
+ </Drawer.Portal>
239
+ </Drawer.Root>
240
+ </StyledRoot>
241
+ )
242
+ }
243
+ )
244
+
245
+ SelectDrawerField.displayName = "SelectDrawerField"