@butternutbox/pawprint-native 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +18 -0
- package/dist/index.cjs +96 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +96 -27
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/atoms/Button/Button.stories.tsx +22 -2
- package/src/components/atoms/Button/Button.tsx +9 -7
- package/src/components/atoms/IconButton/IconButton.tsx +1 -1
- package/src/components/atoms/Tag/Tag.stories.tsx +11 -1
- package/src/components/atoms/Tag/Tag.tsx +12 -2
- package/src/components/molecules/Checkbox/Checkbox.stories.tsx +2 -1
- package/src/components/molecules/Checkbox/Checkbox.tsx +10 -6
- package/src/components/molecules/Drawer/DrawerBody.tsx +1 -1
- package/src/components/molecules/SelectField/SelectField.stories.tsx +53 -0
- package/src/components/molecules/SelectField/SelectField.test.tsx +257 -1
- package/src/components/molecules/SelectField/SelectField.tsx +193 -46
- package/src/components/molecules/SelectField/index.ts +1 -1
- package/src/components/molecules/Slider/Slider.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@butternutbox/pawprint-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ButternutBox Pawprint Design System - React Native Components",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"test": "vitest run"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@butternutbox/pawprint-tokens": "^0.
|
|
25
|
+
"@butternutbox/pawprint-tokens": "^0.5.0",
|
|
26
26
|
"@emotion/native": "^11.11.0",
|
|
27
27
|
"@emotion/react": "^11.14.0",
|
|
28
28
|
"@rn-primitives/avatar": "^1.4.0",
|
|
@@ -11,6 +11,22 @@ import {
|
|
|
11
11
|
KeyboardDoubleArrowDown
|
|
12
12
|
} from "@butternutbox/pawprint-icons/core"
|
|
13
13
|
|
|
14
|
+
const iconElements: Record<string, React.ReactNode> = {
|
|
15
|
+
None: undefined,
|
|
16
|
+
KeyboardDoubleArrowDown: (
|
|
17
|
+
<Icon icon={KeyboardDoubleArrowDown} colour="primary" />
|
|
18
|
+
),
|
|
19
|
+
IosShare: <Icon icon={IosShare} colour="primary" />,
|
|
20
|
+
Whatsapp: <Icon icon={Whatsapp} colour="primary" />,
|
|
21
|
+
Check: <Icon icon={Check} colour="primary" />
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const iconArgType = {
|
|
25
|
+
control: "select" as const,
|
|
26
|
+
options: Object.keys(iconElements),
|
|
27
|
+
mapping: iconElements
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
export default {
|
|
15
31
|
title: "Atoms/Button",
|
|
16
32
|
component: Button,
|
|
@@ -45,7 +61,9 @@ export default {
|
|
|
45
61
|
children: {
|
|
46
62
|
control: { type: "text" },
|
|
47
63
|
description: "Button label text"
|
|
48
|
-
}
|
|
64
|
+
},
|
|
65
|
+
startIcon: iconArgType,
|
|
66
|
+
endIcon: iconArgType
|
|
49
67
|
}
|
|
50
68
|
}
|
|
51
69
|
|
|
@@ -57,7 +75,9 @@ Playground.args = {
|
|
|
57
75
|
colour: "primary",
|
|
58
76
|
loading: false,
|
|
59
77
|
fullWidth: false,
|
|
60
|
-
disabled: false
|
|
78
|
+
disabled: false,
|
|
79
|
+
startIcon: undefined,
|
|
80
|
+
endIcon: undefined
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
export const AllVariants = () => (
|
|
@@ -39,7 +39,7 @@ const IconWrapper = styled(View)({
|
|
|
39
39
|
})
|
|
40
40
|
|
|
41
41
|
const StyledButton = styled(Pressable)<{
|
|
42
|
-
|
|
42
|
+
buttonMinHeight: number
|
|
43
43
|
buttonMinWidth: number
|
|
44
44
|
buttonPaddingHorizontal: number
|
|
45
45
|
buttonPaddingVertical: number
|
|
@@ -52,7 +52,7 @@ const StyledButton = styled(Pressable)<{
|
|
|
52
52
|
buttonFullWidth: boolean
|
|
53
53
|
}>(
|
|
54
54
|
({
|
|
55
|
-
|
|
55
|
+
buttonMinHeight,
|
|
56
56
|
buttonMinWidth,
|
|
57
57
|
buttonPaddingHorizontal,
|
|
58
58
|
buttonPaddingVertical,
|
|
@@ -68,7 +68,7 @@ const StyledButton = styled(Pressable)<{
|
|
|
68
68
|
alignItems: "center",
|
|
69
69
|
justifyContent: "center",
|
|
70
70
|
position: "relative",
|
|
71
|
-
|
|
71
|
+
minHeight: buttonMinHeight,
|
|
72
72
|
minWidth: buttonMinWidth,
|
|
73
73
|
paddingHorizontal: buttonPaddingHorizontal,
|
|
74
74
|
paddingVertical: buttonPaddingVertical,
|
|
@@ -85,9 +85,10 @@ const StyledButton = styled(Pressable)<{
|
|
|
85
85
|
|
|
86
86
|
const StyledTextWrapper = styled(View)<{
|
|
87
87
|
textOpacity: number
|
|
88
|
-
|
|
88
|
+
fullWidth?: boolean
|
|
89
|
+
}>(({ textOpacity, fullWidth }) => ({
|
|
89
90
|
opacity: textOpacity,
|
|
90
|
-
|
|
91
|
+
...(fullWidth ? { flex: 1 } : {})
|
|
91
92
|
}))
|
|
92
93
|
|
|
93
94
|
const StyledSpinnerWrapper = styled(View)({
|
|
@@ -207,7 +208,7 @@ const Button = React.forwardRef<View, ButtonProps>(
|
|
|
207
208
|
accessibilityState={{ disabled: isDisabled, busy: loading }}
|
|
208
209
|
onPressIn={() => setPressed(true)}
|
|
209
210
|
onPressOut={() => setPressed(false)}
|
|
210
|
-
|
|
211
|
+
buttonMinHeight={parseTokenValue(sizeTokens.minHeight)}
|
|
211
212
|
buttonMinWidth={parseTokenValue(sizeTokens.minWidth)}
|
|
212
213
|
buttonPaddingHorizontal={parseTokenValue(
|
|
213
214
|
spacingTokens.horizontalPadding
|
|
@@ -228,7 +229,7 @@ const Button = React.forwardRef<View, ButtonProps>(
|
|
|
228
229
|
{...rest}
|
|
229
230
|
>
|
|
230
231
|
{startIcon && <IconWrapper>{startIcon}</IconWrapper>}
|
|
231
|
-
<StyledTextWrapper textOpacity={loading ? 0 : 1}>
|
|
232
|
+
<StyledTextWrapper textOpacity={loading ? 0 : 1} fullWidth={fullWidth}>
|
|
232
233
|
<Typography
|
|
233
234
|
token={{
|
|
234
235
|
fontFamily: typography.fontFamily,
|
|
@@ -238,6 +239,7 @@ const Button = React.forwardRef<View, ButtonProps>(
|
|
|
238
239
|
letterSpacing: typography.letterSpacing
|
|
239
240
|
}}
|
|
240
241
|
color={variantStyles.textColor as never}
|
|
242
|
+
align="center"
|
|
241
243
|
>
|
|
242
244
|
{children}
|
|
243
245
|
</Typography>
|
|
@@ -112,7 +112,7 @@ const IconButton = React.forwardRef<View, IconButtonProps>(
|
|
|
112
112
|
const isDisabled = disabled || loading
|
|
113
113
|
const buttons = theme.tokens.components.buttons
|
|
114
114
|
const sizeTokens = buttons.size[size]
|
|
115
|
-
const dimension = parseTokenValue(sizeTokens.
|
|
115
|
+
const dimension = parseTokenValue(sizeTokens.minHeight)
|
|
116
116
|
const iconSizing = theme.tokens.components.icons.sizing.icons.core
|
|
117
117
|
const iconDimension = parseTokenValue(iconSizing[sizeToIconSizeToken[size]])
|
|
118
118
|
|
|
@@ -2,6 +2,7 @@ import React from "react"
|
|
|
2
2
|
import { View, StyleSheet, Text } from "react-native"
|
|
3
3
|
import { Tag } from "./Tag"
|
|
4
4
|
import type { TagProps } from "./Tag"
|
|
5
|
+
import { CheckCircle } from "@butternutbox/pawprint-icons/core"
|
|
5
6
|
|
|
6
7
|
export default {
|
|
7
8
|
title: "Atoms/Tag",
|
|
@@ -28,6 +29,14 @@ export default {
|
|
|
28
29
|
children: {
|
|
29
30
|
control: { type: "text" },
|
|
30
31
|
description: "Tag label text"
|
|
32
|
+
},
|
|
33
|
+
icon: {
|
|
34
|
+
control: "boolean",
|
|
35
|
+
mapping: {
|
|
36
|
+
true: CheckCircle,
|
|
37
|
+
false: undefined
|
|
38
|
+
},
|
|
39
|
+
description: "Show/hide leading icon"
|
|
31
40
|
}
|
|
32
41
|
}
|
|
33
42
|
}
|
|
@@ -36,7 +45,8 @@ export const Playground = (args: TagProps) => <Tag {...args} />
|
|
|
36
45
|
Playground.args = {
|
|
37
46
|
children: "Tag",
|
|
38
47
|
variant: "primary",
|
|
39
|
-
size: "medium"
|
|
48
|
+
size: "medium",
|
|
49
|
+
icon: undefined
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
const tagVariants = [
|
|
@@ -32,6 +32,7 @@ const StyledTag = styled(View)<{
|
|
|
32
32
|
tagSize: TagSize
|
|
33
33
|
}>(({ theme, tagVariant, tagSize }) => {
|
|
34
34
|
const { sizing, spacing, colour, badge } = theme.tokens.components.tags
|
|
35
|
+
const { spacing: semanticSpacing } = theme.tokens.semantics.dimensions
|
|
35
36
|
|
|
36
37
|
const backgroundColorMap = {
|
|
37
38
|
primary: colour.primary.background,
|
|
@@ -43,13 +44,22 @@ const StyledTag = styled(View)<{
|
|
|
43
44
|
error: colour.primary.error
|
|
44
45
|
} as const
|
|
45
46
|
|
|
47
|
+
const verticalPaddingMap = {
|
|
48
|
+
small: semanticSpacing["2xs"],
|
|
49
|
+
medium: semanticSpacing["3xs"],
|
|
50
|
+
large: semanticSpacing["2xs"]
|
|
51
|
+
} as const
|
|
52
|
+
|
|
46
53
|
return {
|
|
47
54
|
flexDirection: "row",
|
|
48
55
|
alignItems: "center",
|
|
49
56
|
justifyContent: "center",
|
|
50
|
-
|
|
57
|
+
minHeight: parseTokenValue(sizing[tagSize].minHeight),
|
|
51
58
|
minWidth: parseTokenValue(sizing[tagSize].minWidth),
|
|
52
|
-
|
|
59
|
+
paddingLeft: parseTokenValue(spacing.horizontalPadding) * 2,
|
|
60
|
+
paddingRight: parseTokenValue(spacing.horizontalPadding) * 2,
|
|
61
|
+
paddingTop: parseTokenValue(verticalPaddingMap[tagSize]),
|
|
62
|
+
paddingBottom: parseTokenValue(verticalPaddingMap[tagSize]),
|
|
53
63
|
gap: parseTokenValue(spacing[tagSize].gap),
|
|
54
64
|
borderRadius: parseTokenValue(badge.borderRadius.default),
|
|
55
65
|
backgroundColor: backgroundColorMap[tagVariant]
|
|
@@ -98,9 +98,10 @@ export const Tile = () => (
|
|
|
98
98
|
Horizontal
|
|
99
99
|
</Typography>
|
|
100
100
|
<CheckboxGroup orientation="horizontal">
|
|
101
|
+
<Checkbox variant="tile" label="Chicken" />
|
|
101
102
|
<Checkbox
|
|
102
103
|
variant="tile"
|
|
103
|
-
label="
|
|
104
|
+
label="A really long piece of text that needs multiple lines"
|
|
104
105
|
/>
|
|
105
106
|
<Checkbox variant="tile" label="Beef" />
|
|
106
107
|
<Checkbox variant="tile" label="Lamb" />
|
|
@@ -69,13 +69,12 @@ const StyledRootPressable = styled(Pressable)<{
|
|
|
69
69
|
? { paddingHorizontal: rootPaddingHorizontal }
|
|
70
70
|
: {}),
|
|
71
71
|
// `fitContent` tiles hug their content (for wrapping rows); otherwise the
|
|
72
|
-
// tile fills its container width.
|
|
73
|
-
// so labels wrap properly.
|
|
72
|
+
// tile fills its container width.
|
|
74
73
|
...(rootMaxWidth !== undefined
|
|
75
74
|
? rootFitContent
|
|
76
75
|
? { maxWidth: rootMaxWidth, alignSelf: "flex-start" }
|
|
77
76
|
: { maxWidth: rootMaxWidth, width: "100%" }
|
|
78
|
-
: {
|
|
77
|
+
: {}),
|
|
79
78
|
...(rootBgColor ? { backgroundColor: rootBgColor } : {}),
|
|
80
79
|
...(rootBorderWidth !== undefined
|
|
81
80
|
? { borderWidth: rootBorderWidth, borderColor: rootBorderColor }
|
|
@@ -114,11 +113,14 @@ const StyledControl = styled(View)<{
|
|
|
114
113
|
|
|
115
114
|
const StyledContent = styled(View)<{
|
|
116
115
|
contentGap: number
|
|
117
|
-
|
|
116
|
+
contentFlex: number
|
|
117
|
+
contentFitContent?: boolean
|
|
118
|
+
}>(({ contentGap, contentFlex, contentFitContent }) => ({
|
|
118
119
|
flexDirection: "column",
|
|
119
120
|
gap: contentGap,
|
|
120
|
-
flex:
|
|
121
|
-
minWidth: 0
|
|
121
|
+
flex: contentFlex,
|
|
122
|
+
minWidth: 0,
|
|
123
|
+
...(contentFitContent ? { maxWidth: "85%" } : {})
|
|
122
124
|
}))
|
|
123
125
|
|
|
124
126
|
const StyledIllustration = styled(View)<{
|
|
@@ -261,6 +263,8 @@ export const Checkbox = React.forwardRef<View, CheckboxProps>(
|
|
|
261
263
|
|
|
262
264
|
<StyledContent
|
|
263
265
|
contentGap={parseTokenValue(checkbox.spacing.content.gap)}
|
|
266
|
+
contentFlex={isTile && fitContent ? 0 : 1}
|
|
267
|
+
contentFitContent={isTile && fitContent}
|
|
264
268
|
>
|
|
265
269
|
{label && (
|
|
266
270
|
<Typography
|
|
@@ -79,7 +79,7 @@ export const DrawerBody = React.forwardRef<ScrollView, DrawerBodyProps>(
|
|
|
79
79
|
const paddingRight =
|
|
80
80
|
headerContext === null
|
|
81
81
|
? parseTokenValue(spacing.close.right.md) +
|
|
82
|
-
parseTokenValue(buttons.size.sm.
|
|
82
|
+
parseTokenValue(buttons.size.sm.minHeight)
|
|
83
83
|
: horizontalPadding
|
|
84
84
|
|
|
85
85
|
// With no footer, the body is the last element in the panel, so it must
|
|
@@ -409,6 +409,59 @@ export const SearchableDefaultPlaceholder = () => (
|
|
|
409
409
|
</StoryWrapper>
|
|
410
410
|
)
|
|
411
411
|
|
|
412
|
+
const ALL_BREEDS = [
|
|
413
|
+
{ value: "labrador", label: "Labrador Retriever" },
|
|
414
|
+
{ value: "german-shepherd", label: "German Shepherd" },
|
|
415
|
+
{ value: "golden-retriever", label: "Golden Retriever" },
|
|
416
|
+
{ value: "bulldog", label: "Bulldog" },
|
|
417
|
+
{ value: "poodle", label: "Poodle" },
|
|
418
|
+
{ value: "beagle", label: "Beagle" },
|
|
419
|
+
{ value: "rottweiler", label: "Rottweiler" },
|
|
420
|
+
{ value: "dachshund", label: "Dachshund" },
|
|
421
|
+
{ value: "yorkshire-terrier", label: "Yorkshire Terrier" },
|
|
422
|
+
{ value: "boxer", label: "Boxer" }
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
export const SearchableAsyncQuery = () => {
|
|
426
|
+
const searchBreeds = (query: string) =>
|
|
427
|
+
new Promise<typeof ALL_BREEDS>((resolve, reject) => {
|
|
428
|
+
setTimeout(() => {
|
|
429
|
+
// Type "fail" to see the error state.
|
|
430
|
+
if (query.toLowerCase().includes("fail")) {
|
|
431
|
+
reject(new Error("Simulated request failure"))
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
resolve(
|
|
435
|
+
ALL_BREEDS.filter((breed) =>
|
|
436
|
+
breed.label.toLowerCase().includes(query.toLowerCase())
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
}, 600)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<StoryWrapper>
|
|
444
|
+
<View style={styles.column}>
|
|
445
|
+
<View style={styles.section}>
|
|
446
|
+
<Typography size="sm" weight="semiBold" color="tertiary">
|
|
447
|
+
Async Query (spinner, no-results, error)
|
|
448
|
+
</Typography>
|
|
449
|
+
<SelectField
|
|
450
|
+
label="Breed"
|
|
451
|
+
placeholder="Select a breed"
|
|
452
|
+
description="Type to search — 'fail' triggers the error state"
|
|
453
|
+
searchable
|
|
454
|
+
searchPlaceholder="Search breeds..."
|
|
455
|
+
noResultsText="No breeds found"
|
|
456
|
+
errorText="Couldn't load breeds"
|
|
457
|
+
onSearchQuery={searchBreeds}
|
|
458
|
+
/>
|
|
459
|
+
</View>
|
|
460
|
+
</View>
|
|
461
|
+
</StoryWrapper>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
412
465
|
const styles = StyleSheet.create({
|
|
413
466
|
container: {
|
|
414
467
|
width: 320
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react"
|
|
2
2
|
import { View } from "react-native"
|
|
3
|
-
import { screen, fireEvent, act } from "@testing-library/react"
|
|
3
|
+
import { screen, fireEvent, act, waitFor } from "@testing-library/react"
|
|
4
4
|
import { describe, it, expect, vi } from "vitest"
|
|
5
5
|
import { renderWithTheme } from "../../../test-utils"
|
|
6
6
|
import { SelectField } from "./SelectField"
|
|
@@ -377,6 +377,262 @@ describe("SelectField", () => {
|
|
|
377
377
|
})
|
|
378
378
|
})
|
|
379
379
|
|
|
380
|
+
describe("when searchable with an async query", () => {
|
|
381
|
+
type Result = { value: string; label: string }
|
|
382
|
+
|
|
383
|
+
const openAndType = (query: string) => {
|
|
384
|
+
fireEvent.click(screen.getAllByRole("button")[0])
|
|
385
|
+
const input = screen.getByPlaceholderText("Search...")
|
|
386
|
+
fireEvent.change(input, { target: { value: query } })
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
it("shows children as the default list before the user types", async () => {
|
|
390
|
+
const onSearchQuery = vi.fn(async (): Promise<Result[]> => [])
|
|
391
|
+
|
|
392
|
+
renderWithTheme(
|
|
393
|
+
<SelectField
|
|
394
|
+
label="Breed"
|
|
395
|
+
placeholder="Select a breed"
|
|
396
|
+
searchable
|
|
397
|
+
debounceMs={0}
|
|
398
|
+
onSearchQuery={onSearchQuery}
|
|
399
|
+
>
|
|
400
|
+
<SelectField.Item value="popular">Most popular</SelectField.Item>
|
|
401
|
+
</SelectField>
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
fireEvent.click(screen.getAllByRole("button")[0])
|
|
405
|
+
|
|
406
|
+
expect(await screen.findByText("Most popular")).toBeInTheDocument()
|
|
407
|
+
expect(onSearchQuery).not.toHaveBeenCalled()
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it("does not open an empty dropdown in query mode before the user types", () => {
|
|
411
|
+
const onSearchQuery = vi.fn(async (): Promise<Result[]> => [])
|
|
412
|
+
|
|
413
|
+
renderWithTheme(
|
|
414
|
+
<SelectField
|
|
415
|
+
label="Breed"
|
|
416
|
+
placeholder="Select a breed"
|
|
417
|
+
searchable
|
|
418
|
+
debounceMs={0}
|
|
419
|
+
onSearchQuery={onSearchQuery}
|
|
420
|
+
/>
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
vi.useFakeTimers()
|
|
424
|
+
fireEvent.click(screen.getAllByRole("button")[0])
|
|
425
|
+
act(() => vi.advanceTimersByTime(500))
|
|
426
|
+
vi.useRealTimers()
|
|
427
|
+
|
|
428
|
+
// Trigger is open (search input shown) but no content container is
|
|
429
|
+
// rendered: an empty dropdown crashes the real Select.Content on web.
|
|
430
|
+
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument()
|
|
431
|
+
expect(screen.queryByTestId("select-overlay")).not.toBeInTheDocument()
|
|
432
|
+
expect(onSearchQuery).not.toHaveBeenCalled()
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it("calls onSearchQuery with the typed query and renders the results", async () => {
|
|
436
|
+
const onSearchQuery = vi.fn(
|
|
437
|
+
async (): Promise<Result[]> => [
|
|
438
|
+
{ value: "labrador", label: "Labrador Retriever" }
|
|
439
|
+
]
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
renderWithTheme(
|
|
443
|
+
<SelectField
|
|
444
|
+
label="Breed"
|
|
445
|
+
placeholder="Select a breed"
|
|
446
|
+
searchable
|
|
447
|
+
debounceMs={0}
|
|
448
|
+
onSearchQuery={onSearchQuery}
|
|
449
|
+
/>
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
openAndType("lab")
|
|
453
|
+
|
|
454
|
+
expect(await screen.findByText("Labrador Retriever")).toBeInTheDocument()
|
|
455
|
+
expect(onSearchQuery).toHaveBeenCalledWith("lab")
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it("shows a loading spinner while the query is in flight", async () => {
|
|
459
|
+
let resolveQuery: (results: Result[]) => void = () => {}
|
|
460
|
+
const onSearchQuery = vi.fn(
|
|
461
|
+
() =>
|
|
462
|
+
new Promise<Result[]>((resolve) => {
|
|
463
|
+
resolveQuery = resolve
|
|
464
|
+
})
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
renderWithTheme(
|
|
468
|
+
<SelectField
|
|
469
|
+
label="Breed"
|
|
470
|
+
placeholder="Select a breed"
|
|
471
|
+
searchable
|
|
472
|
+
debounceMs={0}
|
|
473
|
+
onSearchQuery={onSearchQuery}
|
|
474
|
+
/>
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
openAndType("lab")
|
|
478
|
+
|
|
479
|
+
expect(await screen.findByRole("progressbar")).toBeInTheDocument()
|
|
480
|
+
|
|
481
|
+
await waitFor(() => expect(onSearchQuery).toHaveBeenCalled())
|
|
482
|
+
act(() => resolveQuery([{ value: "labrador", label: "Labrador" }]))
|
|
483
|
+
|
|
484
|
+
expect(await screen.findByText("Labrador")).toBeInTheDocument()
|
|
485
|
+
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument()
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it("shows errorText when the query rejects", async () => {
|
|
489
|
+
const onSearchQuery = vi.fn(async (): Promise<Result[]> => {
|
|
490
|
+
throw new Error("boom")
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
renderWithTheme(
|
|
494
|
+
<SelectField
|
|
495
|
+
label="Breed"
|
|
496
|
+
placeholder="Select a breed"
|
|
497
|
+
searchable
|
|
498
|
+
debounceMs={0}
|
|
499
|
+
errorText="Couldn't load breeds"
|
|
500
|
+
onSearchQuery={onSearchQuery}
|
|
501
|
+
/>
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
openAndType("lab")
|
|
505
|
+
|
|
506
|
+
expect(
|
|
507
|
+
await screen.findByText("Couldn't load breeds")
|
|
508
|
+
).toBeInTheDocument()
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it("shows noResultsText when the query resolves empty", async () => {
|
|
512
|
+
const onSearchQuery = vi.fn(async (): Promise<Result[]> => [])
|
|
513
|
+
|
|
514
|
+
renderWithTheme(
|
|
515
|
+
<SelectField
|
|
516
|
+
label="Breed"
|
|
517
|
+
placeholder="Select a breed"
|
|
518
|
+
searchable
|
|
519
|
+
debounceMs={0}
|
|
520
|
+
noResultsText="No breeds found"
|
|
521
|
+
onSearchQuery={onSearchQuery}
|
|
522
|
+
/>
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
openAndType("xyz")
|
|
526
|
+
|
|
527
|
+
expect(await screen.findByText("No breeds found")).toBeInTheDocument()
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it("renders results instead of locally filtering children in query mode", async () => {
|
|
531
|
+
const onSearchQuery = vi.fn(
|
|
532
|
+
async (): Promise<Result[]> => [
|
|
533
|
+
{ value: "labrador", label: "Labrador Retriever" }
|
|
534
|
+
]
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
renderWithTheme(
|
|
538
|
+
<SelectField
|
|
539
|
+
label="Breed"
|
|
540
|
+
placeholder="Select a breed"
|
|
541
|
+
searchable
|
|
542
|
+
debounceMs={0}
|
|
543
|
+
onSearchQuery={onSearchQuery}
|
|
544
|
+
>
|
|
545
|
+
<SelectField.Item value="uk">United Kingdom</SelectField.Item>
|
|
546
|
+
</SelectField>
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
openAndType("lab")
|
|
550
|
+
|
|
551
|
+
expect(await screen.findByText("Labrador Retriever")).toBeInTheDocument()
|
|
552
|
+
expect(screen.queryByText("United Kingdom")).not.toBeInTheDocument()
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it("debounces the query, firing once after the last keystroke", () => {
|
|
556
|
+
const onSearchQuery = vi.fn(
|
|
557
|
+
async (): Promise<Result[]> => [
|
|
558
|
+
{ value: "labrador", label: "Labrador Retriever" }
|
|
559
|
+
]
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
renderWithTheme(
|
|
563
|
+
<SelectField
|
|
564
|
+
label="Breed"
|
|
565
|
+
placeholder="Select a breed"
|
|
566
|
+
searchable
|
|
567
|
+
debounceMs={300}
|
|
568
|
+
onSearchQuery={onSearchQuery}
|
|
569
|
+
/>
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
vi.useFakeTimers()
|
|
573
|
+
fireEvent.click(screen.getAllByRole("button")[0])
|
|
574
|
+
const input = screen.getByPlaceholderText("Search...")
|
|
575
|
+
|
|
576
|
+
fireEvent.change(input, { target: { value: "l" } })
|
|
577
|
+
act(() => vi.advanceTimersByTime(200))
|
|
578
|
+
fireEvent.change(input, { target: { value: "la" } })
|
|
579
|
+
act(() => vi.advanceTimersByTime(200))
|
|
580
|
+
fireEvent.change(input, { target: { value: "lab" } })
|
|
581
|
+
|
|
582
|
+
// Each keystroke restarted the timer, so nothing has fired yet even
|
|
583
|
+
// though 400ms of typing has elapsed.
|
|
584
|
+
expect(onSearchQuery).not.toHaveBeenCalled()
|
|
585
|
+
|
|
586
|
+
act(() => vi.advanceTimersByTime(300))
|
|
587
|
+
vi.useRealTimers()
|
|
588
|
+
|
|
589
|
+
expect(onSearchQuery).toHaveBeenCalledTimes(1)
|
|
590
|
+
expect(onSearchQuery).toHaveBeenCalledWith("lab")
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it("ignores a stale response superseded by a later keystroke", async () => {
|
|
594
|
+
const resolvers: Array<(results: Result[]) => void> = []
|
|
595
|
+
const onSearchQuery = vi.fn(
|
|
596
|
+
() =>
|
|
597
|
+
new Promise<Result[]>((resolve) => {
|
|
598
|
+
resolvers.push(resolve)
|
|
599
|
+
})
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
renderWithTheme(
|
|
603
|
+
<SelectField
|
|
604
|
+
label="Breed"
|
|
605
|
+
placeholder="Select a breed"
|
|
606
|
+
searchable
|
|
607
|
+
debounceMs={0}
|
|
608
|
+
onSearchQuery={onSearchQuery}
|
|
609
|
+
/>
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
fireEvent.click(screen.getAllByRole("button")[0])
|
|
613
|
+
const input = screen.getByPlaceholderText("Search...")
|
|
614
|
+
|
|
615
|
+
fireEvent.change(input, { target: { value: "lab" } })
|
|
616
|
+
await waitFor(() => expect(resolvers).toHaveLength(1))
|
|
617
|
+
|
|
618
|
+
fireEvent.change(input, { target: { value: "labr" } })
|
|
619
|
+
await waitFor(() => expect(resolvers).toHaveLength(2))
|
|
620
|
+
|
|
621
|
+
// The newer query lands first, then the superseded one resolves late.
|
|
622
|
+
await act(async () => {
|
|
623
|
+
resolvers[1]([{ value: "labrador", label: "Labrador Retriever" }])
|
|
624
|
+
})
|
|
625
|
+
expect(screen.getByText("Labrador Retriever")).toBeInTheDocument()
|
|
626
|
+
|
|
627
|
+
await act(async () => {
|
|
628
|
+
resolvers[0]([{ value: "stale", label: "Stale Result" }])
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
expect(screen.queryByText("Stale Result")).not.toBeInTheDocument()
|
|
632
|
+
expect(screen.getByText("Labrador Retriever")).toBeInTheDocument()
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
|
|
380
636
|
describe("with items", () => {
|
|
381
637
|
const openDropdown = () => fireEvent.click(screen.getAllByRole("button")[0])
|
|
382
638
|
|