@wireservers-ui/react-natives 2.0.2 → 2.0.4
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 +25 -0
- package/README.md +8 -16
- package/bin/cli.js +1 -1
- package/package.json +4 -4
- package/src/index.ts +5 -0
- package/src/select/index.ts +5 -0
- package/src/select/select-input.tsx +2 -2
- package/src/select/select-item.tsx +39 -11
- package/src/select/select-search-input.tsx +77 -0
- package/src/select/select-selected-badges.tsx +119 -0
- package/src/select/select.tsx +183 -7
- package/src/select/styles.ts +88 -0
- package/src/select/types.ts +57 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ Versioning: [Semantic Versioning](https://semver.org/)
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [2.0.4] - 2026-07-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Select` now supports multi-select state with `isMulti`, `selectedValues`, `defaultValues`, `onValuesChange`, and configurable close-on-select behavior.
|
|
14
|
+
- `SelectSearchInput` filters dropdown items using built-in text matching or a custom `filterOption` callback.
|
|
15
|
+
- `SelectSelectedBadges` renders selected values as removable badges with class overrides, `maxVisible`, overflow labels, and custom badge rendering.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- `SelectItem` toggles selected values in multi-select mode and preserves single-select behavior by default.
|
|
19
|
+
- `SelectInput` now uses the root `Select` placeholder when no local placeholder override is provided.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## [2.0.3] - 2026-04-18
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- Repository URL updated from `wireservers/wireservers-ui` to `wireservers/react-natives` following GitHub repo rename — resolves 404 on npmjs Release Notes link
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- CI workflows migrated from `dev`/`prod`/`sot` branch model to `develop`/`main` gitflow
|
|
30
|
+
- `babel-preset-expo` added as explicit dependency to resolve peer dependency resolution failures in certain environments
|
|
31
|
+
- Docs site Dockerfile and nginx config added for self-hosted deployment
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
10
35
|
## [2.0.2] - 2026-03-21
|
|
11
36
|
|
|
12
37
|
### Changed
|
package/README.md
CHANGED
|
@@ -2,24 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
**A comprehensive React Native component library built with NativeWind and Tailwind Variants.**
|
|
4
4
|
|
|
5
|
-
> **Note:** This project is in active development (v2.0.
|
|
5
|
+
> **Note:** This project is in active development (v2.0.4). We're building out components, documentation, and tooling. Contributions and feedback are welcome!
|
|
6
6
|
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/@wireservers-ui/react-natives)
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
## Release Notes (v2.0.
|
|
12
|
+
## Release Notes (v2.0.4)
|
|
13
13
|
|
|
14
14
|
### What was updated
|
|
15
15
|
|
|
16
|
-
-
|
|
17
|
-
-
|
|
16
|
+
- `Select` now supports searchable multi-select usage with controlled and uncontrolled selected values
|
|
17
|
+
- `SelectSearchInput` filters dropdown items while keeping existing single-select usage unchanged
|
|
18
|
+
- `SelectSelectedBadges` displays removable selected-value badges with styling and rendering overrides
|
|
18
19
|
|
|
19
20
|
### Changelog
|
|
20
21
|
|
|
21
22
|
- Full changelog is included in the package at `CHANGELOG.md`
|
|
22
|
-
- GitHub: https://github.com/wireservers/
|
|
23
|
+
- GitHub: https://github.com/wireservers/react-natives/blob/main/packages/react-natives/CHANGELOG.md
|
|
23
24
|
- npm package page: open the `CHANGELOG.md` file from the package contents/files list
|
|
24
25
|
|
|
25
26
|
---
|
|
@@ -32,17 +33,8 @@ React-Natives is a collection of 70+ production-ready, accessible React Native c
|
|
|
32
33
|
|
|
33
34
|
### Key Features
|
|
34
35
|
|
|
35
|
-
- **70+ Components** — Buttons, forms, modals, tables, calendars, color pickers, and more
|
|
36
|
-
- **TypeScript First** — Fully typed with excellent IDE autocomplete
|
|
37
|
-
- **NativeWind + Tailwind Variants** — Utility-first styling with type-safe variant APIs
|
|
38
|
-
- **Themeable** — CSS variable-based color system with light/dark mode support
|
|
39
|
-
- **Accessible** — Built with accessibility in mind
|
|
40
|
-
- **Cross Platform** — iOS, Android, and Web via React Native
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
| Layer | Technology |
|
|
45
|
-
| --- | --- |
|
|
37
|
+
// ...existing code...
|
|
46
38
|
| Components | React Native |
|
|
47
39
|
| Styling | NativeWind v4, Tailwind CSS 3, Tailwind Variants |
|
|
48
40
|
| Types | TypeScript |
|
|
@@ -609,7 +601,7 @@ react-natives/
|
|
|
609
601
|
|
|
610
602
|
```bash
|
|
611
603
|
# Clone the repository
|
|
612
|
-
git clone https://github.com/wireservers/
|
|
604
|
+
git clone https://github.com/wireservers/react-natives.git
|
|
613
605
|
cd wireservers-ui/packages/react-natives
|
|
614
606
|
|
|
615
607
|
# Install dependencies
|
package/bin/cli.js
CHANGED
|
@@ -44,7 +44,7 @@ if (!isExpoProject(cwd)) {
|
|
|
44
44
|
" npx create-expo-app@latest demos/react-natives/project --template blank-typescript",
|
|
45
45
|
);
|
|
46
46
|
console.error(" cd demos/react-natives/project");
|
|
47
|
-
console.error(" npx @wireservers-ui/react-natives@2.0.
|
|
47
|
+
console.error(" npx @wireservers-ui/react-natives@2.0.2 init\n");
|
|
48
48
|
process.exit(1);
|
|
49
49
|
}
|
|
50
50
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireservers-ui/react-natives",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"description": "A comprehensive React Native component library built with NativeWind and Tailwind Variants",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"CHANGELOG.md"
|
|
12
12
|
],
|
|
13
13
|
"bin": {
|
|
14
|
-
"react-natives": "
|
|
14
|
+
"react-natives": "bin/cli.js"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {},
|
|
17
17
|
"keywords": [
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
27
|
-
"url": "https://github.com/wireservers/
|
|
27
|
+
"url": "git+https://github.com/wireservers/react-natives.git",
|
|
28
28
|
"directory": "packages/react-natives"
|
|
29
29
|
},
|
|
30
30
|
"license": "MIT",
|
|
@@ -47,4 +47,4 @@
|
|
|
47
47
|
"nativewind": "^4.2.3",
|
|
48
48
|
"tailwind-variants": "^0.1.20"
|
|
49
49
|
}
|
|
50
|
-
}
|
|
50
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -255,11 +255,14 @@ export {
|
|
|
255
255
|
SelectItemContext,
|
|
256
256
|
useSelectItemContext,
|
|
257
257
|
SelectItemText,
|
|
258
|
+
SelectSearchInput,
|
|
259
|
+
SelectSelectedBadges,
|
|
258
260
|
SelectDragIndicator,
|
|
259
261
|
SelectScrollView,
|
|
260
262
|
} from './select';
|
|
261
263
|
export type {
|
|
262
264
|
SelectProps,
|
|
265
|
+
SelectOption,
|
|
263
266
|
SelectTriggerProps,
|
|
264
267
|
SelectInputProps,
|
|
265
268
|
SelectIconProps,
|
|
@@ -268,6 +271,8 @@ export type {
|
|
|
268
271
|
SelectContentProps,
|
|
269
272
|
SelectItemProps,
|
|
270
273
|
SelectItemTextProps,
|
|
274
|
+
SelectSearchInputProps,
|
|
275
|
+
SelectSelectedBadgesProps,
|
|
271
276
|
SelectDragIndicatorProps,
|
|
272
277
|
SelectScrollViewProps,
|
|
273
278
|
SelectSize,
|
package/src/select/index.ts
CHANGED
|
@@ -7,10 +7,13 @@ export { SelectBackdrop } from './select-backdrop';
|
|
|
7
7
|
export { SelectContent } from './select-content';
|
|
8
8
|
export { SelectItem, SelectItemContext, useSelectItemContext } from './select-item';
|
|
9
9
|
export { SelectItemText } from './select-item-text';
|
|
10
|
+
export { SelectSearchInput } from './select-search-input';
|
|
11
|
+
export { SelectSelectedBadges } from './select-selected-badges';
|
|
10
12
|
export { SelectDragIndicator } from './select-drag-indicator';
|
|
11
13
|
export { SelectScrollView } from './select-scroll-view';
|
|
12
14
|
export type {
|
|
13
15
|
SelectProps,
|
|
16
|
+
SelectOption,
|
|
14
17
|
SelectTriggerProps,
|
|
15
18
|
SelectInputProps,
|
|
16
19
|
SelectIconProps,
|
|
@@ -19,6 +22,8 @@ export type {
|
|
|
19
22
|
SelectContentProps,
|
|
20
23
|
SelectItemProps,
|
|
21
24
|
SelectItemTextProps,
|
|
25
|
+
SelectSearchInputProps,
|
|
26
|
+
SelectSelectedBadgesProps,
|
|
22
27
|
SelectDragIndicatorProps,
|
|
23
28
|
SelectScrollViewProps,
|
|
24
29
|
SelectSize,
|
|
@@ -8,10 +8,10 @@ export const SelectInput = React.forwardRef<
|
|
|
8
8
|
React.ElementRef<typeof Text>,
|
|
9
9
|
SelectInputProps
|
|
10
10
|
>(({ className, placeholder, ...props }, ref) => {
|
|
11
|
-
const { selectedLabel, size } = useSelectContext();
|
|
11
|
+
const { selectedLabel, size, placeholder: contextPlaceholder } = useSelectContext();
|
|
12
12
|
|
|
13
13
|
const hasValue = !!selectedLabel;
|
|
14
|
-
const displayText = hasValue ? selectedLabel : (placeholder ??
|
|
14
|
+
const displayText = hasValue ? selectedLabel : (placeholder ?? contextPlaceholder);
|
|
15
15
|
|
|
16
16
|
return (
|
|
17
17
|
<Text
|
|
@@ -25,17 +25,32 @@ export const SelectItem = React.forwardRef<
|
|
|
25
25
|
React.ElementRef<typeof Pressable>,
|
|
26
26
|
SelectItemProps
|
|
27
27
|
>(({ className, label, value, isDisabled = false, children, ...props }, ref) => {
|
|
28
|
-
const {
|
|
28
|
+
const {
|
|
29
|
+
isMulti,
|
|
30
|
+
onValueChange,
|
|
31
|
+
size,
|
|
32
|
+
isValueSelected,
|
|
33
|
+
shouldShowItem,
|
|
34
|
+
registerItem,
|
|
35
|
+
} = useSelectContext();
|
|
29
36
|
|
|
30
|
-
const isSelected =
|
|
37
|
+
const isSelected = isValueSelected(value);
|
|
38
|
+
const isVisible = shouldShowItem(value, label);
|
|
39
|
+
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
registerItem(value, label);
|
|
42
|
+
}, [label, registerItem, value]);
|
|
31
43
|
|
|
32
44
|
const handlePress = () => {
|
|
33
45
|
if (!isDisabled) {
|
|
34
46
|
onValueChange(value, label);
|
|
35
|
-
onClose();
|
|
36
47
|
}
|
|
37
48
|
};
|
|
38
49
|
|
|
50
|
+
if (!isVisible) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
return (
|
|
40
55
|
<SelectItemContext.Provider value={{ label, value, isSelected, isDisabled }}>
|
|
41
56
|
<Pressable
|
|
@@ -55,14 +70,27 @@ export const SelectItem = React.forwardRef<
|
|
|
55
70
|
{...props}
|
|
56
71
|
>
|
|
57
72
|
{children ?? (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
<>
|
|
74
|
+
{isMulti ? (
|
|
75
|
+
<Text
|
|
76
|
+
className={selectItemTextStyle({
|
|
77
|
+
size,
|
|
78
|
+
isSelected,
|
|
79
|
+
})}
|
|
80
|
+
style={{ width: 22 }}
|
|
81
|
+
>
|
|
82
|
+
{isSelected ? '\u2713' : ''}
|
|
83
|
+
</Text>
|
|
84
|
+
) : null}
|
|
85
|
+
<Text
|
|
86
|
+
className={selectItemTextStyle({
|
|
87
|
+
size,
|
|
88
|
+
isSelected,
|
|
89
|
+
})}
|
|
90
|
+
>
|
|
91
|
+
{label}
|
|
92
|
+
</Text>
|
|
93
|
+
</>
|
|
66
94
|
)}
|
|
67
95
|
</Pressable>
|
|
68
96
|
</SelectItemContext.Provider>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import { useSelectContext } from './select';
|
|
4
|
+
import type { SelectSearchInputProps } from './types';
|
|
5
|
+
import {
|
|
6
|
+
selectSearchClearButtonStyle,
|
|
7
|
+
selectSearchInputFieldStyle,
|
|
8
|
+
selectSearchInputStyle,
|
|
9
|
+
} from './styles';
|
|
10
|
+
|
|
11
|
+
export const SelectSearchInput = React.forwardRef<
|
|
12
|
+
React.ElementRef<typeof TextInput>,
|
|
13
|
+
SelectSearchInputProps
|
|
14
|
+
>(
|
|
15
|
+
(
|
|
16
|
+
{
|
|
17
|
+
className,
|
|
18
|
+
inputClassName,
|
|
19
|
+
clearButtonClassName,
|
|
20
|
+
clearLabel = 'Clear search',
|
|
21
|
+
isClearable = true,
|
|
22
|
+
placeholder = 'Search...',
|
|
23
|
+
value,
|
|
24
|
+
onChangeText,
|
|
25
|
+
style,
|
|
26
|
+
...props
|
|
27
|
+
},
|
|
28
|
+
ref,
|
|
29
|
+
) => {
|
|
30
|
+
const { size, searchValue, onSearchChange, isDisabled } = useSelectContext();
|
|
31
|
+
const resolvedValue = value ?? searchValue;
|
|
32
|
+
|
|
33
|
+
const handleChangeText = (nextValue: string) => {
|
|
34
|
+
onSearchChange(nextValue);
|
|
35
|
+
onChangeText?.(nextValue);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleClear = () => {
|
|
39
|
+
handleChangeText('');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<View className={selectSearchInputStyle({ size, class: className })}>
|
|
44
|
+
<TextInput
|
|
45
|
+
ref={ref}
|
|
46
|
+
value={resolvedValue}
|
|
47
|
+
onChangeText={handleChangeText}
|
|
48
|
+
editable={!isDisabled}
|
|
49
|
+
readOnly={isDisabled}
|
|
50
|
+
placeholder={placeholder}
|
|
51
|
+
placeholderTextColor="rgb(var(--color-typography-400))"
|
|
52
|
+
className={selectSearchInputFieldStyle({
|
|
53
|
+
size,
|
|
54
|
+
class: inputClassName,
|
|
55
|
+
})}
|
|
56
|
+
{...props}
|
|
57
|
+
style={[{ backgroundColor: 'transparent' }, style]}
|
|
58
|
+
/>
|
|
59
|
+
{isClearable && resolvedValue ? (
|
|
60
|
+
<Pressable
|
|
61
|
+
onPress={handleClear}
|
|
62
|
+
className={selectSearchClearButtonStyle({
|
|
63
|
+
size,
|
|
64
|
+
class: clearButtonClassName,
|
|
65
|
+
})}
|
|
66
|
+
accessibilityRole="button"
|
|
67
|
+
accessibilityLabel={clearLabel}
|
|
68
|
+
>
|
|
69
|
+
<Text className="text-typography-400">{'\u2715'}</Text>
|
|
70
|
+
</Pressable>
|
|
71
|
+
) : null}
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
SelectSearchInput.displayName = 'SelectSearchInput';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, Text, View, type GestureResponderEvent } from 'react-native';
|
|
3
|
+
import { useSelectContext } from './select';
|
|
4
|
+
import type { SelectSelectedBadgesProps } from './types';
|
|
5
|
+
import {
|
|
6
|
+
selectSelectedBadgeCloseButtonStyle,
|
|
7
|
+
selectSelectedBadgeStyle,
|
|
8
|
+
selectSelectedBadgeTextStyle,
|
|
9
|
+
selectSelectedBadgesStyle,
|
|
10
|
+
} from './styles';
|
|
11
|
+
|
|
12
|
+
export const SelectSelectedBadges = React.forwardRef<
|
|
13
|
+
React.ElementRef<typeof View>,
|
|
14
|
+
SelectSelectedBadgesProps
|
|
15
|
+
>(
|
|
16
|
+
(
|
|
17
|
+
{
|
|
18
|
+
className,
|
|
19
|
+
badgeClassName,
|
|
20
|
+
badgeTextClassName,
|
|
21
|
+
badgeCloseButtonClassName,
|
|
22
|
+
maxVisible,
|
|
23
|
+
showRemoveButton = true,
|
|
24
|
+
overflowLabel = (count) => `+${count}`,
|
|
25
|
+
renderBadge,
|
|
26
|
+
...props
|
|
27
|
+
},
|
|
28
|
+
ref,
|
|
29
|
+
) => {
|
|
30
|
+
const { selectedItems, onValueRemove, size, isDisabled } = useSelectContext();
|
|
31
|
+
const visibleItems =
|
|
32
|
+
maxVisible === undefined ? selectedItems : selectedItems.slice(0, maxVisible);
|
|
33
|
+
const overflowCount =
|
|
34
|
+
maxVisible === undefined
|
|
35
|
+
? 0
|
|
36
|
+
: Math.max(selectedItems.length - maxVisible, 0);
|
|
37
|
+
|
|
38
|
+
if (selectedItems.length === 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<View
|
|
44
|
+
ref={ref}
|
|
45
|
+
className={selectSelectedBadgesStyle({ class: className })}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
{visibleItems.map((item) => {
|
|
49
|
+
const remove = () => onValueRemove(item.value);
|
|
50
|
+
const handleRemove = (event: GestureResponderEvent) => {
|
|
51
|
+
event.stopPropagation?.();
|
|
52
|
+
remove();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (renderBadge) {
|
|
56
|
+
return (
|
|
57
|
+
<React.Fragment key={item.value}>
|
|
58
|
+
{renderBadge(item, { remove, isDisabled })}
|
|
59
|
+
</React.Fragment>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<View
|
|
65
|
+
key={item.value}
|
|
66
|
+
className={selectSelectedBadgeStyle({
|
|
67
|
+
isDisabled,
|
|
68
|
+
class: badgeClassName,
|
|
69
|
+
})}
|
|
70
|
+
>
|
|
71
|
+
<Text
|
|
72
|
+
numberOfLines={1}
|
|
73
|
+
className={selectSelectedBadgeTextStyle({
|
|
74
|
+
size,
|
|
75
|
+
class: badgeTextClassName,
|
|
76
|
+
})}
|
|
77
|
+
>
|
|
78
|
+
{item.label}
|
|
79
|
+
</Text>
|
|
80
|
+
{showRemoveButton ? (
|
|
81
|
+
<Pressable
|
|
82
|
+
disabled={isDisabled}
|
|
83
|
+
onPress={handleRemove}
|
|
84
|
+
className={selectSelectedBadgeCloseButtonStyle({
|
|
85
|
+
size,
|
|
86
|
+
class: badgeCloseButtonClassName,
|
|
87
|
+
})}
|
|
88
|
+
accessibilityRole="button"
|
|
89
|
+
accessibilityLabel={`Remove ${item.label}`}
|
|
90
|
+
>
|
|
91
|
+
<Text className="text-typography-400">{'\u2715'}</Text>
|
|
92
|
+
</Pressable>
|
|
93
|
+
) : null}
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
{overflowCount > 0 ? (
|
|
98
|
+
<View
|
|
99
|
+
className={selectSelectedBadgeStyle({
|
|
100
|
+
isDisabled,
|
|
101
|
+
class: badgeClassName,
|
|
102
|
+
})}
|
|
103
|
+
>
|
|
104
|
+
<Text
|
|
105
|
+
className={selectSelectedBadgeTextStyle({
|
|
106
|
+
size,
|
|
107
|
+
class: badgeTextClassName,
|
|
108
|
+
})}
|
|
109
|
+
>
|
|
110
|
+
{overflowLabel(overflowCount)}
|
|
111
|
+
</Text>
|
|
112
|
+
</View>
|
|
113
|
+
) : null}
|
|
114
|
+
</View>
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
SelectSelectedBadges.displayName = 'SelectSelectedBadges';
|
package/src/select/select.tsx
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useState,
|
|
3
|
+
useCallback,
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useMemo,
|
|
7
|
+
} from 'react';
|
|
2
8
|
import { View } from 'react-native';
|
|
3
9
|
import { useFormControlContext } from '../form-control/form-control';
|
|
4
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
SelectProps,
|
|
12
|
+
SelectContextValue,
|
|
13
|
+
SelectSize,
|
|
14
|
+
TriggerLayout,
|
|
15
|
+
SelectOption,
|
|
16
|
+
} from './types';
|
|
5
17
|
|
|
6
18
|
export const SelectContext = createContext<SelectContextValue | null>(null);
|
|
7
19
|
|
|
@@ -22,9 +34,20 @@ export const Select = React.forwardRef<
|
|
|
22
34
|
(
|
|
23
35
|
{
|
|
24
36
|
className,
|
|
37
|
+
isMulti = false,
|
|
25
38
|
selectedValue: controlledValue,
|
|
26
39
|
defaultValue,
|
|
27
40
|
onValueChange: onValueChangeProp,
|
|
41
|
+
selectedValues: controlledValues,
|
|
42
|
+
defaultValues = [],
|
|
43
|
+
onValuesChange,
|
|
44
|
+
valueLabels = {},
|
|
45
|
+
closeOnSelect,
|
|
46
|
+
searchValue: controlledSearchValue,
|
|
47
|
+
defaultSearchValue = '',
|
|
48
|
+
onSearchChange,
|
|
49
|
+
filterOption,
|
|
50
|
+
formatSelectedLabel,
|
|
28
51
|
size = 'md',
|
|
29
52
|
variant = 'outline',
|
|
30
53
|
isDisabled: isDisabledProp,
|
|
@@ -38,7 +61,12 @@ export const Select = React.forwardRef<
|
|
|
38
61
|
const [internalValue, setInternalValue] = useState<string | null>(
|
|
39
62
|
defaultValue ?? null,
|
|
40
63
|
);
|
|
64
|
+
const [internalValues, setInternalValues] = useState<string[]>(defaultValues);
|
|
41
65
|
const [internalLabel, setInternalLabel] = useState('');
|
|
66
|
+
const [registeredLabels, setRegisteredLabels] =
|
|
67
|
+
useState<Record<string, string>>({});
|
|
68
|
+
const [internalSearchValue, setInternalSearchValue] =
|
|
69
|
+
useState(defaultSearchValue);
|
|
42
70
|
const [triggerLayout, setTriggerLayout] = useState<TriggerLayout | null>(null);
|
|
43
71
|
|
|
44
72
|
const formControl = useFormControlContext();
|
|
@@ -49,8 +77,51 @@ export const Select = React.forwardRef<
|
|
|
49
77
|
|
|
50
78
|
const isControlled = controlledValue !== undefined;
|
|
51
79
|
const selectedValue = isControlled ? controlledValue : internalValue;
|
|
80
|
+
const isMultiControlled = controlledValues !== undefined;
|
|
81
|
+
const selectedValues = isMulti
|
|
82
|
+
? isMultiControlled
|
|
83
|
+
? controlledValues
|
|
84
|
+
: internalValues
|
|
85
|
+
: selectedValue
|
|
86
|
+
? [selectedValue]
|
|
87
|
+
: [];
|
|
88
|
+
const isSearchControlled = controlledSearchValue !== undefined;
|
|
89
|
+
const searchValue = isSearchControlled
|
|
90
|
+
? controlledSearchValue
|
|
91
|
+
: internalSearchValue;
|
|
92
|
+
const shouldCloseOnSelect = closeOnSelect ?? !isMulti;
|
|
52
93
|
|
|
53
|
-
const
|
|
94
|
+
const getLabel = useCallback(
|
|
95
|
+
(value: string) => valueLabels[value] ?? registeredLabels[value] ?? value,
|
|
96
|
+
[registeredLabels, valueLabels],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const selectedItems = useMemo<SelectOption[]>(
|
|
100
|
+
() => selectedValues.map((value) => ({ value, label: getLabel(value) })),
|
|
101
|
+
[getLabel, selectedValues],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const selectedLabel = useMemo(() => {
|
|
105
|
+
if (isMulti) {
|
|
106
|
+
if (formatSelectedLabel) {
|
|
107
|
+
return formatSelectedLabel(selectedItems);
|
|
108
|
+
}
|
|
109
|
+
if (selectedItems.length === 0) return '';
|
|
110
|
+
if (selectedItems.length === 1) return selectedItems[0]?.label ?? '';
|
|
111
|
+
return `${selectedItems.length} items selected`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return selectedValue
|
|
115
|
+
? internalLabel || getLabel(selectedValue)
|
|
116
|
+
: '';
|
|
117
|
+
}, [
|
|
118
|
+
formatSelectedLabel,
|
|
119
|
+
getLabel,
|
|
120
|
+
internalLabel,
|
|
121
|
+
isMulti,
|
|
122
|
+
selectedItems,
|
|
123
|
+
selectedValue,
|
|
124
|
+
]);
|
|
54
125
|
|
|
55
126
|
const onOpen = useCallback(() => {
|
|
56
127
|
if (!isDisabled) {
|
|
@@ -64,21 +135,121 @@ export const Select = React.forwardRef<
|
|
|
64
135
|
|
|
65
136
|
const onValueChange = useCallback(
|
|
66
137
|
(value: string, label: string) => {
|
|
138
|
+
setRegisteredLabels((current) =>
|
|
139
|
+
current[value] === label ? current : { ...current, [value]: label },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (isMulti) {
|
|
143
|
+
const nextValues = selectedValues.includes(value)
|
|
144
|
+
? selectedValues.filter((selected) => selected !== value)
|
|
145
|
+
: [...selectedValues, value];
|
|
146
|
+
|
|
147
|
+
if (!isMultiControlled) {
|
|
148
|
+
setInternalValues(nextValues);
|
|
149
|
+
}
|
|
150
|
+
onValuesChange?.(nextValues);
|
|
151
|
+
} else {
|
|
152
|
+
if (!isControlled) {
|
|
153
|
+
setInternalValue(value);
|
|
154
|
+
}
|
|
155
|
+
setInternalLabel(label);
|
|
156
|
+
onValueChangeProp?.(value);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (shouldCloseOnSelect) {
|
|
160
|
+
setIsOpen(false);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
[
|
|
164
|
+
isControlled,
|
|
165
|
+
isMulti,
|
|
166
|
+
isMultiControlled,
|
|
167
|
+
onValueChangeProp,
|
|
168
|
+
onValuesChange,
|
|
169
|
+
selectedValues,
|
|
170
|
+
shouldCloseOnSelect,
|
|
171
|
+
],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const onValueRemove = useCallback(
|
|
175
|
+
(value: string) => {
|
|
176
|
+
if (isMulti) {
|
|
177
|
+
const nextValues = selectedValues.filter((selected) => selected !== value);
|
|
178
|
+
if (!isMultiControlled) {
|
|
179
|
+
setInternalValues(nextValues);
|
|
180
|
+
}
|
|
181
|
+
onValuesChange?.(nextValues);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
67
185
|
if (!isControlled) {
|
|
68
|
-
setInternalValue(
|
|
186
|
+
setInternalValue(null);
|
|
69
187
|
}
|
|
70
|
-
setInternalLabel(
|
|
71
|
-
onValueChangeProp?.(
|
|
188
|
+
setInternalLabel('');
|
|
189
|
+
onValueChangeProp?.('');
|
|
190
|
+
},
|
|
191
|
+
[
|
|
192
|
+
isControlled,
|
|
193
|
+
isMulti,
|
|
194
|
+
isMultiControlled,
|
|
195
|
+
onValueChangeProp,
|
|
196
|
+
onValuesChange,
|
|
197
|
+
selectedValues,
|
|
198
|
+
],
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const handleSearchChange = useCallback(
|
|
202
|
+
(value: string) => {
|
|
203
|
+
if (!isSearchControlled) {
|
|
204
|
+
setInternalSearchValue(value);
|
|
205
|
+
}
|
|
206
|
+
onSearchChange?.(value);
|
|
207
|
+
},
|
|
208
|
+
[isSearchControlled, onSearchChange],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const isValueSelected = useCallback(
|
|
212
|
+
(value: string) => selectedValues.includes(value),
|
|
213
|
+
[selectedValues],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const shouldShowItem = useCallback(
|
|
217
|
+
(value: string, label: string) => {
|
|
218
|
+
const normalizedSearch = searchValue.trim().toLowerCase();
|
|
219
|
+
if (!normalizedSearch) return true;
|
|
220
|
+
|
|
221
|
+
if (filterOption) {
|
|
222
|
+
return filterOption(searchValue, { value, label });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
label.toLowerCase().includes(normalizedSearch) ||
|
|
227
|
+
value.toLowerCase().includes(normalizedSearch)
|
|
228
|
+
);
|
|
229
|
+
},
|
|
230
|
+
[filterOption, searchValue],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const registerItem = useCallback(
|
|
234
|
+
(value: string, label: string) => {
|
|
235
|
+
setRegisteredLabels((current) =>
|
|
236
|
+
current[value] === label ? current : { ...current, [value]: label },
|
|
237
|
+
);
|
|
72
238
|
},
|
|
73
|
-
[
|
|
239
|
+
[],
|
|
74
240
|
);
|
|
75
241
|
|
|
76
242
|
return (
|
|
77
243
|
<SelectContext.Provider
|
|
78
244
|
value={{
|
|
79
245
|
isOpen,
|
|
246
|
+
isMulti,
|
|
80
247
|
selectedValue: selectedValue ?? null,
|
|
248
|
+
selectedValues,
|
|
81
249
|
selectedLabel,
|
|
250
|
+
selectedItems,
|
|
251
|
+
searchValue,
|
|
252
|
+
placeholder,
|
|
82
253
|
size: resolvedSize,
|
|
83
254
|
variant,
|
|
84
255
|
isDisabled,
|
|
@@ -87,6 +258,11 @@ export const Select = React.forwardRef<
|
|
|
87
258
|
onOpen,
|
|
88
259
|
onClose,
|
|
89
260
|
onValueChange,
|
|
261
|
+
onValueRemove,
|
|
262
|
+
onSearchChange: handleSearchChange,
|
|
263
|
+
isValueSelected,
|
|
264
|
+
shouldShowItem,
|
|
265
|
+
registerItem,
|
|
90
266
|
setTriggerLayout,
|
|
91
267
|
}}
|
|
92
268
|
>
|
package/src/select/styles.ts
CHANGED
|
@@ -56,6 +56,94 @@ export const selectInputStyle = tv({
|
|
|
56
56
|
},
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
export const selectSelectedBadgesStyle = tv({
|
|
60
|
+
base: 'flex-row flex-wrap items-center gap-1.5 flex-1',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const selectSelectedBadgeStyle = tv({
|
|
64
|
+
base: 'flex-row items-center rounded-full border border-outline-200 bg-background-0 px-2 py-1 shadow-soft-1',
|
|
65
|
+
variants: {
|
|
66
|
+
isDisabled: {
|
|
67
|
+
true: 'opacity-50',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const selectSelectedBadgeTextStyle = tv({
|
|
73
|
+
base: 'font-medium text-typography-900',
|
|
74
|
+
variants: {
|
|
75
|
+
size: {
|
|
76
|
+
sm: 'text-xs',
|
|
77
|
+
md: 'text-sm',
|
|
78
|
+
lg: 'text-base',
|
|
79
|
+
xl: 'text-base',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
defaultVariants: {
|
|
83
|
+
size: 'md',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const selectSelectedBadgeCloseButtonStyle = tv({
|
|
88
|
+
base: 'ml-1.5 rounded-full items-center justify-center',
|
|
89
|
+
variants: {
|
|
90
|
+
size: {
|
|
91
|
+
sm: 'h-4 w-4',
|
|
92
|
+
md: 'h-5 w-5',
|
|
93
|
+
lg: 'h-5 w-5',
|
|
94
|
+
xl: 'h-6 w-6',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
defaultVariants: {
|
|
98
|
+
size: 'md',
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export const selectSearchInputStyle = tv({
|
|
103
|
+
base: 'mx-2 my-2 flex-row items-center rounded-md border border-outline-200 bg-background-0 px-3',
|
|
104
|
+
variants: {
|
|
105
|
+
size: {
|
|
106
|
+
sm: 'h-8',
|
|
107
|
+
md: 'h-9',
|
|
108
|
+
lg: 'h-10',
|
|
109
|
+
xl: 'h-11',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
defaultVariants: {
|
|
113
|
+
size: 'md',
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export const selectSearchInputFieldStyle = tv({
|
|
118
|
+
base: 'flex-1 text-typography-900',
|
|
119
|
+
variants: {
|
|
120
|
+
size: {
|
|
121
|
+
sm: 'text-sm',
|
|
122
|
+
md: 'text-base',
|
|
123
|
+
lg: 'text-lg',
|
|
124
|
+
xl: 'text-xl',
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
defaultVariants: {
|
|
128
|
+
size: 'md',
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export const selectSearchClearButtonStyle = tv({
|
|
133
|
+
base: 'ml-2 rounded-full items-center justify-center',
|
|
134
|
+
variants: {
|
|
135
|
+
size: {
|
|
136
|
+
sm: 'h-5 w-5',
|
|
137
|
+
md: 'h-6 w-6',
|
|
138
|
+
lg: 'h-6 w-6',
|
|
139
|
+
xl: 'h-7 w-7',
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
defaultVariants: {
|
|
143
|
+
size: 'md',
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
59
147
|
export const selectIconStyle = tv({
|
|
60
148
|
base: 'text-typography-400 ml-2',
|
|
61
149
|
variants: {
|
package/src/select/types.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
View,
|
|
3
|
+
Text as RNText,
|
|
4
|
+
TextInput,
|
|
5
|
+
Pressable,
|
|
6
|
+
ScrollView,
|
|
7
|
+
} from 'react-native';
|
|
2
8
|
|
|
3
9
|
export type SelectSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
4
10
|
export type SelectVariant = 'outline' | 'filled' | 'underlined' | 'rounded';
|
|
5
11
|
|
|
12
|
+
export interface SelectOption {
|
|
13
|
+
value: string;
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
6
17
|
export interface TriggerLayout {
|
|
7
18
|
pageX: number;
|
|
8
19
|
pageY: number;
|
|
@@ -12,8 +23,13 @@ export interface TriggerLayout {
|
|
|
12
23
|
|
|
13
24
|
export interface SelectContextValue {
|
|
14
25
|
isOpen: boolean;
|
|
26
|
+
isMulti: boolean;
|
|
15
27
|
selectedValue: string | null;
|
|
28
|
+
selectedValues: string[];
|
|
16
29
|
selectedLabel: string;
|
|
30
|
+
selectedItems: SelectOption[];
|
|
31
|
+
searchValue: string;
|
|
32
|
+
placeholder: string;
|
|
17
33
|
size: SelectSize;
|
|
18
34
|
variant: SelectVariant;
|
|
19
35
|
isDisabled: boolean;
|
|
@@ -22,14 +38,30 @@ export interface SelectContextValue {
|
|
|
22
38
|
onOpen: () => void;
|
|
23
39
|
onClose: () => void;
|
|
24
40
|
onValueChange: (value: string, label: string) => void;
|
|
41
|
+
onValueRemove: (value: string) => void;
|
|
42
|
+
onSearchChange: (value: string) => void;
|
|
43
|
+
isValueSelected: (value: string) => boolean;
|
|
44
|
+
shouldShowItem: (value: string, label: string) => boolean;
|
|
45
|
+
registerItem: (value: string, label: string) => void;
|
|
25
46
|
setTriggerLayout: (layout: TriggerLayout) => void;
|
|
26
47
|
}
|
|
27
48
|
|
|
28
49
|
export interface SelectProps {
|
|
29
50
|
className?: string;
|
|
51
|
+
isMulti?: boolean;
|
|
30
52
|
selectedValue?: string;
|
|
31
53
|
defaultValue?: string;
|
|
32
54
|
onValueChange?: (value: string) => void;
|
|
55
|
+
selectedValues?: string[];
|
|
56
|
+
defaultValues?: string[];
|
|
57
|
+
onValuesChange?: (values: string[]) => void;
|
|
58
|
+
valueLabels?: Record<string, string>;
|
|
59
|
+
closeOnSelect?: boolean;
|
|
60
|
+
searchValue?: string;
|
|
61
|
+
defaultSearchValue?: string;
|
|
62
|
+
onSearchChange?: (value: string) => void;
|
|
63
|
+
filterOption?: (searchValue: string, option: SelectOption) => boolean;
|
|
64
|
+
formatSelectedLabel?: (items: SelectOption[]) => string;
|
|
33
65
|
size?: SelectSize;
|
|
34
66
|
variant?: SelectVariant;
|
|
35
67
|
isDisabled?: boolean;
|
|
@@ -81,6 +113,30 @@ export interface SelectItemTextProps
|
|
|
81
113
|
className?: string;
|
|
82
114
|
}
|
|
83
115
|
|
|
116
|
+
export interface SelectSearchInputProps
|
|
117
|
+
extends React.ComponentPropsWithoutRef<typeof TextInput> {
|
|
118
|
+
className?: string;
|
|
119
|
+
inputClassName?: string;
|
|
120
|
+
clearButtonClassName?: string;
|
|
121
|
+
clearLabel?: string;
|
|
122
|
+
isClearable?: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface SelectSelectedBadgesProps
|
|
126
|
+
extends React.ComponentPropsWithoutRef<typeof View> {
|
|
127
|
+
className?: string;
|
|
128
|
+
badgeClassName?: string;
|
|
129
|
+
badgeTextClassName?: string;
|
|
130
|
+
badgeCloseButtonClassName?: string;
|
|
131
|
+
maxVisible?: number;
|
|
132
|
+
showRemoveButton?: boolean;
|
|
133
|
+
overflowLabel?: (count: number) => string;
|
|
134
|
+
renderBadge?: (
|
|
135
|
+
item: SelectOption,
|
|
136
|
+
helpers: { remove: () => void; isDisabled: boolean },
|
|
137
|
+
) => React.ReactNode;
|
|
138
|
+
}
|
|
139
|
+
|
|
84
140
|
export interface SelectDragIndicatorProps
|
|
85
141
|
extends React.ComponentPropsWithoutRef<typeof View> {
|
|
86
142
|
className?: string;
|