@butternutbox/pawprint-native 0.21.0 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butternutbox/pawprint-native",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
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.4.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
- buttonHeight: number
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
- buttonHeight,
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
- height: buttonHeight,
71
+ minHeight: buttonMinHeight,
72
72
  minWidth: buttonMinWidth,
73
73
  paddingHorizontal: buttonPaddingHorizontal,
74
74
  paddingVertical: buttonPaddingVertical,
@@ -87,7 +87,7 @@ const StyledTextWrapper = styled(View)<{
87
87
  textOpacity: number
88
88
  }>(({ textOpacity }) => ({
89
89
  opacity: textOpacity,
90
- flexShrink: 0
90
+ flex: 1
91
91
  }))
92
92
 
93
93
  const StyledSpinnerWrapper = styled(View)({
@@ -207,7 +207,7 @@ const Button = React.forwardRef<View, ButtonProps>(
207
207
  accessibilityState={{ disabled: isDisabled, busy: loading }}
208
208
  onPressIn={() => setPressed(true)}
209
209
  onPressOut={() => setPressed(false)}
210
- buttonHeight={parseTokenValue(sizeTokens.height)}
210
+ buttonMinHeight={parseTokenValue(sizeTokens.minHeight)}
211
211
  buttonMinWidth={parseTokenValue(sizeTokens.minWidth)}
212
212
  buttonPaddingHorizontal={parseTokenValue(
213
213
  spacingTokens.horizontalPadding
@@ -238,6 +238,7 @@ const Button = React.forwardRef<View, ButtonProps>(
238
238
  letterSpacing: typography.letterSpacing
239
239
  }}
240
240
  color={variantStyles.textColor as never}
241
+ align="center"
241
242
  >
242
243
  {children}
243
244
  </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.height)
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
- height: parseTokenValue(sizing[tagSize].height),
57
+ minHeight: parseTokenValue(sizing[tagSize].minHeight),
51
58
  minWidth: parseTokenValue(sizing[tagSize].minWidth),
52
- paddingHorizontal: parseTokenValue(spacing.horizontalPadding),
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]
@@ -99,6 +99,10 @@ export const Tile = () => (
99
99
  </Typography>
100
100
  <CheckboxGroup orientation="horizontal">
101
101
  <Checkbox variant="tile" label="Chicken" />
102
+ <Checkbox
103
+ variant="tile"
104
+ label="A really long piece of text that needs multiple lines"
105
+ />
102
106
  <Checkbox variant="tile" label="Beef" />
103
107
  <Checkbox variant="tile" label="Lamb" />
104
108
  </CheckboxGroup>
@@ -114,11 +114,13 @@ const StyledControl = styled(View)<{
114
114
  const StyledContent = styled(View)<{
115
115
  contentGap: number
116
116
  contentFlex: number
117
- }>(({ contentGap, contentFlex }) => ({
117
+ contentFitContent?: boolean
118
+ }>(({ contentGap, contentFlex, contentFitContent }) => ({
118
119
  flexDirection: "column",
119
120
  gap: contentGap,
120
121
  flex: contentFlex,
121
- minWidth: 0
122
+ minWidth: 0,
123
+ ...(contentFitContent ? { maxWidth: "85%" } : {})
122
124
  }))
123
125
 
124
126
  const StyledIllustration = styled(View)<{
@@ -262,6 +264,7 @@ export const Checkbox = React.forwardRef<View, CheckboxProps>(
262
264
  <StyledContent
263
265
  contentGap={parseTokenValue(checkbox.spacing.content.gap)}
264
266
  contentFlex={isTile && fitContent ? 0 : 1}
267
+ contentFitContent={isTile && fitContent}
265
268
  >
266
269
  {label && (
267
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.height)
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