@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butternutbox/pawprint-native",
3
- "version": "0.22.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.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,
@@ -85,9 +85,10 @@ const StyledButton = styled(Pressable)<{
85
85
 
86
86
  const StyledTextWrapper = styled(View)<{
87
87
  textOpacity: number
88
- }>(({ textOpacity }) => ({
88
+ fullWidth?: boolean
89
+ }>(({ textOpacity, fullWidth }) => ({
89
90
  opacity: textOpacity,
90
- flexShrink: 0
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
- buttonHeight={parseTokenValue(sizeTokens.height)}
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.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]
@@ -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="Some really really really really really really long text"
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. For standalone checkboxes, always fill 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
- : { width: "100%" }),
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
- }>(({ contentGap }) => ({
116
+ contentFlex: number
117
+ contentFitContent?: boolean
118
+ }>(({ contentGap, contentFlex, contentFitContent }) => ({
118
119
  flexDirection: "column",
119
120
  gap: contentGap,
120
- flex: 1,
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.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