@butternutbox/pawprint-native 1.6.0 → 1.6.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": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -1,5 +1,38 @@
1
1
  import React from "react"
2
2
 
3
+ export const MEASURED_TRIGGER = {
4
+ width: 320,
5
+ height: 52,
6
+ pageX: 40,
7
+ pageY: 700
8
+ }
9
+
10
+ const attachMeasure =
11
+ (ref: React.Ref<HTMLDivElement>) => (node: HTMLDivElement | null) => {
12
+ if (node) {
13
+ ;(node as unknown as { measure: unknown }).measure = (
14
+ callback: (
15
+ x: number,
16
+ y: number,
17
+ width: number,
18
+ height: number,
19
+ pageX: number,
20
+ pageY: number
21
+ ) => void
22
+ ) =>
23
+ callback(
24
+ 0,
25
+ 0,
26
+ MEASURED_TRIGGER.width,
27
+ MEASURED_TRIGGER.height,
28
+ MEASURED_TRIGGER.pageX,
29
+ MEASURED_TRIGGER.pageY
30
+ )
31
+ }
32
+ if (typeof ref === "function") ref(node)
33
+ else if (ref) (ref as React.RefObject<HTMLDivElement | null>).current = node
34
+ }
35
+
3
36
  // ─── View ────────────────────────────────────────────────────────────────
