@butternutbox/pawprint-native 1.6.0 → 1.6.2

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.2",
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}
@@ -1,7 +1,8 @@
1
1
  import React from "react"
2
2
  import { View } from "react-native"
3
3
  import { screen, fireEvent, act, waitFor } from "@testing-library/react"
4
- import { describe, it, expect, vi } from "vitest"
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
5
+ import { MEASURED_TRIGGER } from "../../../__mocks__/react-native"
5
6
  import { renderWithTheme } from "../../../test-utils"
6
7
  import { SelectField } from "./SelectField"
7
8
 
@@ -697,4 +698,80 @@ describe("SelectField", () => {
697
698
  expect(item).toBeInTheDocument()
698
699
  })
699
700
  })
701
+ describe("when the field moves while the dropdown is open", () => {
702
+ const restingPageY = MEASURED_TRIGGER.pageY
703
+
704
+ beforeEach(() => {
705
+ vi.useFakeTimers()
706
+ })
707
+
708
+ afterEach(() => {
709
+ MEASURED_TRIGGER.pageY = restingPageY
710
+ vi.useRealTimers()
711
+ })
712
+
713
+ const renderSearchable = () =>
714
+ renderWithTheme(
715
+ <SelectField label="Breed" placeholder="Select a breed" searchable>
716
+ <SelectField.Item value="lab">Labrador</SelectField.Item>
717
+ <SelectField.Item value="pug">Pug</SelectField.Item>
718
+ </SelectField>
719
+ )
720
+
721
+ const triggerPositionOf = (container: HTMLElement) =>
722
+ container
723
+ .querySelector("[data-trigger-position]")
724
+ ?.getAttribute("data-trigger-position")
725
+
726
+ const advanceFrames = (count: number) =>
727
+ act(() => vi.advanceTimersByTime(count * 16))
728
+
729
+ it("anchors the dropdown to the field once it has opened", () => {
730
+ const { container } = renderSearchable()
731
+
732
+ fireEvent.click(screen.getAllByRole("button")[0])
733
+ advanceFrames(3)
734
+
735
+ expect(triggerPositionOf(container)).toBe(
736
+ JSON.stringify(MEASURED_TRIGGER)
737
+ )
738
+ })
739
+
740
+ it("follows the field to where it comes to rest", () => {
741
+ const { container } = renderSearchable()
742
+
743
+ fireEvent.click(screen.getAllByRole("button")[0])
744
+ advanceFrames(3)
745
+ MEASURED_TRIGGER.pageY = 380
746
+ advanceFrames(3)
747
+
748
+ expect(triggerPositionOf(container)).toBe(
749
+ JSON.stringify({ ...MEASURED_TRIGGER, pageY: 380 })
750
+ )
751
+ })
752
+
753
+ it("holds the dropdown in place while the field is still moving", () => {
754
+ const { container } = renderSearchable()
755
+
756
+ fireEvent.click(screen.getAllByRole("button")[0])
757
+ advanceFrames(3)
758
+
759
+ for (const pageY of [650, 600, 550]) {
760
+ MEASURED_TRIGGER.pageY = pageY
761
+ advanceFrames(1)
762
+ }
763
+
764
+ expect(triggerPositionOf(container)).toBe(
765
+ JSON.stringify({ ...MEASURED_TRIGGER, pageY: restingPageY })
766
+ )
767
+ })
768
+
769
+ it("does not track the field while the dropdown is closed", () => {
770
+ const { container } = renderSearchable()
771
+
772
+ advanceFrames(3)
773
+
774
+ expect(triggerPositionOf(container)).toBeUndefined()
775
+ })
776
+ })
700
777
  })
@@ -75,6 +75,59 @@ 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
+ type TriggerPosition = NonNullable<
84
+ ReturnType<typeof SelectPrimitive.useRootContext>["triggerPosition"]
85
+ >
86
+
87
+ const isSamePosition = (a: TriggerPosition | null, b: TriggerPosition | null) =>
88
+ a !== null &&
89
+ b !== null &&
90
+ a.width === b.width &&
91
+ a.height === b.height &&
92
+ a.pageX === b.pageX &&
93
+ a.pageY === b.pageY
94
+
95
+ function SelectFieldAnchor({ triggerRef, enabled }: SelectFieldAnchorProps) {
96
+ const { triggerPosition, setTriggerPosition } =
97
+ SelectPrimitive.useRootContext()
98
+ const anchoredRef = React.useRef<TriggerPosition | null>(null)
99
+
100
+ React.useEffect(() => {
101
+ anchoredRef.current = triggerPosition ?? null
102
+ }, [triggerPosition])
103
+
104
+ React.useEffect(() => {
105
+ if (!enabled) return
106
+
107
+ let frame: number
108
+ let previous: TriggerPosition | null = null
109
+
110
+ const track = () => {
111
+ triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
112
+ const measured = { width, height, pageX, pageY }
113
+ const settled = isSamePosition(measured, previous)
114
+ if (settled && !isSamePosition(measured, anchoredRef.current)) {
115
+ anchoredRef.current = measured
116
+ setTriggerPosition(measured)
117
+ }
118
+ previous = measured
119
+ })
120
+ frame = requestAnimationFrame(track)
121
+ }
122
+
123
+ frame = requestAnimationFrame(track)
124
+
125
+ return () => cancelAnimationFrame(frame)
126
+ }, [enabled, setTriggerPosition, triggerRef])
127
+
128
+ return null
129
+ }
130
+
78
131
  /**
79
132
  * Select field component for choosing a value from a dropdown list.
80
133
  * Built on @rn-primitives/select with design system styling.
@@ -216,6 +269,7 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
216
269
  const [searchQuery, setSearchQuery] = React.useState("")
217
270
  const [clearKey, setClearKey] = React.useState(0)
218
271
  const searchInputRef = React.useRef<TextInput>(null)
272
+ const triggerViewRef = React.useRef<View>(null)
219
273
 
220
274
  const isQueryMode = searchable && typeof onSearchQuery === "function"
221
275
 
@@ -447,6 +501,7 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
447
501
  </InputLabel>
448
502
  )}
449
503
  <SelectFieldTrigger
504
+ ref={triggerViewRef}
450
505
  state={state}
451
506
  leadingIcon={leadingIcon}
452
507
  isOpen={isOpen}
@@ -476,6 +531,10 @@ const SelectFieldRoot = React.forwardRef<View, SelectFieldProps>(
476
531
  )}
477
532
  {error && state === "error" && <InputError>{error}</InputError>}
478
533
  </StyledRoot>
534
+ <SelectFieldAnchor
535
+ triggerRef={triggerViewRef}
536
+ enabled={searchable && isOpen}
537
+ />
479
538
  {showContent && hasContentToShow && (
480
539
  <SelectFieldContent>
481
540
  {isSearching ? (