@butternutbox/pawprint-native 1.5.1 → 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/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +26 -0
- package/dist/index.cjs +50 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +50 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__mocks__/react-native.tsx +46 -5
- package/src/__mocks__/rn-primitives/select.tsx +22 -3
- package/src/components/molecules/Drawer/Drawer.test.tsx +49 -2
- package/src/components/molecules/Drawer/Drawer.tsx +32 -3
- package/src/components/molecules/SelectField/SelectField.test.tsx +47 -0
- package/src/components/molecules/SelectField/SelectField.tsx +35 -0
package/package.json
CHANGED
|
@@ -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: (
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
@@ -6,12 +6,18 @@ import { Drawer } from "./Drawer"
|
|
|
6
6
|
|
|
7
7
|
const BasicDrawer = ({
|
|
8
8
|
open,
|
|
9
|
-
onOpenChange
|
|
9
|
+
onOpenChange,
|
|
10
|
+
onExitComplete
|
|
10
11
|
}: {
|
|
11
12
|
open?: boolean
|
|
12
13
|
onOpenChange?: (v: boolean) => void
|
|
14
|
+
onExitComplete?: () => void
|
|
13
15
|
}) => (
|
|
14
|
-
<Drawer.Root
|
|
16
|
+
<Drawer.Root
|
|
17
|
+
open={open}
|
|
18
|
+
onOpenChange={onOpenChange}
|
|
19
|
+
onExitComplete={onExitComplete}
|
|
20
|
+
>
|
|
15
21
|
<Drawer.Trigger>
|
|
16
22
|
<button>Open</button>
|
|
17
23
|
</Drawer.Trigger>
|
|
@@ -77,6 +83,47 @@ describe("Drawer", () => {
|
|
|
77
83
|
})
|
|
78
84
|
})
|
|
79
85
|
|
|
86
|
+
describe("onExitComplete", () => {
|
|
87
|
+
it("is not called while the drawer has never closed", () => {
|
|
88
|
+
const onExitComplete = vi.fn()
|
|
89
|
+
const { rerender } = renderWithTheme(
|
|
90
|
+
<BasicDrawer open={false} onExitComplete={onExitComplete} />
|
|
91
|
+
)
|
|
92
|
+
rerender(<BasicDrawer open onExitComplete={onExitComplete} />)
|
|
93
|
+
expect(onExitComplete).not.toHaveBeenCalled()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("is called once the drawer has closed and its content is removed", () => {
|
|
97
|
+
const onExitComplete = vi.fn(() => {
|
|
98
|
+
expect(screen.queryByText("Drawer title")).not.toBeInTheDocument()
|
|
99
|
+
})
|
|
100
|
+
const { rerender } = renderWithTheme(
|
|
101
|
+
<BasicDrawer open onExitComplete={onExitComplete} />
|
|
102
|
+
)
|
|
103
|
+
rerender(<BasicDrawer open={false} onExitComplete={onExitComplete} />)
|
|
104
|
+
expect(onExitComplete).toHaveBeenCalledTimes(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("is called once per close", () => {
|
|
108
|
+
const onExitComplete = vi.fn()
|
|
109
|
+
const { rerender } = renderWithTheme(
|
|
110
|
+
<BasicDrawer open onExitComplete={onExitComplete} />
|
|
111
|
+
)
|
|
112
|
+
rerender(<BasicDrawer open={false} onExitComplete={onExitComplete} />)
|
|
113
|
+
rerender(<BasicDrawer open onExitComplete={onExitComplete} />)
|
|
114
|
+
rerender(<BasicDrawer open={false} onExitComplete={onExitComplete} />)
|
|
115
|
+
expect(onExitComplete).toHaveBeenCalledTimes(2)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("is called when an uncontrolled drawer is closed", () => {
|
|
119
|
+
const onExitComplete = vi.fn()
|
|
120
|
+
renderWithTheme(<BasicDrawer onExitComplete={onExitComplete} />)
|
|
121
|
+
fireEvent.click(screen.getByText("Open"))
|
|
122
|
+
fireEvent.click(screen.getByLabelText("Close"))
|
|
123
|
+
expect(onExitComplete).toHaveBeenCalledTimes(1)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
80
127
|
describe("controlled mode", () => {
|
|
81
128
|
it("respects controlled open=true", () => {
|
|
82
129
|
renderWithTheme(<BasicDrawer open />)
|
|
@@ -19,6 +19,14 @@ type DrawerRootOwnProps = {
|
|
|
19
19
|
open?: boolean
|
|
20
20
|
defaultOpen?: boolean
|
|
21
21
|
onOpenChange?: (open: boolean) => void
|
|
22
|
+
/**
|
|
23
|
+
* Called once the drawer has fully closed: the exit animation has finished
|
|
24
|
+
* and the native `Modal` has been removed. Use it to chain work that can't
|
|
25
|
+
* overlap the drawer, such as opening another modal drawer (iOS won't present
|
|
26
|
+
* a second `Modal` while the first is still on screen) or unmounting the
|
|
27
|
+
* drawer's host.
|
|
28
|
+
*/
|
|
29
|
+
onExitComplete?: () => void
|
|
22
30
|
/**
|
|
23
31
|
* Whether the drawer shows a dimming backdrop and presents as a blocking
|
|
24
32
|
* modal (default `true`). Set to `false` for a backdrop-less, pass-through
|
|
@@ -70,6 +78,7 @@ const DrawerRoot = React.forwardRef<View, DrawerProps>(
|
|
|
70
78
|
open: controlledOpen,
|
|
71
79
|
defaultOpen = false,
|
|
72
80
|
onOpenChange,
|
|
81
|
+
onExitComplete,
|
|
73
82
|
backdrop = true,
|
|
74
83
|
children,
|
|
75
84
|
style,
|
|
@@ -102,10 +111,23 @@ const DrawerRoot = React.forwardRef<View, DrawerProps>(
|
|
|
102
111
|
onOpenChange?.(false)
|
|
103
112
|
}, [onOpenChange])
|
|
104
113
|
|
|
105
|
-
const
|
|
114
|
+
const handleExitComplete = useCallback(() => {
|
|
106
115
|
setModalVisible(false)
|
|
107
116
|
}, [])
|
|
108
117
|
|
|
118
|
+
const onExitCompleteRef = useRef(onExitComplete)
|
|
119
|
+
onExitCompleteRef.current = onExitComplete
|
|
120
|
+
const wasModalVisible = useRef(modalVisible)
|
|
121
|
+
|
|
122
|
+
// Fires after the commit that removed the Modal, not from the animation
|
|
123
|
+
// callback, so consumers never act while the Modal is still mounted.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (wasModalVisible.current && !modalVisible) {
|
|
126
|
+
onExitCompleteRef.current?.()
|
|
127
|
+
}
|
|
128
|
+
wasModalVisible.current = modalVisible
|
|
129
|
+
}, [modalVisible])
|
|
130
|
+
|
|
109
131
|
const ctx = useMemo(
|
|
110
132
|
() => ({
|
|
111
133
|
isOpen,
|
|
@@ -113,9 +135,16 @@ const DrawerRoot = React.forwardRef<View, DrawerProps>(
|
|
|
113
135
|
backdrop,
|
|
114
136
|
openDrawer,
|
|
115
137
|
closeDrawer,
|
|
116
|
-
onExitComplete
|
|
138
|
+
onExitComplete: handleExitComplete
|
|
117
139
|
}),
|
|
118
|
-
[
|
|
140
|
+
[
|
|
141
|
+
isOpen,
|
|
142
|
+
modalVisible,
|
|
143
|
+
backdrop,
|
|
144
|
+
openDrawer,
|
|
145
|
+
closeDrawer,
|
|
146
|
+
handleExitComplete
|
|
147
|
+
]
|
|
119
148
|
)
|
|
120
149
|
|
|
121
150
|
return (
|
|
@@ -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 ? (
|