4
37
  export const View = React.forwardRef<HTMLDivElement, any>(
5
38
  (
@@ -45,7 +78,7 @@ export const View = React.forwardRef<HTMLDivElement, any>(
45
78
  domProps["aria-valuemax"] = String(accessibilityValue.max)
46
79
  }
47
80
  return (
48
- <div ref={ref} style={style} {...domProps}>
81
+ <div ref={attachMeasure(ref)} style={style} {...domProps}>
49
82
  {children}
50
83
  </div>
51
84
  )
@@ -335,11 +368,19 @@ export const Platform = {
335
368
  select: (obj: any) => obj.ios ?? obj.default
336
369
  }
337
370
 
371
+ const keyboardHandlers = new Map<string, Set<() => void>>()
372
+
338
373
  export const Keyboard = {
339
- addListener: (_event: string, _handler: () => void) => ({
340
- remove: () => {}
341
- }),
342
- dismiss: () => {}
374
+ addListener: (event: string, handler: () => void) => {
375
+ const handlers = keyboardHandlers.get(event) ?? new Set()
376
+ handlers.add(handler)
377
+ keyboardHandlers.set(event, handlers)
378
+ return { remove: () => handlers.delete(handler) }
379
+ },
380
+ dismiss: () => {},
381
+ emit: (event: string) => {
382
+ keyboardHandlers.get(event)?.forEach((handler) => handler())
383
+ }
343
384
  }
344
385
 
345
386
  // ─── Type exports (re-exported as empty for TS compat) ───────────────────
@@ -1,9 +1,19 @@
1
1
  import React from "react"
2
2
 
3
+ type TriggerPosition = {
4
+ width: number
5
+ height: number
6
+ pageX: number
7
+ pageY: number
8
+ }
9
+
3
10
  const SelectCtx = React.createContext<{
4
11
  open: boolean
5
12
  setOpen: (open: boolean) => void
6
- }>({ open: false, setOpen: () => {} })
13
+ setTriggerPosition: (position: TriggerPosition | null) => void
14
+ }>({ open: false, setOpen: () => {}, setTriggerPosition: () => {} })
15
+
16
+ export const useRootContext = () => React.useContext(SelectCtx)
7
17
 
8
18
  export const Root = React.forwardRef<any, any>(
9
19
  (
@@ -19,6 +29,8 @@ export const Root = React.forwardRef<any, any>(
19
29
  ref
20
30
  ) => {
21
31
  const [open, setOpenState] = React.useState(false)
32
+ const [triggerPosition, setTriggerPosition] =
33
+ React.useState<TriggerPosition | null>(null)
22
34
  const [internalValue, setInternalValue] = React.useState(
23
35
  value ?? defaultValue ?? null
24
36
  )
@@ -37,8 +49,15 @@ export const Root = React.forwardRef<any, any>(
37
49
  }
38
50
 
39
51
  return (
40
- <SelectCtx.Provider value={{ open, setOpen }}>
41
- <div ref={ref} data-disabled={disabled} {...rest}>
52
+ <SelectCtx.Provider value={{ open, setOpen, setTriggerPosition }}>
53
+ <div
54
+ ref={ref}
55
+ data-disabled={disabled}
56
+ data-trigger-position={
57
+ triggerPosition ? JSON.stringify(triggerPosition) : undefined
58
+ }
59
+ {...rest}
60
+ >
42
61
  {typeof children === "function"
43
62
  ? children({ value: currentValue, onChange: handleChange })
44
63
  : children}
@@ -2,6 +2,7 @@ import React from "react"
2
2
  import { View } from "react-native"
3
3
  import { screen, fireEvent, act, waitFor } from "@testing-library/react"
4
4
  import { describe, it, expect, vi } from "vitest"
5
+ import { Keyboard, MEASURED_TRIGGER } from "../../../__mocks__/react-native"
5
6
  import { renderWithTheme } from "../../../test-utils"
6
7
  import { SelectField } from "./SelectField"
7
8
 
@@ -697,4 +698,50 @@ describe("SelectField", () => {
697
698
  expect(item).toBeInTheDocument()
698
699
  })
699
700
  })
701
+ describe("when the keyboard moves the trigger", () => {
702
+ const renderSearchable = () =>
703
+ renderWithTheme(
704
+ <SelectField label="Breed" placeholder="Select a breed" searchable>
705
+ <SelectField.Item value="lab">Labrador</SelectField.Item>
706
+ <SelectField.Item value="pug">Pug</SelectField.Item>
707
+ </SelectField>
708
+ )
709
+
710
+ const triggerPositionOf = (container: HTMLElement) =>
711
+ container
712
+ .querySelector("[data-trigger-position]")
713
+ ?.getAttribute("data-trigger-position")
714
+
715
+ it("re-anchors the dropdown to the trigger when the keyboard shows", () => {
716
+ const { container } = renderSearchable()
717
+
718
+ fireEvent.click(screen.getAllByRole("button")[0])
719
+ expect(triggerPositionOf(container)).toBeUndefined()
720
+
721
+ act(() => Keyboard.emit("keyboardDidShow"))
722
+
723
+ expect(triggerPositionOf(container)).toBe(
724
+ JSON.stringify(MEASURED_TRIGGER)
725
+ )
726
+ })
727
+
728
+ it("re-anchors the dropdown when the keyboard hides", () => {
729
+ const { container } = renderSearchable()
730
+
731
+ fireEvent.click(screen.getAllByRole("button")[0])
732
+ act(() => Keyboard.emit("keyboardDidHide"))
733
+
734
+ expect(triggerPositionOf(container)).toBe(
735
+ JSON.stringify(MEASURED_TRIGGER)
736
+ )
737
+ })
738
+
739
+ it("does not re-anchor while the dropdown is closed", () => {
740
+ const { container } = renderSearchable()
741
+
742
+ act(() => Keyboard.emit("keyboardDidShow"))
743
+
744
+ expect(triggerPositionOf(container)).toBeUndefined()
745
+ })
746
+ })
700
747
  })
@@ -75,6 +75,35 @@ type SelectFieldOwnProps<Value = Option | string> = {
75
75
  export type SelectFieldProps<Value = Option> = SelectFieldOwnProps<Value> &
76
76
  Omit<ViewProps, keyof SelectFieldOwnProps<Value>>
77
77
 
78
+ type SelectFieldAnchorProps = {
79
+ triggerRef: React.RefObject<View | null>
80
+ enabled: boolean
81
+ }
82
+
83
+ function SelectFieldAnchor({ triggerRef, enabled }: SelectFieldAnchorProps) {
84
+ const { setTriggerPosition } = SelectPrimitive.useRootContext()
85
+
86
+ React.useEffect(() => {
87
+ if (!enabled) return
88
+
89
+ const reanchor = () => {
90
+ triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
91
+ setTriggerPosition({ width, height, pageX, pageY })
92
+ })
93
+ }
94
+
95
+ const shown = Keyboard.addListener("keyboardDidShow", reanchor)
96
+ const hidden = Keyboard.addListener("keyboardDidHide", reanchor)
97
+
98
+ return () => {
99
+ shown.remove()
100
+ hidden.remove()
101
+ }
102
+ }, [enabled, setTriggerPosition, triggerRef])
103
+
104
+ return null
105
+ }
106
+
78
107
  /**
79
108
  * Select field component for choosing a value from a dropdown list.
80
109
  * Built on @rn-primitives/select with design system styling.
@@ -216,6 +245,7 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
216
245
  const [searchQuery, setSearchQuery] = React.useState("")
217
246
  const [clearKey, setClearKey] = React.useState(0)
218
247
  const searchInputRef = React.useRef<TextInput>(null)
248
+ const triggerViewRef = React.useRef<View>(null)
219
249
 
220
250
  const isQueryMode = searchable && typeof onSearchQuery === "function"
221
251
 
@@ -447,6 +477,7 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
447
477
  </InputLabel>
448
478
  )}
449
479
  <SelectFieldTrigger
480
+ ref={triggerViewRef}
450
481
  state={state}
451
482
  leadingIcon={leadingIcon}
452
483
  isOpen={isOpen}
@@ -476,6 +507,10 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
476
507
  )}
477
508
  {error && state === "error" && <InputError>{error}</InputError>}
478
509
  </StyledRoot>
510
+ <SelectFieldAnchor
511
+ triggerRef={triggerViewRef}
512
+ enabled={searchable && isOpen}
513
+ />
479
514
  {showContent && hasContentToShow && (
480
515
  <SelectFieldContent>
481
516
  {isSearching ? (