@startupjs-ui/dropdown 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 +20 -0
- package/README.mdx +91 -0
- package/components/Caption/index.cssx.styl +17 -0
- package/components/Caption/index.tsx +48 -0
- package/components/Item/index.cssx.styl +47 -0
- package/components/Item/index.tsx +117 -0
- package/helpers/index.ts +1 -0
- package/helpers/useKeyboard.ts +76 -0
- package/index.cssx.styl +49 -0
- package/index.d.ts +50 -0
- package/index.tsx +313 -0
- package/package.json +27 -0
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/dropdown
|
|
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
|
+
* 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))
|
|
20
|
+
* **dropdown:** refactor Dropdown component ([4305c16](https://github.com/startupjs/startupjs-ui/commit/4305c1606c9b26ae4746932b53dcb91034f713a5))
|
package/README.mdx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import Dropdown, { _PropsJsonSchema as DropdownPropsJsonSchema } from './index'
|
|
4
|
+
import Icon from '@startupjs-ui/icon'
|
|
5
|
+
import { Sandbox } from '@startupjs-ui/docs'
|
|
6
|
+
import {
|
|
7
|
+
faEllipsisV,
|
|
8
|
+
faPencilAlt,
|
|
9
|
+
faTrashAlt
|
|
10
|
+
} from '@fortawesome/free-solid-svg-icons'
|
|
11
|
+
|
|
12
|
+
# Dropdown
|
|
13
|
+
|
|
14
|
+
Pop-up menus. Adaptable depending on the extension.
|
|
15
|
+
|
|
16
|
+
```jsx
|
|
17
|
+
import { Dropdown } from 'startupjs-ui'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Simple example
|
|
21
|
+
|
|
22
|
+
```jsx example
|
|
23
|
+
const [sort, setSort] = useState('')
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Dropdown value={sort} onChange={v => setSort(v)}>
|
|
27
|
+
<Dropdown.Caption placeholder="Sort by" />
|
|
28
|
+
<Dropdown.Item value="popular" label="Popular" />
|
|
29
|
+
<Dropdown.Item value="brand" label="Brand" />
|
|
30
|
+
<Dropdown.Item value="name" label="Name" />
|
|
31
|
+
</Dropdown>
|
|
32
|
+
)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Custom caption
|
|
36
|
+
|
|
37
|
+
```jsx example
|
|
38
|
+
const [sort, setSort] = useState('')
|
|
39
|
+
|
|
40
|
+
const captionStyle = {
|
|
41
|
+
width: 30,
|
|
42
|
+
height: 30,
|
|
43
|
+
backgroundColor: 'white',
|
|
44
|
+
borderRadius: 50,
|
|
45
|
+
justifyContent: 'center',
|
|
46
|
+
alignItems: 'center'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Dropdown value={sort} onChange={v => setSort(v)}>
|
|
51
|
+
<Dropdown.Caption>
|
|
52
|
+
<View style={captionStyle}>
|
|
53
|
+
<Icon icon={faEllipsisV} />
|
|
54
|
+
</View>
|
|
55
|
+
</Dropdown.Caption>
|
|
56
|
+
<Dropdown.Item value="popular" label="Popular" />
|
|
57
|
+
<Dropdown.Item value="brand" label="Brand" />
|
|
58
|
+
<Dropdown.Item value="name" label="Name" />
|
|
59
|
+
</Dropdown>
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Icons in items
|
|
64
|
+
|
|
65
|
+
```jsx example
|
|
66
|
+
const [value, setValue] = useState('')
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Dropdown value={value} onChange={v => setValue(v)}>
|
|
70
|
+
<Dropdown.Item icon={faPencilAlt} value="edit" label="Edit" />
|
|
71
|
+
<Dropdown.Item icon={faTrashAlt} value="delete" label="Delete" />
|
|
72
|
+
</Dropdown>
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Sandbox
|
|
77
|
+
|
|
78
|
+
<Sandbox
|
|
79
|
+
Component={Dropdown}
|
|
80
|
+
propsJsonSchema={DropdownPropsJsonSchema}
|
|
81
|
+
props={{
|
|
82
|
+
value: 'popular',
|
|
83
|
+
onChange: v => alert(`Selected: ${v}`),
|
|
84
|
+
children: [
|
|
85
|
+
<Dropdown.Caption placeholder="Sort by" />,
|
|
86
|
+
<Dropdown.Item value="popular" label="Popular" />,
|
|
87
|
+
<Dropdown.Item value="brand" label="Brand" />,
|
|
88
|
+
<Dropdown.Item value="name" label="Name" />
|
|
89
|
+
]
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.select
|
|
2
|
+
background-color var(--color-bg-main-strong)
|
|
3
|
+
height 4u
|
|
4
|
+
border-radius .5u
|
|
5
|
+
border-style solid
|
|
6
|
+
border-width 1px
|
|
7
|
+
border-color var(--color-border-main)
|
|
8
|
+
padding 0 1u
|
|
9
|
+
align-items center
|
|
10
|
+
justify-content space-between
|
|
11
|
+
min-width 150px
|
|
12
|
+
|
|
13
|
+
.placeholder
|
|
14
|
+
color var(--color-text-placeholder)
|
|
15
|
+
|
|
16
|
+
.active
|
|
17
|
+
color var(--color-text-main)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
import { pug, observer } from 'startupjs'
|
|
3
|
+
import Div from '@startupjs-ui/div'
|
|
4
|
+
import Span from '@startupjs-ui/span'
|
|
5
|
+
import Icon from '@startupjs-ui/icon'
|
|
6
|
+
import Button from '@startupjs-ui/button'
|
|
7
|
+
import { themed } from '@startupjs-ui/core'
|
|
8
|
+
import { faAngleDown } from '@fortawesome/free-solid-svg-icons/faAngleDown'
|
|
9
|
+
import './index.cssx.styl'
|
|
10
|
+
|
|
11
|
+
export interface DropdownCaptionProps {
|
|
12
|
+
/** Caption content (used when `variant='custom'`) */
|
|
13
|
+
children?: ReactNode
|
|
14
|
+
/** Placeholder text shown when no active item */
|
|
15
|
+
placeholder?: string
|
|
16
|
+
/** Visual variant @default 'select' */
|
|
17
|
+
variant?: 'select' | 'button' | 'custom'
|
|
18
|
+
/** @private Active item label injected by Dropdown */
|
|
19
|
+
_activeLabel?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function DropdownCaption ({
|
|
23
|
+
children,
|
|
24
|
+
placeholder = 'Select a state...',
|
|
25
|
+
variant = 'select',
|
|
26
|
+
_activeLabel
|
|
27
|
+
}: DropdownCaptionProps): ReactNode {
|
|
28
|
+
if (variant === 'custom') return children
|
|
29
|
+
|
|
30
|
+
if (variant === 'button') {
|
|
31
|
+
return pug`
|
|
32
|
+
Button(
|
|
33
|
+
variant='flat'
|
|
34
|
+
color='primary'
|
|
35
|
+
pointerEvents='box-none'
|
|
36
|
+
)= placeholder
|
|
37
|
+
`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return pug`
|
|
41
|
+
Div.select(row)
|
|
42
|
+
Span.placeholder(styleName={ active: !!_activeLabel })
|
|
43
|
+
= _activeLabel || placeholder
|
|
44
|
+
Icon(icon=faAngleDown)
|
|
45
|
+
`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default observer(themed('DropdownCaption', DropdownCaption))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
.item
|
|
2
|
+
&.list
|
|
3
|
+
flex-direction row
|
|
4
|
+
justify-content space-between
|
|
5
|
+
border-bottom-width 1px
|
|
6
|
+
border-bottom-color #cccccc
|
|
7
|
+
|
|
8
|
+
&.buttons
|
|
9
|
+
justify-content center
|
|
10
|
+
align-items center
|
|
11
|
+
height 6u
|
|
12
|
+
border-bottom-width 1px
|
|
13
|
+
border-bottom-color #cccccc
|
|
14
|
+
|
|
15
|
+
&.popover
|
|
16
|
+
padding 12px
|
|
17
|
+
|
|
18
|
+
&.itemDown
|
|
19
|
+
border-bottom-width 0
|
|
20
|
+
|
|
21
|
+
.itemText
|
|
22
|
+
&.list
|
|
23
|
+
padding 2u
|
|
24
|
+
|
|
25
|
+
&.buttons
|
|
26
|
+
padding 2u
|
|
27
|
+
|
|
28
|
+
&.active
|
|
29
|
+
color var(--color-text-primary)
|
|
30
|
+
|
|
31
|
+
.iconActive
|
|
32
|
+
&.list
|
|
33
|
+
position absolute
|
|
34
|
+
top 17px
|
|
35
|
+
right 14px
|
|
36
|
+
width 2u
|
|
37
|
+
height 2u
|
|
38
|
+
|
|
39
|
+
&.buttons
|
|
40
|
+
position absolute
|
|
41
|
+
top 17px
|
|
42
|
+
left 14px
|
|
43
|
+
width 2u
|
|
44
|
+
height 2u
|
|
45
|
+
|
|
46
|
+
.selectMenu
|
|
47
|
+
background-color #eeeeee
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
import { Text, View, TouchableOpacity, type StyleProp, type ViewStyle } from 'react-native'
|
|
3
|
+
import { pug, observer } from 'startupjs'
|
|
4
|
+
import Icon from '@startupjs-ui/icon'
|
|
5
|
+
import Menu from '@startupjs-ui/menu'
|
|
6
|
+
import Link from '@startupjs-ui/link'
|
|
7
|
+
import { themed } from '@startupjs-ui/core'
|
|
8
|
+
import { faCheck } from '@fortawesome/free-solid-svg-icons/faCheck'
|
|
9
|
+
import './index.cssx.styl'
|
|
10
|
+
|
|
11
|
+
export interface DropdownItemProps {
|
|
12
|
+
/** Custom styles applied to the wrapper */
|
|
13
|
+
style?: StyleProp<ViewStyle>
|
|
14
|
+
/** Navigation target (renders as Link when provided) */
|
|
15
|
+
to?: any
|
|
16
|
+
/** Item label */
|
|
17
|
+
label?: string
|
|
18
|
+
/** Item value reported to Dropdown.onChange */
|
|
19
|
+
value?: string | number
|
|
20
|
+
/** Optional icon displayed in Menu.Item (popover variant) */
|
|
21
|
+
icon?: any
|
|
22
|
+
/** Disable item interactions */
|
|
23
|
+
disabled?: boolean
|
|
24
|
+
/** Custom press handler (bypasses Dropdown.onChange) */
|
|
25
|
+
onPress?: () => void
|
|
26
|
+
/** Custom content when used as a pure/custom item */
|
|
27
|
+
children?: ReactNode
|
|
28
|
+
/** @private Active value injected by Dropdown */
|
|
29
|
+
_activeValue?: any
|
|
30
|
+
/** @private Selected index for keyboard navigation */
|
|
31
|
+
_selectIndexValue?: number
|
|
32
|
+
/** @private Variant injected by Dropdown */
|
|
33
|
+
_variant?: 'list' | 'buttons' | 'popover' | 'pure'
|
|
34
|
+
/** @private Style for active item injected by Dropdown */
|
|
35
|
+
_styleActiveItem?: StyleProp<ViewStyle>
|
|
36
|
+
/** @private Change handler injected by Dropdown */
|
|
37
|
+
_onChange?: (value: any) => void
|
|
38
|
+
/** @private Dismiss handler injected by Dropdown */
|
|
39
|
+
_onDismissDropdown?: () => void
|
|
40
|
+
/** @private Item index injected by Dropdown */
|
|
41
|
+
_index?: number
|
|
42
|
+
/** @private Items count injected by Dropdown */
|
|
43
|
+
_childrenLength?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function DropdownItem ({
|
|
47
|
+
style,
|
|
48
|
+
to,
|
|
49
|
+
label,
|
|
50
|
+
value,
|
|
51
|
+
icon,
|
|
52
|
+
disabled,
|
|
53
|
+
onPress,
|
|
54
|
+
children,
|
|
55
|
+
_activeValue,
|
|
56
|
+
_selectIndexValue,
|
|
57
|
+
_variant,
|
|
58
|
+
_styleActiveItem,
|
|
59
|
+
_onChange,
|
|
60
|
+
_onDismissDropdown,
|
|
61
|
+
_index,
|
|
62
|
+
_childrenLength
|
|
63
|
+
}: DropdownItemProps): ReactNode {
|
|
64
|
+
const isPure = _variant === 'pure'
|
|
65
|
+
|
|
66
|
+
const handlePress = () => {
|
|
67
|
+
if (disabled) return
|
|
68
|
+
|
|
69
|
+
if (onPress) {
|
|
70
|
+
onPress()
|
|
71
|
+
_onDismissDropdown && _onDismissDropdown()
|
|
72
|
+
} else {
|
|
73
|
+
_onChange && _onChange(value)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (_variant === 'popover' && !isPure) {
|
|
78
|
+
return pug`
|
|
79
|
+
Menu.Item(
|
|
80
|
+
to=to
|
|
81
|
+
style=style
|
|
82
|
+
active=_activeValue === value
|
|
83
|
+
disabled=disabled
|
|
84
|
+
styleName={ selectMenu: _selectIndexValue === _index }
|
|
85
|
+
onPress=handlePress
|
|
86
|
+
icon=icon
|
|
87
|
+
)= label
|
|
88
|
+
`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const Wrapper: any = to ? Link : TouchableOpacity
|
|
92
|
+
return pug`
|
|
93
|
+
Wrapper(
|
|
94
|
+
to=to
|
|
95
|
+
style=style
|
|
96
|
+
onPress=handlePress
|
|
97
|
+
)
|
|
98
|
+
View.item(
|
|
99
|
+
style=(!isPure && _activeValue === value) ? _styleActiveItem : undefined
|
|
100
|
+
styleName=[!isPure && _variant, {
|
|
101
|
+
active: !isPure && (_activeValue === value),
|
|
102
|
+
itemUp: !isPure && (_index === 0),
|
|
103
|
+
itemDown: !isPure && (_index === (_childrenLength || 0) - 1),
|
|
104
|
+
selectMenu: _selectIndexValue === _index
|
|
105
|
+
}]
|
|
106
|
+
)
|
|
107
|
+
if isPure
|
|
108
|
+
= children
|
|
109
|
+
else
|
|
110
|
+
Text.itemText(styleName=[_variant, { active: _activeValue && _activeValue === value }])
|
|
111
|
+
= label
|
|
112
|
+
if _activeValue === value
|
|
113
|
+
Icon.iconActive(styleName=_variant icon=faCheck)
|
|
114
|
+
`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default observer(themed('DropdownItem', DropdownItem))
|
package/helpers/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as useKeyboard } from './useKeyboard'
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { Platform } from 'react-native'
|
|
3
|
+
|
|
4
|
+
export default function useKeyboard ({
|
|
5
|
+
isShow,
|
|
6
|
+
renderContent,
|
|
7
|
+
value,
|
|
8
|
+
onChange,
|
|
9
|
+
onChangeShow
|
|
10
|
+
}: {
|
|
11
|
+
isShow: boolean
|
|
12
|
+
renderContent: { current: any[] }
|
|
13
|
+
value: any
|
|
14
|
+
onChange?: (value: any) => void
|
|
15
|
+
onChangeShow: (visible: boolean) => void
|
|
16
|
+
}): [number] {
|
|
17
|
+
const [selectIndexValue, setSelectIndexValue] = useState(-1)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (Platform.OS !== 'web') return
|
|
21
|
+
|
|
22
|
+
if (isShow) {
|
|
23
|
+
document.addEventListener('keydown', onKeyDown)
|
|
24
|
+
} else {
|
|
25
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
26
|
+
setSelectIndexValue(-1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return () => { document.removeEventListener('keydown', onKeyDown) }
|
|
30
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
|
+
}, [isShow, selectIndexValue])
|
|
32
|
+
|
|
33
|
+
function onKeyDown (e: KeyboardEvent) {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
e.stopPropagation()
|
|
36
|
+
|
|
37
|
+
let item: any
|
|
38
|
+
let index: number
|
|
39
|
+
const keyName = e.key
|
|
40
|
+
|
|
41
|
+
switch (keyName) {
|
|
42
|
+
case 'ArrowUp':
|
|
43
|
+
if (selectIndexValue === 0 || (selectIndexValue === -1 && !value)) return
|
|
44
|
+
|
|
45
|
+
index = selectIndexValue - 1
|
|
46
|
+
if (selectIndexValue === -1 && value) {
|
|
47
|
+
index = renderContent.current.findIndex(item => item.props.value === value)
|
|
48
|
+
index--
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setSelectIndexValue(index)
|
|
52
|
+
break
|
|
53
|
+
|
|
54
|
+
case 'ArrowDown':
|
|
55
|
+
if (selectIndexValue === renderContent.current.length - 1) return
|
|
56
|
+
|
|
57
|
+
index = selectIndexValue + 1
|
|
58
|
+
if (selectIndexValue === -1 && value) {
|
|
59
|
+
index = renderContent.current.findIndex(item => item.props.value === value)
|
|
60
|
+
index++
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setSelectIndexValue(index)
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
case 'Enter':
|
|
67
|
+
if (selectIndexValue === -1) return
|
|
68
|
+
item = renderContent.current.find((_, i) => i === selectIndexValue)
|
|
69
|
+
onChange && onChange(item.props.value)
|
|
70
|
+
onChangeShow(false)
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [selectIndexValue]
|
|
76
|
+
}
|
package/index.cssx.styl
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
.dropdown
|
|
2
|
+
&.list
|
|
3
|
+
padding 2u
|
|
4
|
+
padding-bottom 1u
|
|
5
|
+
|
|
6
|
+
&.buttons
|
|
7
|
+
max-height 100%
|
|
8
|
+
|
|
9
|
+
.case
|
|
10
|
+
&.buttons
|
|
11
|
+
margin 1.5u 1.5u 2u
|
|
12
|
+
border-radius 1u
|
|
13
|
+
background-color var(--color-bg-main-strong)
|
|
14
|
+
|
|
15
|
+
.caption
|
|
16
|
+
align-self flex-start
|
|
17
|
+
|
|
18
|
+
.captionText
|
|
19
|
+
&.list
|
|
20
|
+
padding 1u 2u 2u
|
|
21
|
+
font-size 21px
|
|
22
|
+
|
|
23
|
+
+web()
|
|
24
|
+
fontFamily('normal', 600)
|
|
25
|
+
|
|
26
|
+
+native()
|
|
27
|
+
fontFamily('normal', 600)
|
|
28
|
+
|
|
29
|
+
.button
|
|
30
|
+
&.buttons
|
|
31
|
+
justify-content center
|
|
32
|
+
align-items center
|
|
33
|
+
margin -1u 1.5u 1.5u
|
|
34
|
+
padding 2u
|
|
35
|
+
border-radius 1u
|
|
36
|
+
background-color var(--color-bg-main-strong)
|
|
37
|
+
|
|
38
|
+
.popover
|
|
39
|
+
border-radius .5u
|
|
40
|
+
shadow(2)
|
|
41
|
+
|
|
42
|
+
.drawerReset
|
|
43
|
+
background-color transparent
|
|
44
|
+
height auto
|
|
45
|
+
box-shadow none
|
|
46
|
+
border-radius 0
|
|
47
|
+
|
|
48
|
+
:export
|
|
49
|
+
media: $UI.media
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// DO NOT MODIFY THIS FILE - IT IS AUTOMATICALLY GENERATED ON COMMITS.
|
|
3
|
+
|
|
4
|
+
import { type ReactNode, type RefObject } from 'react';
|
|
5
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
6
|
+
export declare const _PropsJsonSchema: {};
|
|
7
|
+
export interface DropdownProps {
|
|
8
|
+
/** Ref to control dropdown programmatically */
|
|
9
|
+
ref?: RefObject<DropdownRef>;
|
|
10
|
+
/** Custom styles applied to the dropdown content container */
|
|
11
|
+
style?: StyleProp<ViewStyle>;
|
|
12
|
+
/** Custom styles applied to the caption wrapper */
|
|
13
|
+
captionStyle?: StyleProp<ViewStyle>;
|
|
14
|
+
/** Custom styles applied to the active item view */
|
|
15
|
+
activeItemStyle?: StyleProp<ViewStyle>;
|
|
16
|
+
/** Dropdown caption and items */
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
/** Currently selected value @default '' */
|
|
19
|
+
value?: string | number;
|
|
20
|
+
/** Popover position @default 'bottom' */
|
|
21
|
+
position?: 'top' | 'bottom' | 'left' | 'right';
|
|
22
|
+
/** Popover attachment @default 'start' */
|
|
23
|
+
attachment?: 'start' | 'center' | 'end';
|
|
24
|
+
/** Fallback placements order */
|
|
25
|
+
placements?: any;
|
|
26
|
+
/** Drawer items rendering variant @default 'buttons' */
|
|
27
|
+
drawerVariant?: 'list' | 'buttons' | 'pure';
|
|
28
|
+
/** Title shown in list drawer variant */
|
|
29
|
+
drawerListTitle?: string;
|
|
30
|
+
/** Cancel button label in buttons drawer variant @default 'Cancel' */
|
|
31
|
+
drawerCancelLabel?: string;
|
|
32
|
+
/** Disable caption press */
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
/** Enable drawer behavior on small screens @default true */
|
|
35
|
+
hasDrawer?: boolean;
|
|
36
|
+
/** Show swipe responder zone in drawer */
|
|
37
|
+
showDrawerResponder?: boolean;
|
|
38
|
+
/** Called when item is selected */
|
|
39
|
+
onChange?: (value: string | number | undefined) => void;
|
|
40
|
+
/** Called when dropdown is dismissed via overlay/cancel */
|
|
41
|
+
onDismiss?: () => void;
|
|
42
|
+
}
|
|
43
|
+
export interface DropdownRef {
|
|
44
|
+
/** Open dropdown programmatically */
|
|
45
|
+
open: () => void;
|
|
46
|
+
/** Close dropdown programmatically */
|
|
47
|
+
close: () => void;
|
|
48
|
+
}
|
|
49
|
+
declare const ObservedDropdown: any;
|
|
50
|
+
export default ObservedDropdown;
|
package/index.tsx
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import React, { useState, useRef, useImperativeHandle, useEffect, type ReactNode, type RefObject } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Dimensions,
|
|
4
|
+
UIManager,
|
|
5
|
+
ScrollView,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
TouchableOpacity,
|
|
9
|
+
View,
|
|
10
|
+
type StyleProp,
|
|
11
|
+
type ViewStyle
|
|
12
|
+
} from 'react-native'
|
|
13
|
+
import { pug, observer, $ } from 'startupjs'
|
|
14
|
+
import { themed } from '@startupjs-ui/core'
|
|
15
|
+
import Drawer from '@startupjs-ui/drawer'
|
|
16
|
+
import Popover, { type PopoverRef } from '@startupjs-ui/popover'
|
|
17
|
+
import DropdownCaption from './components/Caption'
|
|
18
|
+
import DropdownItem from './components/Item'
|
|
19
|
+
import { useKeyboard } from './helpers'
|
|
20
|
+
import STYLES from './index.cssx.styl'
|
|
21
|
+
|
|
22
|
+
export const _PropsJsonSchema = {/* DropdownProps */}
|
|
23
|
+
|
|
24
|
+
export interface DropdownProps {
|
|
25
|
+
/** Ref to control dropdown programmatically */
|
|
26
|
+
ref?: RefObject<DropdownRef>
|
|
27
|
+
/** Custom styles applied to the dropdown content container */
|
|
28
|
+
style?: StyleProp<ViewStyle>
|
|
29
|
+
/** Custom styles applied to the caption wrapper */
|
|
30
|
+
captionStyle?: StyleProp<ViewStyle>
|
|
31
|
+
/** Custom styles applied to the active item view */
|
|
32
|
+
activeItemStyle?: StyleProp<ViewStyle>
|
|
33
|
+
/** Dropdown caption and items */
|
|
34
|
+
children?: ReactNode
|
|
35
|
+
/** Currently selected value @default '' */
|
|
36
|
+
value?: string | number
|
|
37
|
+
/** Popover position @default 'bottom' */
|
|
38
|
+
position?: 'top' | 'bottom' | 'left' | 'right'
|
|
39
|
+
/** Popover attachment @default 'start' */
|
|
40
|
+
attachment?: 'start' | 'center' | 'end'
|
|
41
|
+
/** Fallback placements order */
|
|
42
|
+
placements?: any
|
|
43
|
+
/** Drawer items rendering variant @default 'buttons' */
|
|
44
|
+
drawerVariant?: 'list' | 'buttons' | 'pure'
|
|
45
|
+
/** Title shown in list drawer variant */
|
|
46
|
+
drawerListTitle?: string
|
|
47
|
+
/** Cancel button label in buttons drawer variant @default 'Cancel' */
|
|
48
|
+
drawerCancelLabel?: string
|
|
49
|
+
/** Disable caption press */
|
|
50
|
+
disabled?: boolean
|
|
51
|
+
/** Enable drawer behavior on small screens @default true */
|
|
52
|
+
hasDrawer?: boolean
|
|
53
|
+
/** Show swipe responder zone in drawer */
|
|
54
|
+
showDrawerResponder?: boolean
|
|
55
|
+
/** Called when item is selected */
|
|
56
|
+
onChange?: (value: string | number | undefined) => void
|
|
57
|
+
/** Called when dropdown is dismissed via overlay/cancel */
|
|
58
|
+
onDismiss?: () => void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DropdownRef {
|
|
62
|
+
/** Open dropdown programmatically */
|
|
63
|
+
open: () => void
|
|
64
|
+
/** Close dropdown programmatically */
|
|
65
|
+
close: () => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// TODO: key event change scroll
|
|
69
|
+
function Dropdown ({
|
|
70
|
+
style = [],
|
|
71
|
+
captionStyle,
|
|
72
|
+
activeItemStyle,
|
|
73
|
+
children,
|
|
74
|
+
value = '',
|
|
75
|
+
position = 'bottom',
|
|
76
|
+
attachment = 'start',
|
|
77
|
+
placements,
|
|
78
|
+
drawerVariant = 'buttons',
|
|
79
|
+
drawerListTitle = '',
|
|
80
|
+
drawerCancelLabel = 'Cancel',
|
|
81
|
+
disabled,
|
|
82
|
+
hasDrawer = true,
|
|
83
|
+
showDrawerResponder,
|
|
84
|
+
onChange,
|
|
85
|
+
onDismiss,
|
|
86
|
+
ref
|
|
87
|
+
}: DropdownProps): ReactNode {
|
|
88
|
+
const popoverRef = useRef<PopoverRef>(null)
|
|
89
|
+
const refScroll = useRef<any>(null)
|
|
90
|
+
const renderContent = useRef<any[]>([])
|
|
91
|
+
const closeReason = useRef<null | 'toggle' | 'select' | 'dismiss' | 'resize'>(null)
|
|
92
|
+
|
|
93
|
+
const $isShow = $(false)
|
|
94
|
+
const [activeInfo, setActiveInfo] = useState<any>(null)
|
|
95
|
+
const $layoutWidth = $(
|
|
96
|
+
Math.min(Dimensions.get('window').width, Dimensions.get('screen').width)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const [selectIndexValue] = useKeyboard({
|
|
100
|
+
value,
|
|
101
|
+
isShow: $isShow.get(),
|
|
102
|
+
renderContent,
|
|
103
|
+
onChange: (v: any) => {
|
|
104
|
+
closeReason.current = 'select'
|
|
105
|
+
onChange && onChange(v)
|
|
106
|
+
},
|
|
107
|
+
onChangeShow: v => { handleVisibleChange(v) }
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const isPopover = !hasDrawer || ($layoutWidth.get() > STYLES.media.tablet)
|
|
111
|
+
|
|
112
|
+
function handleWidthChange () {
|
|
113
|
+
closeReason.current = 'resize'
|
|
114
|
+
popoverRef.current?.close?.()
|
|
115
|
+
$isShow.set(false)
|
|
116
|
+
$layoutWidth.set(Math.min(Dimensions.get('window').width, Dimensions.get('screen').width))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const listener = Dimensions.addEventListener('change', handleWidthChange)
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
$isShow.del()
|
|
124
|
+
listener?.remove?.()
|
|
125
|
+
}
|
|
126
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
127
|
+
}, [])
|
|
128
|
+
|
|
129
|
+
useImperativeHandle(ref, () => ({
|
|
130
|
+
open: () => {
|
|
131
|
+
handleVisibleChange(true)
|
|
132
|
+
},
|
|
133
|
+
close: () => {
|
|
134
|
+
handleVisibleChange(false, { reason: 'toggle' })
|
|
135
|
+
}
|
|
136
|
+
}))
|
|
137
|
+
|
|
138
|
+
function handleVisibleChange (nextVisible: boolean, meta: { reason?: typeof closeReason.current } = {}) {
|
|
139
|
+
if (typeof meta.reason !== 'undefined') closeReason.current = meta.reason
|
|
140
|
+
|
|
141
|
+
if (isPopover) {
|
|
142
|
+
if (nextVisible) {
|
|
143
|
+
closeReason.current = null
|
|
144
|
+
popoverRef.current?.open?.()
|
|
145
|
+
$isShow.set(true)
|
|
146
|
+
} else {
|
|
147
|
+
popoverRef.current?.close?.()
|
|
148
|
+
$isShow.set(false)
|
|
149
|
+
}
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!nextVisible && closeReason.current === 'dismiss') onDismiss && onDismiss()
|
|
154
|
+
$isShow.set(nextVisible)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function onLayoutActive ({ nativeEvent }: any) {
|
|
158
|
+
setActiveInfo(nativeEvent.layout)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function onCancel () {
|
|
162
|
+
handleVisibleChange(false, { reason: 'dismiss' })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function onRequestOpen () {
|
|
166
|
+
const node = refScroll.current?.getScrollableNode
|
|
167
|
+
? refScroll.current.getScrollableNode()
|
|
168
|
+
: refScroll.current
|
|
169
|
+
|
|
170
|
+
if (!node) return
|
|
171
|
+
|
|
172
|
+
UIManager.measure(node, (x, y, width, curHeight) => {
|
|
173
|
+
if (activeInfo && activeInfo.y >= (curHeight - activeInfo.height)) {
|
|
174
|
+
refScroll.current?.scrollTo?.({ y: activeInfo.y, animated: false })
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let caption: ReactNode = null
|
|
180
|
+
let activeLabel = ''
|
|
181
|
+
renderContent.current = []
|
|
182
|
+
|
|
183
|
+
React.Children.toArray(children).forEach((child: any, index, arr) => {
|
|
184
|
+
if (child?.type === DropdownCaption) {
|
|
185
|
+
if (index !== 0) Error('Caption need use first child')
|
|
186
|
+
if (child.props.children) {
|
|
187
|
+
caption = React.cloneElement(child, { variant: 'custom' })
|
|
188
|
+
} else {
|
|
189
|
+
caption = child
|
|
190
|
+
}
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const _child = React.cloneElement(child, {
|
|
195
|
+
_variant: child.props.children
|
|
196
|
+
? 'pure'
|
|
197
|
+
: (isPopover ? 'popover' : drawerVariant),
|
|
198
|
+
_styleActiveItem: activeItemStyle,
|
|
199
|
+
_activeValue: value,
|
|
200
|
+
_selectIndexValue: selectIndexValue,
|
|
201
|
+
_index: caption ? (index - 1) : index,
|
|
202
|
+
_childrenLength: caption ? (arr.length - 1) : arr.length,
|
|
203
|
+
_onDismissDropdown: () => { handleVisibleChange(false) },
|
|
204
|
+
_onChange: (v: any) => {
|
|
205
|
+
closeReason.current = 'select'
|
|
206
|
+
onChange && onChange(v)
|
|
207
|
+
handleVisibleChange(false)
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
if (value === child.props.value) {
|
|
212
|
+
activeLabel = child.props.label
|
|
213
|
+
renderContent.current.push(pug`
|
|
214
|
+
View(
|
|
215
|
+
key=index
|
|
216
|
+
value=child.props.value
|
|
217
|
+
onLayout=onLayoutActive
|
|
218
|
+
)=_child
|
|
219
|
+
`)
|
|
220
|
+
} else {
|
|
221
|
+
renderContent.current.push(_child)
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
if (!caption) {
|
|
226
|
+
caption = <DropdownCaption _activeLabel={activeLabel} />
|
|
227
|
+
} else {
|
|
228
|
+
caption = React.cloneElement(caption as any, { _activeLabel: activeLabel })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const _popoverStyle = StyleSheet.flatten(style)
|
|
232
|
+
if ((caption as any).props?.variant === 'button' || (caption as any).props?.variant === 'custom') {
|
|
233
|
+
;(_popoverStyle as any).minWidth = 160
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const matchAnchorWidth = !(_popoverStyle as any)?.width && !(_popoverStyle as any)?.minWidth
|
|
237
|
+
|
|
238
|
+
if (isPopover) {
|
|
239
|
+
const renderPopoverContent = (): ReactNode => pug`
|
|
240
|
+
ScrollView(
|
|
241
|
+
ref=refScroll
|
|
242
|
+
showsVerticalScrollIndicator=false
|
|
243
|
+
)= renderContent.current
|
|
244
|
+
`
|
|
245
|
+
|
|
246
|
+
const handlePopoverCloseComplete = () => {
|
|
247
|
+
$isShow.set(false)
|
|
248
|
+
if (closeReason.current !== 'select' && closeReason.current !== 'toggle' && closeReason.current !== 'resize') {
|
|
249
|
+
onDismiss && onDismiss()
|
|
250
|
+
}
|
|
251
|
+
closeReason.current = null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return pug`
|
|
255
|
+
Popover(
|
|
256
|
+
ref=popoverRef
|
|
257
|
+
style=captionStyle
|
|
258
|
+
attachmentStyle=_popoverStyle
|
|
259
|
+
position=position
|
|
260
|
+
attachment=attachment
|
|
261
|
+
placements=placements
|
|
262
|
+
matchAnchorWidth=matchAnchorWidth
|
|
263
|
+
onOpenComplete=onRequestOpen
|
|
264
|
+
onCloseComplete=handlePopoverCloseComplete
|
|
265
|
+
renderContent=renderPopoverContent
|
|
266
|
+
)
|
|
267
|
+
TouchableOpacity(
|
|
268
|
+
disabled=disabled
|
|
269
|
+
onPress=() => handleVisibleChange(!$isShow.get(), { reason: !$isShow.get() ? null : 'toggle' })
|
|
270
|
+
)
|
|
271
|
+
= caption
|
|
272
|
+
`
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return pug`
|
|
276
|
+
if caption
|
|
277
|
+
TouchableOpacity.caption(
|
|
278
|
+
disabled=disabled
|
|
279
|
+
onPress=() => handleVisibleChange(!$isShow.get())
|
|
280
|
+
)
|
|
281
|
+
= caption
|
|
282
|
+
Drawer(
|
|
283
|
+
visible=$isShow.get()
|
|
284
|
+
position='bottom'
|
|
285
|
+
style={ maxHeight: '100%' }
|
|
286
|
+
styleName={ drawerReset: drawerVariant === 'buttons' }
|
|
287
|
+
onDismiss=() => handleVisibleChange(false)
|
|
288
|
+
onRequestOpen=onRequestOpen
|
|
289
|
+
showResponder=showDrawerResponder
|
|
290
|
+
)
|
|
291
|
+
View.dropdown(styleName=drawerVariant)
|
|
292
|
+
if drawerVariant === 'list'
|
|
293
|
+
View.caption(styleName=drawerVariant)
|
|
294
|
+
Text.captionText(styleName=drawerVariant)= drawerListTitle
|
|
295
|
+
ScrollView.case(
|
|
296
|
+
ref=refScroll
|
|
297
|
+
showsVerticalScrollIndicator=false
|
|
298
|
+
style=_popoverStyle
|
|
299
|
+
styleName=drawerVariant
|
|
300
|
+
)= renderContent.current
|
|
301
|
+
if drawerVariant === 'buttons'
|
|
302
|
+
TouchableOpacity(onPress=onCancel)
|
|
303
|
+
View.button(styleName=drawerVariant)
|
|
304
|
+
Text= drawerCancelLabel
|
|
305
|
+
`
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const ObservedDropdown: any = observer(themed('Dropdown', Dropdown))
|
|
309
|
+
|
|
310
|
+
ObservedDropdown.Caption = DropdownCaption
|
|
311
|
+
ObservedDropdown.Item = DropdownItem
|
|
312
|
+
|
|
313
|
+
export default ObservedDropdown
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@startupjs-ui/dropdown",
|
|
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/button": "^0.1.3",
|
|
12
|
+
"@startupjs-ui/core": "^0.1.3",
|
|
13
|
+
"@startupjs-ui/div": "^0.1.3",
|
|
14
|
+
"@startupjs-ui/drawer": "^0.1.3",
|
|
15
|
+
"@startupjs-ui/icon": "^0.1.3",
|
|
16
|
+
"@startupjs-ui/link": "^0.1.3",
|
|
17
|
+
"@startupjs-ui/menu": "^0.1.3",
|
|
18
|
+
"@startupjs-ui/popover": "^0.1.3",
|
|
19
|
+
"@startupjs-ui/span": "^0.1.3"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": "*",
|
|
23
|
+
"react-native": "*",
|
|
24
|
+
"startupjs": "*"
|
|
25
|
+
},
|
|
26
|
+
"gitHead": "fd964ebc3892d3dd0a6c85438c0af619cc50c3f0"
|
|
27
|
+
}
|