@startupjs-ui/abstract-popover 0.1.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
6
+ ## [0.1.3](https://github.com/startupjs/startupjs-ui/compare/v0.1.2...v0.1.3) (2025-12-29)
7
+
8
+ **Note:** Version bump only for package @startupjs-ui/abstract-popover
9
+
10
+
11
+
12
+
13
+
14
+ ## [0.1.2](https://github.com/startupjs/startupjs-ui/compare/v0.1.1...v0.1.2) (2025-12-29)
15
+
16
+
17
+ ### Features
18
+
19
+ * **abstract-popover:** refactor AbstractPopover component ([4a19018](https://github.com/startupjs/startupjs-ui/commit/4a190183b9e6903b6758d1d006fce7eca39bce0a))
20
+ * add mdx and docs packages. Refactor docs to get rid of any @startupjs/ui usage and use startupjs-ui instead ([703c926](https://github.com/startupjs/startupjs-ui/commit/703c92636efb0421ffd11783f692fc892b74018f))
package/README.mdx ADDED
@@ -0,0 +1,156 @@
1
+ import { useRef, useState } from 'react'
2
+ import { Sandbox } from '@startupjs-ui/docs'
3
+ import AbstractPopover, {
4
+ _PropsJsonSchema as AbstractPopoverPropsJsonSchema
5
+ } from './index'
6
+ import Button from '@startupjs-ui/button'
7
+ import Div from '@startupjs-ui/div'
8
+ import Span from '@startupjs-ui/span'
9
+
10
+ # AbstractPopover
11
+
12
+ Low-level primitive for anchoring content to an element (tooltip / popover use-cases).
13
+
14
+ ```jsx
15
+ import { AbstractPopover } from 'startupjs-ui'
16
+ ```
17
+
18
+ ## Basic example
19
+
20
+ ```jsx example
21
+ const anchorRef = useRef()
22
+ const [visible, setVisible] = useState(false)
23
+
24
+ return (
25
+ <Div style={{ padding: 20, backgroundColor: '#f6f7f9' }}>
26
+ <Div row gap style={{ alignItems: 'center' }}>
27
+ <Button onPress={() => setVisible(v => !v)}>
28
+ Toggle popover
29
+ </Button>
30
+ <Div
31
+ ref={anchorRef}
32
+ style={{
33
+ padding: 10,
34
+ backgroundColor: 'white',
35
+ borderRadius: 8,
36
+ borderWidth: 1,
37
+ borderColor: '#ddd'
38
+ }}
39
+ >
40
+ <Span>Anchor</Span>
41
+ </Div>
42
+ </Div>
43
+
44
+ <AbstractPopover
45
+ anchorRef={anchorRef}
46
+ visible={visible}
47
+ arrow
48
+ position='bottom'
49
+ attachment='start'
50
+ style={{
51
+ backgroundColor: 'white',
52
+ padding: 10,
53
+ borderRadius: 8,
54
+ borderWidth: 1,
55
+ borderColor: '#ddd'
56
+ }}
57
+ >
58
+ <Span>Popover content</Span>
59
+ </AbstractPopover>
60
+ </Div>
61
+ )
62
+ ```
63
+
64
+ ## Matching anchor width
65
+
66
+ ```jsx example
67
+ const anchorRef = useRef()
68
+ const [visible, setVisible] = useState(false)
69
+
70
+ return (
71
+ <Div style={{ padding: 20, backgroundColor: '#f6f7f9' }}>
72
+ <Div
73
+ ref={anchorRef}
74
+ style={{
75
+ width: 240,
76
+ padding: 10,
77
+ backgroundColor: 'white',
78
+ borderRadius: 8,
79
+ borderWidth: 1,
80
+ borderColor: '#ddd'
81
+ }}
82
+ >
83
+ <Span>Anchor (240px)</Span>
84
+ <Button pushed onPress={() => setVisible(v => !v)}>
85
+ Toggle
86
+ </Button>
87
+ </Div>
88
+
89
+ <AbstractPopover
90
+ anchorRef={anchorRef}
91
+ visible={visible}
92
+ matchAnchorWidth
93
+ position='bottom'
94
+ attachment='start'
95
+ style={{
96
+ backgroundColor: 'white',
97
+ padding: 10,
98
+ borderRadius: 8,
99
+ borderWidth: 1,
100
+ borderColor: '#ddd'
101
+ }}
102
+ >
103
+ <Span>Width matches anchor</Span>
104
+ </AbstractPopover>
105
+ </Div>
106
+ )
107
+ ```
108
+
109
+ ## Custom fallback placements
110
+
111
+ ```jsx example
112
+ const anchorRef = useRef()
113
+ const [visible, setVisible] = useState(false)
114
+
115
+ return (
116
+ <Div style={{ padding: 20, backgroundColor: '#f6f7f9' }}>
117
+ <Div ref={anchorRef} style={{ padding: 10, backgroundColor: 'white', borderRadius: 8 }}>
118
+ <Span>Anchor</Span>
119
+ <Button pushed onPress={() => setVisible(v => !v)}>
120
+ Toggle
121
+ </Button>
122
+ </Div>
123
+
124
+ <AbstractPopover
125
+ anchorRef={anchorRef}
126
+ visible={visible}
127
+ position='top'
128
+ attachment='center'
129
+ placements={['top-center', 'bottom-center']}
130
+ arrow
131
+ style={{
132
+ backgroundColor: 'white',
133
+ padding: 10,
134
+ borderRadius: 8,
135
+ borderWidth: 1,
136
+ borderColor: '#ddd'
137
+ }}
138
+ >
139
+ <Span>Tries top-center, then bottom-center</Span>
140
+ </AbstractPopover>
141
+ </Div>
142
+ )
143
+ ```
144
+
145
+ ## Sandbox
146
+
147
+ <Sandbox
148
+ Component={AbstractPopover}
149
+ propsJsonSchema={AbstractPopoverPropsJsonSchema}
150
+ props={{
151
+ anchorRef: { current: { measure: (cb) => cb(0, 0, 120, 32, 40, 120) } },
152
+ style: { backgroundColor: 'white', padding: 10, borderRadius: 8, borderWidth: 1, borderColor: '#ddd' },
153
+ arrow: true,
154
+ children: <Span>Sandbox content</Span>
155
+ }}
156
+ />
package/constants.ts ADDED
@@ -0,0 +1,35 @@
1
+ export const POPOVER_MARGIN = 8 as const
2
+ export const ARROW_SIZE = 8 as const
3
+
4
+ export type AbstractPopoverPosition = 'top' | 'bottom' | 'left' | 'right'
5
+ export type AbstractPopoverAttachment = 'start' | 'center' | 'end'
6
+ export type AbstractPopoverPlacement = `${AbstractPopoverPosition}-${AbstractPopoverAttachment}`
7
+
8
+ export const STEPS = {
9
+ CLOSE: 'close',
10
+ MEASURE: 'measure',
11
+ RENDER: 'render',
12
+ OPEN: 'open'
13
+ } as const
14
+
15
+ export const PLACEMENTS_ORDER = [
16
+ 'top-start',
17
+ 'top-center',
18
+ 'top-end',
19
+ 'right-start',
20
+ 'right-center',
21
+ 'right-end',
22
+ 'bottom-end',
23
+ 'bottom-center',
24
+ 'bottom-start',
25
+ 'left-end',
26
+ 'left-center',
27
+ 'left-start'
28
+ ] as const satisfies readonly AbstractPopoverPlacement[]
29
+
30
+ export const POSITIONS_REVERSE = {
31
+ top: 'bottom',
32
+ bottom: 'top',
33
+ left: 'right',
34
+ right: 'left'
35
+ } as const
package/getGeometry.ts ADDED
@@ -0,0 +1,459 @@
1
+ import { Dimensions } from 'react-native'
2
+ import {
3
+ ARROW_SIZE,
4
+ PLACEMENTS_ORDER,
5
+ POPOVER_MARGIN,
6
+ POSITIONS_REVERSE,
7
+ type AbstractPopoverAttachment as Attachment,
8
+ type AbstractPopoverPlacement as Placement,
9
+ type AbstractPopoverPosition as Position
10
+ } from './constants'
11
+
12
+ interface Measures {
13
+ width: number
14
+ height: number
15
+ }
16
+
17
+ interface LayoutMeasures {
18
+ x: number
19
+ y: number
20
+ width: number
21
+ height: number
22
+ }
23
+
24
+ interface AnchorMeasures extends LayoutMeasures {
25
+ pageX: number
26
+ pageY: number
27
+ }
28
+
29
+ export interface PopoverGeometry {
30
+ placement: Placement
31
+ position: Position
32
+ attachment: Attachment
33
+ top: number
34
+ left: number
35
+ arrowTop: number | string
36
+ arrowLeft: number | string
37
+ width: number
38
+ }
39
+
40
+ export default function getGeometry (params: {
41
+ placement: Placement
42
+ placements: readonly Placement[]
43
+ arrow?: boolean
44
+ matchAnchorWidth?: boolean
45
+ dimensions: Measures
46
+ tetherMeasures: LayoutMeasures & Measures
47
+ anchorMeasures: AnchorMeasures
48
+ }): PopoverGeometry {
49
+ const {
50
+ placement,
51
+ placements,
52
+ matchAnchorWidth,
53
+ tetherMeasures,
54
+ anchorMeasures
55
+ } = params
56
+ const topPositions = getTopPositions(params)
57
+ const leftPositions = getLeftPositions(params)
58
+ const arrowTopPositions = getTopPositionsArrow(params)
59
+ const arrowLeftPositions = getLeftPositionsArrow(params)
60
+ const preparedPlacements = preparePlacements({ placement, placements })
61
+ const currentPlacement = findAvailablePlacement({
62
+ tetherMeasures,
63
+ placement,
64
+ placements: preparedPlacements,
65
+ leftPositions,
66
+ topPositions,
67
+ initPlacement: placement
68
+ })
69
+
70
+ const [currentPosition, currentAttachment] = currentPlacement.split('-') as [Position, Attachment]
71
+
72
+ const geometry = {
73
+ placement: currentPlacement,
74
+ position: currentPosition,
75
+ attachment: currentAttachment,
76
+ top: topPositions[currentPlacement],
77
+ left: leftPositions[currentPlacement],
78
+ arrowTop: arrowTopPositions[currentPlacement],
79
+ arrowLeft: arrowLeftPositions[currentPlacement],
80
+ width: matchAnchorWidth ? anchorMeasures.width : tetherMeasures.width
81
+ }
82
+
83
+ return geometry
84
+ }
85
+
86
+ function getLeftPositions ({
87
+ tetherMeasures,
88
+ anchorMeasures,
89
+ arrow,
90
+ dimensions
91
+ }: {
92
+ tetherMeasures: Measures
93
+ anchorMeasures: AnchorMeasures
94
+ arrow?: boolean
95
+ dimensions: Measures
96
+ }): Record<Placement, number> {
97
+ const leftPositions: Record<string, number> = {}
98
+
99
+ const halfContent = tetherMeasures.width / 2
100
+ const halfCaption = anchorMeasures.width / 2
101
+
102
+ const overRight = dimensions.width - tetherMeasures.width
103
+
104
+ const positionMinorLeft = anchorMeasures.pageX
105
+ if (positionMinorLeft + tetherMeasures.width > dimensions.width) {
106
+ leftPositions['bottom-start'] = overRight
107
+ leftPositions['top-start'] = overRight
108
+ } else {
109
+ leftPositions['bottom-start'] = positionMinorLeft
110
+ leftPositions['top-start'] = positionMinorLeft
111
+ }
112
+
113
+ const positionMinorCenter = anchorMeasures.pageX - halfContent + halfCaption
114
+ if (positionMinorCenter < 0) {
115
+ leftPositions['top-center'] = 0
116
+ leftPositions['bottom-center'] = 0
117
+ } else if (positionMinorCenter + tetherMeasures.width > dimensions.width) {
118
+ leftPositions['top-center'] = overRight
119
+ leftPositions['bottom-center'] = overRight
120
+ } else {
121
+ leftPositions['top-center'] = positionMinorCenter
122
+ leftPositions['bottom-center'] = positionMinorCenter
123
+ }
124
+
125
+ const positionMinorRight = anchorMeasures.pageX - tetherMeasures.width + anchorMeasures.width
126
+ if (positionMinorRight < 0) {
127
+ leftPositions['bottom-end'] = 0
128
+ leftPositions['top-end'] = 0
129
+ } else {
130
+ leftPositions['bottom-end'] = positionMinorRight
131
+ leftPositions['top-end'] = positionMinorRight
132
+ }
133
+
134
+ const positionRootLeft = anchorMeasures.pageX - tetherMeasures.width - (arrow ? ARROW_SIZE + POPOVER_MARGIN : POPOVER_MARGIN)
135
+ leftPositions['left-start'] = positionRootLeft
136
+ leftPositions['left-center'] = positionRootLeft
137
+ leftPositions['left-end'] = positionRootLeft
138
+
139
+ const positionRootRight = anchorMeasures.pageX + anchorMeasures.width + (arrow ? ARROW_SIZE + POPOVER_MARGIN : POPOVER_MARGIN)
140
+ leftPositions['right-start'] = positionRootRight
141
+ leftPositions['right-center'] = positionRootRight
142
+ leftPositions['right-end'] = positionRootRight
143
+
144
+ return leftPositions
145
+ }
146
+
147
+ function getTopPositions ({
148
+ anchorMeasures,
149
+ tetherMeasures,
150
+ arrow,
151
+ dimensions
152
+ }: {
153
+ tetherMeasures: Measures
154
+ anchorMeasures: AnchorMeasures
155
+ arrow?: boolean
156
+ dimensions: Measures
157
+ }): Record<Placement, number> {
158
+ const topPositions: Record<string, number> = {}
159
+
160
+ const halfCaption = anchorMeasures.height / 2
161
+ const halfContent = tetherMeasures.height / 2
162
+
163
+ const positionRootBottom = anchorMeasures.pageY + anchorMeasures.height + (arrow ? ARROW_SIZE + POPOVER_MARGIN : POPOVER_MARGIN)
164
+ topPositions['bottom-start'] = positionRootBottom
165
+ topPositions['bottom-center'] = positionRootBottom
166
+ topPositions['bottom-end'] = positionRootBottom
167
+
168
+ const positionRootTop = anchorMeasures.pageY - tetherMeasures.height - (arrow ? ARROW_SIZE + POPOVER_MARGIN : POPOVER_MARGIN)
169
+ topPositions['top-start'] = positionRootTop
170
+ topPositions['top-center'] = positionRootTop
171
+ topPositions['top-end'] = positionRootTop
172
+
173
+ const positionMinorCenter = anchorMeasures.pageY + halfCaption - halfContent
174
+ if (positionMinorCenter < 0) {
175
+ topPositions['left-center'] = 0
176
+ topPositions['right-center'] = 0
177
+ } else if (anchorMeasures.pageY + halfContent > dimensions.height) {
178
+ const positionMinorCenterOverBottom = dimensions.height - tetherMeasures.height
179
+ topPositions['left-center'] = positionMinorCenterOverBottom
180
+ topPositions['right-center'] = positionMinorCenterOverBottom
181
+ } else {
182
+ topPositions['left-center'] = positionMinorCenter
183
+ topPositions['right-center'] = positionMinorCenter
184
+ }
185
+
186
+ if (anchorMeasures.pageY + tetherMeasures.height > dimensions.height) {
187
+ const positionMinorStartOver = dimensions.height - tetherMeasures.height
188
+ topPositions['left-start'] = positionMinorStartOver
189
+ topPositions['right-start'] = positionMinorStartOver
190
+ } else {
191
+ topPositions['left-start'] = anchorMeasures.pageY
192
+ topPositions['right-start'] = anchorMeasures.pageY
193
+ }
194
+
195
+ if (anchorMeasures.pageY + anchorMeasures.height - tetherMeasures.height < 0) {
196
+ topPositions['left-end'] = 0
197
+ topPositions['right-end'] = 0
198
+ } else {
199
+ const positionMinorEnd = anchorMeasures.pageY + anchorMeasures.height - tetherMeasures.height
200
+ topPositions['left-end'] = positionMinorEnd
201
+ topPositions['right-end'] = positionMinorEnd
202
+ }
203
+
204
+ return topPositions
205
+ }
206
+
207
+ function getLeftPositionsArrow ({
208
+ tetherMeasures,
209
+ anchorMeasures,
210
+ dimensions
211
+ }: {
212
+ tetherMeasures: Measures
213
+ anchorMeasures: AnchorMeasures
214
+ dimensions: Measures
215
+ }): Record<Placement, number | string> {
216
+ const arrowLeftPositions: Record<string, number | string> = {}
217
+
218
+ const halfContent = tetherMeasures.width / 2
219
+ const halfCaption = anchorMeasures.width / 2
220
+
221
+ const overLeft = anchorMeasures.pageX + halfCaption - ARROW_SIZE
222
+ const overRight = tetherMeasures.width - (dimensions.width - anchorMeasures.pageX) + halfCaption - ARROW_SIZE
223
+ const minRight = tetherMeasures.width - (ARROW_SIZE * 3)
224
+
225
+ if (anchorMeasures.pageX + tetherMeasures.width > dimensions.width) {
226
+ if (anchorMeasures.pageX + anchorMeasures.width > dimensions.width) {
227
+ arrowLeftPositions['top-start'] = minRight
228
+ arrowLeftPositions['bottom-start'] = minRight
229
+ } else {
230
+ arrowLeftPositions['top-start'] = overRight
231
+ arrowLeftPositions['bottom-start'] = overRight
232
+ }
233
+ } else {
234
+ arrowLeftPositions['top-start'] = ARROW_SIZE
235
+ arrowLeftPositions['bottom-start'] = ARROW_SIZE
236
+ }
237
+
238
+ if (anchorMeasures.pageX - tetherMeasures.width + anchorMeasures.width < 0) {
239
+ if (anchorMeasures.pageX < 0) {
240
+ arrowLeftPositions['top-end'] = ARROW_SIZE
241
+ arrowLeftPositions['bottom-end'] = ARROW_SIZE
242
+ } else {
243
+ arrowLeftPositions['top-end'] = overLeft
244
+ arrowLeftPositions['bottom-end'] = overLeft
245
+ }
246
+ } else {
247
+ const positionEnd = tetherMeasures.width - (ARROW_SIZE * 3)
248
+ arrowLeftPositions['top-end'] = positionEnd
249
+ arrowLeftPositions['bottom-end'] = positionEnd
250
+ }
251
+
252
+ if (anchorMeasures.pageX - halfContent + halfCaption < 0) {
253
+ if (anchorMeasures.pageX < 0) {
254
+ arrowLeftPositions['top-center'] = ARROW_SIZE
255
+ arrowLeftPositions['bottom-center'] = ARROW_SIZE
256
+ } else {
257
+ arrowLeftPositions['top-center'] = overLeft
258
+ arrowLeftPositions['bottom-center'] = overLeft
259
+ }
260
+ } else if (anchorMeasures.pageX + halfContent > dimensions.width) {
261
+ if (anchorMeasures.pageX + anchorMeasures.width > dimensions.width) {
262
+ arrowLeftPositions['top-center'] = minRight
263
+ arrowLeftPositions['bottom-center'] = minRight
264
+ } else {
265
+ arrowLeftPositions['top-center'] = overRight
266
+ arrowLeftPositions['bottom-center'] = overRight
267
+ }
268
+ } else {
269
+ const positionCenter = halfContent - ARROW_SIZE
270
+ arrowLeftPositions['top-center'] = positionCenter
271
+ arrowLeftPositions['bottom-center'] = positionCenter
272
+ }
273
+
274
+ arrowLeftPositions['left-center'] = '100%'
275
+ arrowLeftPositions['left-start'] = '100%'
276
+ arrowLeftPositions['left-end'] = '100%'
277
+
278
+ const dblArrow = ARROW_SIZE * 2
279
+ arrowLeftPositions['right-start'] = -dblArrow
280
+ arrowLeftPositions['right-center'] = -dblArrow
281
+ arrowLeftPositions['right-end'] = -dblArrow
282
+
283
+ return arrowLeftPositions
284
+ }
285
+
286
+ function getTopPositionsArrow ({
287
+ tetherMeasures,
288
+ anchorMeasures,
289
+ dimensions
290
+ }: {
291
+ tetherMeasures: Measures
292
+ anchorMeasures: AnchorMeasures
293
+ dimensions: Measures
294
+ }): Record<Placement, number> {
295
+ const arrowTopPositions: Record<string, number> = {}
296
+
297
+ const halfCaption = anchorMeasures.height / 2
298
+ const halfContent = tetherMeasures.height / 2
299
+
300
+ const overTop = anchorMeasures.pageX + halfCaption - ARROW_SIZE
301
+ const overBottom = tetherMeasures.height - (dimensions.height - anchorMeasures.pageX) + halfCaption - ARROW_SIZE
302
+ const minBottom = tetherMeasures.height - (ARROW_SIZE * 3)
303
+
304
+ if (anchorMeasures.pageX + halfCaption - halfContent < 0) {
305
+ if (anchorMeasures.pageX < 0) {
306
+ arrowTopPositions['left-center'] = ARROW_SIZE
307
+ arrowTopPositions['right-center'] = ARROW_SIZE
308
+ } else {
309
+ arrowTopPositions['left-center'] = overTop
310
+ arrowTopPositions['right-center'] = overTop
311
+ }
312
+ } else if (anchorMeasures.pageX + halfContent > dimensions.height) {
313
+ if (anchorMeasures.pageX + anchorMeasures.height > dimensions.height) {
314
+ arrowTopPositions['left-center'] = minBottom
315
+ arrowTopPositions['right-center'] = minBottom
316
+ } else {
317
+ arrowTopPositions['left-center'] = overBottom
318
+ arrowTopPositions['right-center'] = overBottom
319
+ }
320
+ } else {
321
+ const positionCenter = halfContent - ARROW_SIZE
322
+ arrowTopPositions['left-center'] = positionCenter
323
+ arrowTopPositions['right-center'] = positionCenter
324
+ }
325
+
326
+ if (anchorMeasures.pageX + tetherMeasures.height > dimensions.height) {
327
+ if (anchorMeasures.pageX + anchorMeasures.height > dimensions.height) {
328
+ arrowTopPositions['left-start'] = minBottom
329
+ arrowTopPositions['right-start'] = minBottom
330
+ } else {
331
+ arrowTopPositions['left-start'] = overBottom
332
+ arrowTopPositions['right-start'] = overBottom
333
+ }
334
+ } else {
335
+ const positionStart = ARROW_SIZE
336
+ arrowTopPositions['left-start'] = positionStart
337
+ arrowTopPositions['right-start'] = positionStart
338
+ }
339
+
340
+ if (anchorMeasures.pageX + anchorMeasures.height - tetherMeasures.height < 0) {
341
+ if (anchorMeasures.pageX < 0) {
342
+ arrowTopPositions['left-end'] = ARROW_SIZE
343
+ arrowTopPositions['right-end'] = ARROW_SIZE
344
+ } else {
345
+ arrowTopPositions['left-end'] = overTop
346
+ arrowTopPositions['right-end'] = overTop
347
+ }
348
+ } else {
349
+ const positionEnd = tetherMeasures.height - (ARROW_SIZE * 3)
350
+ arrowTopPositions['left-end'] = positionEnd
351
+ arrowTopPositions['right-end'] = positionEnd
352
+ }
353
+
354
+ arrowTopPositions['top-start'] = tetherMeasures.height
355
+ arrowTopPositions['top-center'] = tetherMeasures.height
356
+ arrowTopPositions['top-end'] = tetherMeasures.height
357
+
358
+ const dblArrow = ARROW_SIZE * 2
359
+ arrowTopPositions['bottom-start'] = -dblArrow
360
+ arrowTopPositions['bottom-center'] = -dblArrow
361
+ arrowTopPositions['bottom-end'] = -dblArrow
362
+
363
+ return arrowTopPositions
364
+ }
365
+
366
+ // TODO: This is unlogical behaviour.
367
+ // We can always find a better position if the specified position does not fit
368
+ function preparePlacements ({
369
+ placement,
370
+ placements
371
+ }: {
372
+ placement: Placement
373
+ placements: readonly Placement[]
374
+ }): Placement[] {
375
+ if (placements.length !== PLACEMENTS_ORDER.length) {
376
+ return PLACEMENTS_ORDER.filter(item => {
377
+ return placements.includes(item as Placement)
378
+ })
379
+ }
380
+
381
+ let _preparePlacements: Placement[] = [...PLACEMENTS_ORDER]
382
+
383
+ const activeIndexPlacement = _preparePlacements.findIndex(item => {
384
+ return item === placement
385
+ })
386
+
387
+ const activePlacement = _preparePlacements[activeIndexPlacement] ?? placement
388
+ const [position, attachment] = activePlacement.split('-') as [Position, Attachment]
389
+ const reversePlacement = `${POSITIONS_REVERSE[position]}-${attachment}` as Placement
390
+ const reverseIndexPlacement = _preparePlacements.findIndex(item => {
391
+ return item === reversePlacement
392
+ })
393
+ _preparePlacements = _preparePlacements.filter(item => {
394
+ return item !== reversePlacement
395
+ })
396
+
397
+ _preparePlacements = [
398
+ ..._preparePlacements.slice(0, activeIndexPlacement),
399
+ (PLACEMENTS_ORDER[reverseIndexPlacement] ?? reversePlacement) as Placement,
400
+ ..._preparePlacements.slice(activeIndexPlacement)
401
+ ]
402
+
403
+ return _preparePlacements
404
+ }
405
+
406
+ // TODO: This is unlogical behaviour.
407
+ // We can always find a better position if the specified position does not fit
408
+ function findAvailablePlacement (options: {
409
+ counter?: number
410
+ tetherMeasures: Measures
411
+ placement: Placement
412
+ placements: Placement[]
413
+ leftPositions: Record<Placement, number>
414
+ topPositions: Record<Placement, number>
415
+ initPlacement: Placement
416
+ }): Placement {
417
+ const {
418
+ counter = 1,
419
+ tetherMeasures,
420
+ placement,
421
+ placements,
422
+ leftPositions,
423
+ topPositions,
424
+ initPlacement
425
+ } = options
426
+
427
+ if (counter > placements.length) {
428
+ return initPlacement
429
+ }
430
+
431
+ const activeIndexPlacement = placements.findIndex(item => {
432
+ return item === placement
433
+ })
434
+
435
+ let nextIndex = activeIndexPlacement + 1
436
+ if (nextIndex > placements.length - 1) nextIndex = 0
437
+
438
+ const _leftPosition = leftPositions[placement]
439
+ if (_leftPosition < 0 ||
440
+ _leftPosition + tetherMeasures.width > Dimensions.get('window').width) {
441
+ return findAvailablePlacement({
442
+ ...options,
443
+ counter: counter + 1,
444
+ placement: placements[nextIndex]
445
+ })
446
+ }
447
+
448
+ const _topPosition = topPositions[placement]
449
+ if (_topPosition < 0 ||
450
+ _topPosition + tetherMeasures.height > Dimensions.get('window').height) {
451
+ return findAvailablePlacement({
452
+ ...options,
453
+ counter: counter + 1,
454
+ placement: placements[nextIndex]
455
+ })
456
+ }
457
+
458
+ return placements[activeIndexPlacement] ?? initPlacement
459
+ }
@@ -0,0 +1,18 @@
1
+ .root
2
+ position absolute
3
+ z-index 1000
4
+
5
+ .arrow
6
+ position absolute
7
+ border-width 1u
8
+ border-style solid
9
+ border-color transparent
10
+
11
+ &.left
12
+ transform rotate(-90deg)
13
+
14
+ &.right
15
+ transform rotate(90deg)
16
+
17
+ &.bottom
18
+ transform rotate(180deg)
package/index.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /* eslint-disable */
2
+ // DO NOT MODIFY THIS FILE - IT IS AUTOMATICALLY GENERATED ON COMMITS.
3
+
4
+ import { type ReactNode } from 'react';
5
+ import { type StyleProp, type ViewStyle } from 'react-native';
6
+ import { type AbstractPopoverAttachment as Attachment, type AbstractPopoverPlacement as Placement, type AbstractPopoverPosition as Position } from './constants';
7
+ import './index.cssx.styl';
8
+ export declare const _PropsJsonSchema: {};
9
+ export interface AbstractPopoverProps {
10
+ /** Additional cssx styleName(s) for the popover root */
11
+ styleName?: any;
12
+ /** Custom styles for popover container */
13
+ style?: StyleProp<ViewStyle>;
14
+ /** Ref to the anchor element (must support `measure`) */
15
+ anchorRef: any;
16
+ /** Show/hide popover */
17
+ visible?: boolean;
18
+ /** Primary position @default 'bottom' */
19
+ position?: Position;
20
+ /** Attachment relative to anchor @default 'start' */
21
+ attachment?: Attachment;
22
+ /** Ordered placements fallback list */
23
+ placements?: readonly Placement[];
24
+ /** Show arrow */
25
+ arrow?: boolean;
26
+ /** Match popover width to anchor */
27
+ matchAnchorWidth?: boolean;
28
+ /** Open animation duration (ms) @default 100 */
29
+ durationOpen?: number;
30
+ /** Close animation duration (ms) @default 50 */
31
+ durationClose?: number;
32
+ /** Wrap rendered popover node */
33
+ renderWrapper?: (node: ReactNode) => ReactNode;
34
+ /** Called right before open animation starts */
35
+ onRequestOpen?: () => void;
36
+ /** Called right before close animation starts */
37
+ onRequestClose?: () => void;
38
+ /** Called after open animation ends */
39
+ onOpenComplete?: (finished?: boolean) => void;
40
+ /** Called after close animation ends */
41
+ onCloseComplete?: (finished?: boolean) => void;
42
+ /** Popover content */
43
+ children?: ReactNode;
44
+ }
45
+ declare const _default: import("react").ComponentType<AbstractPopoverProps>;
46
+ export default _default;
package/index.tsx ADDED
@@ -0,0 +1,230 @@
1
+ import { useState, useCallback, useEffect, useRef, useMemo, type ReactNode } from 'react'
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ Dimensions,
6
+ StyleSheet,
7
+ View,
8
+ type LayoutChangeEvent,
9
+ type StyleProp,
10
+ type ViewStyle
11
+ } from 'react-native'
12
+ import { pug, observer } from 'startupjs'
13
+ import { themed } from '@startupjs-ui/core'
14
+ import Portal from '@startupjs-ui/portal'
15
+ import getGeometry, { type PopoverGeometry } from './getGeometry'
16
+ import {
17
+ PLACEMENTS_ORDER,
18
+ type AbstractPopoverAttachment as Attachment,
19
+ type AbstractPopoverPlacement as Placement,
20
+ type AbstractPopoverPosition as Position
21
+ } from './constants'
22
+ import './index.cssx.styl'
23
+
24
+ export const _PropsJsonSchema = {/* AbstractPopoverProps */}
25
+
26
+ export interface AbstractPopoverProps {
27
+ /** Additional cssx styleName(s) for the popover root */
28
+ styleName?: any
29
+ /** Custom styles for popover container */
30
+ style?: StyleProp<ViewStyle>
31
+ /** Ref to the anchor element (must support `measure`) */
32
+ anchorRef: any
33
+ /** Show/hide popover */
34
+ visible?: boolean
35
+ /** Primary position @default 'bottom' */
36
+ position?: Position
37
+ /** Attachment relative to anchor @default 'start' */
38
+ attachment?: Attachment
39
+ /** Ordered placements fallback list */
40
+ placements?: readonly Placement[]
41
+ /** Show arrow */
42
+ arrow?: boolean
43
+ /** Match popover width to anchor */
44
+ matchAnchorWidth?: boolean
45
+ /** Open animation duration (ms) @default 100 */
46
+ durationOpen?: number
47
+ /** Close animation duration (ms) @default 50 */
48
+ durationClose?: number
49
+ /** Wrap rendered popover node */
50
+ renderWrapper?: (node: ReactNode) => ReactNode
51
+ /** Called right before open animation starts */
52
+ onRequestOpen?: () => void
53
+ /** Called right before close animation starts */
54
+ onRequestClose?: () => void
55
+ /** Called after open animation ends */
56
+ onOpenComplete?: (finished?: boolean) => void
57
+ /** Called after close animation ends */
58
+ onCloseComplete?: (finished?: boolean) => void
59
+ /** Popover content */
60
+ children?: ReactNode
61
+ }
62
+
63
+ function AbstractPopover ({
64
+ visible: visibleProp,
65
+ onCloseComplete,
66
+ position = 'bottom',
67
+ attachment = 'start',
68
+ placements = PLACEMENTS_ORDER,
69
+ arrow = false,
70
+ matchAnchorWidth = false,
71
+ durationOpen = 100,
72
+ durationClose = 50,
73
+ ...props
74
+ }: AbstractPopoverProps): ReactNode {
75
+ const [visible, setVisible] = useState(false)
76
+
77
+ useEffect(() => {
78
+ if (visibleProp) setVisible(true)
79
+ }, [visibleProp])
80
+
81
+ const handleCloseComplete = useCallback((finished?: boolean) => {
82
+ setVisible(false)
83
+ onCloseComplete?.(finished)
84
+ }, [onCloseComplete])
85
+
86
+ if (!visible) return null
87
+
88
+ return pug`
89
+ Tether(
90
+ ...props
91
+ visible=visibleProp
92
+ position=position
93
+ attachment=attachment
94
+ placements=placements
95
+ arrow=arrow
96
+ matchAnchorWidth=matchAnchorWidth
97
+ durationOpen=durationOpen
98
+ durationClose=durationClose
99
+ onCloseComplete=handleCloseComplete
100
+ )
101
+ `
102
+ }
103
+
104
+ const Tether = observer(function TetherComponent ({
105
+ styleName,
106
+ style,
107
+ anchorRef,
108
+ visible,
109
+ position = 'bottom',
110
+ attachment = 'start',
111
+ // IDEA: Is this property is redundant?
112
+ // We can always find a better position if the specified position does not fit
113
+ // Also we can use the same logic like in tether.io
114
+ placements = PLACEMENTS_ORDER,
115
+ arrow = false,
116
+ matchAnchorWidth = false,
117
+ durationOpen = 100,
118
+ durationClose = 50,
119
+ renderWrapper,
120
+ onRequestOpen,
121
+ onRequestClose,
122
+ onOpenComplete,
123
+ onCloseComplete,
124
+ children
125
+ }: AbstractPopoverProps): ReactNode {
126
+ const flattenedStyle = StyleSheet.flatten(style) as ViewStyle | undefined
127
+ if (!renderWrapper) renderWrapper = (node): ReactNode => node
128
+
129
+ const [geometry, setGeometry] = useState<PopoverGeometry>()
130
+ const fadeAnim = useRef(new Animated.Value(0)).current
131
+ const dimensions = useMemo(() => ({
132
+ width: Dimensions.get('window').width,
133
+ height: Dimensions.get('window').height
134
+ }), [])
135
+
136
+ const animateIn = useCallback(() => {
137
+ onRequestOpen?.()
138
+
139
+ Animated.parallel([
140
+ Animated.timing(fadeAnim, {
141
+ toValue: 1,
142
+ duration: durationOpen,
143
+ easing: Easing.linear,
144
+ useNativeDriver: true
145
+ })
146
+ ]).start(({ finished }) => {
147
+ onOpenComplete?.(finished)
148
+ })
149
+ }, [durationOpen, fadeAnim, onOpenComplete, onRequestOpen])
150
+
151
+ const animateOut = useCallback(() => {
152
+ onRequestClose?.()
153
+
154
+ Animated.timing(fadeAnim, {
155
+ toValue: 0,
156
+ duration: durationClose,
157
+ easing: Easing.linear,
158
+ useNativeDriver: true
159
+ }).start(({ finished }) => {
160
+ onCloseComplete?.(finished)
161
+ })
162
+ }, [durationClose, fadeAnim, onCloseComplete, onRequestClose])
163
+
164
+ useEffect(() => {
165
+ if (!geometry) return
166
+ if (visible) animateIn()
167
+ else animateOut()
168
+ }, [animateIn, animateOut, geometry, visible])
169
+
170
+ const calculateGeometry = useCallback(({ nativeEvent }: LayoutChangeEvent) => {
171
+ // IDEA: we can pass measures to this component
172
+ // instead of passing ref for the measurement
173
+ // Also, add property that will manage where popover should appear
174
+ // in portal or in at the place where component is called
175
+ // maybe use PortalProvider to render it in the place where component is called
176
+ anchorRef?.current?.measure?.((x: number, y: number, width: number, height: number, pageX: number, pageY: number) => {
177
+ // IDEA: rewrite getGeometry in future
178
+ // we can make geometry behaviout like in tether.js
179
+ const geometry = getGeometry({
180
+ placement: `${position}-${attachment}` as Placement,
181
+ placements,
182
+ arrow,
183
+ matchAnchorWidth,
184
+ dimensions,
185
+ tetherMeasures: nativeEvent.layout,
186
+ anchorMeasures: { x, y, width, height, pageX, pageY }
187
+ })
188
+ setGeometry(geometry)
189
+ })
190
+ }, [anchorRef, arrow, attachment, dimensions, matchAnchorWidth, placements, position])
191
+
192
+ // WORKAROUND
193
+ // the minimum height fixes an issue where the 'onLayout' does not trigger
194
+ // when children are undefined or have no size.
195
+ const rootStyle: any = {
196
+ top: geometry ? geometry.top : -99999,
197
+ left: geometry ? geometry.left : -99999,
198
+ opacity: fadeAnim,
199
+ minHeight: 1
200
+ }
201
+
202
+ if (geometry) {
203
+ rootStyle.width = geometry.width
204
+ }
205
+
206
+ const popover = pug`
207
+ Animated.View.root(
208
+ style=[flattenedStyle, rootStyle]
209
+ styleName=styleName
210
+ onLayout=calculateGeometry
211
+ )
212
+ if arrow && !!geometry
213
+ View.arrow(
214
+ style={
215
+ borderTopColor: flattenedStyle?.backgroundColor,
216
+ left: geometry.arrowLeft,
217
+ top: geometry.arrowTop
218
+ }
219
+ styleName=[geometry.position]
220
+ )
221
+ = children
222
+ `
223
+
224
+ return pug`
225
+ Portal
226
+ = renderWrapper(popover)
227
+ `
228
+ })
229
+
230
+ export default observer(themed('AbstractPopover', AbstractPopover))
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@startupjs-ui/abstract-popover",
3
+ "version": "0.1.3",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "main": "index.tsx",
8
+ "types": "index.d.ts",
9
+ "type": "module",
10
+ "dependencies": {
11
+ "@startupjs-ui/core": "^0.1.3",
12
+ "@startupjs-ui/portal": "^0.1.3"
13
+ },
14
+ "peerDependencies": {
15
+ "react": "*",
16
+ "react-native": "*",
17
+ "startupjs": "*"
18
+ },
19
+ "gitHead": "fd964ebc3892d3dd0a6c85438c0af619cc50c3f0"
20
+ }