@helpwave/hightide-native 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/components/card/Card.tsx +2 -4
- package/src/components/chat/ChatAttachmentMessageBubble.tsx +155 -94
- package/src/components/chat/ChatConversationList.tsx +4 -12
- package/src/components/chat/ChatConversationRow.tsx +159 -164
- package/src/components/chat/ChatDateDivider.tsx +3 -8
- package/src/components/chat/ChatMessageBubble.tsx +54 -52
- package/src/components/chat/ChatMessageComposer.tsx +4 -12
- package/src/components/chat/ChatMessageList.tsx +3 -9
- package/src/components/chat/ChatQuickReplyChip.tsx +41 -27
- package/src/components/chat/ChatSystemLine.tsx +4 -12
- package/src/components/chat/ChatThreadHeader.tsx +136 -101
- package/src/components/layout/Divider.tsx +2 -4
- package/src/components/list/ListActionItem.tsx +57 -65
- package/src/components/list/ListItem.tsx +8 -28
- package/src/components/list/ListItemTextContent.tsx +4 -2
- package/src/components/list/ListNavigationItem.tsx +55 -63
- package/src/components/user-interaction/Button.tsx +43 -49
- package/src/components/user-interaction/Checkbox.tsx +47 -48
- package/src/components/user-interaction/IconButton.tsx +40 -42
- package/src/components/user-interaction/Input.tsx +70 -67
- package/src/components/user-interaction/MultiSelect/MultiSelect.tsx +40 -0
- package/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx +70 -0
- package/src/components/user-interaction/MultiSelect/MultiSelectMenu.tsx +87 -0
- package/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx +87 -0
- package/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx +194 -0
- package/src/components/user-interaction/MultiSelect/MultiSelectTrigger.tsx +123 -0
- package/src/components/user-interaction/MultiSelect/index.ts +6 -0
- package/src/components/user-interaction/SearchBar.tsx +11 -17
- package/src/components/user-interaction/Select/Select.tsx +40 -0
- package/src/components/user-interaction/Select/SelectContext.tsx +69 -0
- package/src/components/user-interaction/Select/SelectMenu.tsx +87 -0
- package/src/components/user-interaction/Select/SelectOption.tsx +77 -0
- package/src/components/user-interaction/Select/SelectRoot.tsx +186 -0
- package/src/components/user-interaction/Select/SelectTrigger.tsx +92 -0
- package/src/components/user-interaction/Select/index.ts +6 -0
- package/src/components/user-interaction/Switch.tsx +46 -98
- package/src/components/user-interaction/Textarea.tsx +179 -0
- package/src/components/user-interaction/ThemedPressable.tsx +48 -49
- package/src/components/user-interaction/index.ts +1 -0
- package/src/components/visualization-and-display/Avatar.tsx +19 -17
- package/src/components/visualization-and-display/Chip.tsx +4 -12
- package/src/components/visualization-and-display/ThemedText.tsx +12 -5
- package/src/enums/chatMessageDirection.ts +18 -0
- package/src/enums/chatMessageStatus.ts +18 -0
- package/src/enums/hightideThemeModes.ts +18 -0
- package/src/enums/index.ts +7 -0
- package/src/enums/listItemContentOrder.ts +18 -0
- package/src/enums/themedTextAppearance.ts +18 -0
- package/src/enums/useMultiSelectFirstHighlightBehavior.ts +18 -0
- package/src/enums/useSelectFirstHighlightBehavior.ts +18 -0
- package/src/global-contexts/content-theme/ContentThemeProvider.tsx +9 -5
- package/src/global-contexts/theme/ThemeProvider.tsx +4 -1
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useAnimatedStyleTransition.ts +460 -0
- package/src/hooks/useMemoizedTheme.ts +23 -0
- package/src/hooks/useMultiSelect.ts +3 -1
- package/src/hooks/useSelect.ts +3 -1
- package/src/icons/HightideIconRegistry.ts +2 -0
- package/src/theme/adapters/style-adapter-utils.ts +121 -27
- package/src/theme/resolvers/avatar.ts +48 -39
- package/src/theme/resolvers/button.ts +7 -17
- package/src/theme/resolvers/chat/attachment-message-bubble.ts +5 -6
- package/src/theme/resolvers/chat/conversation-row.ts +4 -6
- package/src/theme/resolvers/chat/message-bubble.ts +1 -2
- package/src/theme/resolvers/chat/message-composer.ts +1 -2
- package/src/theme/resolvers/chat/quick-reply-chip.ts +2 -3
- package/src/theme/resolvers/chat/system-line.ts +1 -2
- package/src/theme/resolvers/chat/thread-header.ts +3 -4
- package/src/theme/resolvers/checkbox.ts +2 -2
- package/src/theme/resolvers/chip.ts +1 -2
- package/src/theme/resolvers/iconButton.ts +4 -13
- package/src/theme/resolvers/index.ts +1 -0
- package/src/theme/resolvers/input.ts +30 -14
- package/src/theme/resolvers/listItem.ts +2 -3
- package/src/theme/resolvers/multiSelect.ts +1 -3
- package/src/theme/resolvers/searchBar.ts +1 -2
- package/src/theme/resolvers/select.ts +1 -2
- package/src/theme/resolvers/textarea.ts +14 -0
- package/src/theme/resolvers/themedPressable.ts +4 -15
- package/src/theme/themes/createHightideTheme.ts +6 -0
- package/src/theme/themes/hightideThemes.ts +3 -1
- package/src/theme/types/components/chat.ts +6 -21
- package/src/theme/types/components/hightide.ts +2 -0
- package/src/theme/types/components/index.ts +1 -0
- package/src/theme/types/components/input.ts +3 -0
- package/src/theme/types/components/textarea.ts +9 -0
- package/src/theme/types/resolver.ts +4 -27
- package/src/utils/index.ts +1 -0
- package/src/utils/pressableInteraction.ts +7 -0
- package/src/components/user-interaction/MultiSelect.tsx +0 -281
- package/src/components/user-interaction/Select.tsx +0 -242
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useMemo, useState } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
View,
|
|
5
|
+
type StyleProp,
|
|
6
|
+
type ViewStyle
|
|
7
|
+
} from 'react-native'
|
|
8
|
+
|
|
9
|
+
import { useTranslation } from '@helpwave/hightide-utils/context'
|
|
10
|
+
|
|
11
|
+
import { useTheme } from '../../../global-contexts/theme/ThemeContext'
|
|
12
|
+
import { useMemoizedTheme } from '../../../hooks/useMemoizedTheme'
|
|
13
|
+
import { HightideIconRegistry } from '../../../icons/HightideIconRegistry'
|
|
14
|
+
import type { MultiSelectState } from '../../../theme/types/components/multiSelect'
|
|
15
|
+
import { Chip } from '../../visualization-and-display/Chip'
|
|
16
|
+
import { ThemedText } from '../../visualization-and-display/ThemedText'
|
|
17
|
+
import { IconButton } from '../IconButton'
|
|
18
|
+
import { useMultiSelectContext, type MultiSelectOptionType } from './MultiSelectContext'
|
|
19
|
+
import { ThemedPressable } from '../ThemedPressable'
|
|
20
|
+
|
|
21
|
+
export type MultiSelectTriggerProps<T = string> = {
|
|
22
|
+
placeholder?: ReactNode,
|
|
23
|
+
selectedDisplay?: (options: ReadonlyArray<MultiSelectOptionType<T>>) => ReactNode,
|
|
24
|
+
style?: StyleProp<ViewStyle>,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const MultiSelectTrigger = <T,>({
|
|
28
|
+
placeholder = 'Select…',
|
|
29
|
+
selectedDisplay,
|
|
30
|
+
style,
|
|
31
|
+
}: MultiSelectTriggerProps<T>) => {
|
|
32
|
+
const { theme } = useTheme()
|
|
33
|
+
const translation = useTranslation()
|
|
34
|
+
const context = useMultiSelectContext<T>()
|
|
35
|
+
const [isPressed, setIsPressed] = useState(false)
|
|
36
|
+
const interactive = !context.disabled && !context.readOnly
|
|
37
|
+
const selectedOptions = context.selectedIds
|
|
38
|
+
.map((id) => context.idToOptionMap[id])
|
|
39
|
+
.filter((option): option is MultiSelectOptionType<T> => option !== undefined)
|
|
40
|
+
|
|
41
|
+
const resolvedState = useMemo((): MultiSelectState => ({
|
|
42
|
+
color: context.config.color,
|
|
43
|
+
isDisabled: !!context.disabled,
|
|
44
|
+
isReadonly: !!context.readOnly,
|
|
45
|
+
isInvalid: !!context.invalid,
|
|
46
|
+
isOpen: context.isOpen,
|
|
47
|
+
hasSelections: selectedOptions.length > 0,
|
|
48
|
+
hasValue: selectedOptions.length > 0,
|
|
49
|
+
isPressed,
|
|
50
|
+
}), [
|
|
51
|
+
context.config.color,
|
|
52
|
+
context.disabled,
|
|
53
|
+
context.invalid,
|
|
54
|
+
context.isOpen,
|
|
55
|
+
context.readOnly,
|
|
56
|
+
isPressed,
|
|
57
|
+
selectedOptions.length,
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
const multiSelectTheme = theme.components.multiSelect
|
|
61
|
+
const resolvedTriggerStyle = useMemoizedTheme(multiSelectTheme.trigger, resolvedState)
|
|
62
|
+
const resolvedStateLayerStyle = useMemoizedTheme(multiSelectTheme.stateLayer, resolvedState)
|
|
63
|
+
const resolvedTriggerTextStyle = useMemoizedTheme(multiSelectTheme.triggerText, resolvedState)
|
|
64
|
+
const iconSizeSm = theme.icongraphy.sizes.sm
|
|
65
|
+
const customDisplay = selectedDisplay?.(selectedOptions)
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<ThemedPressable
|
|
69
|
+
disabled={!interactive}
|
|
70
|
+
style={[resolvedTriggerStyle, style]}
|
|
71
|
+
onPressIn={() => setIsPressed(true)}
|
|
72
|
+
onPressOut={() => setIsPressed(false)}
|
|
73
|
+
onPress={() => context.toggleIsOpen()}
|
|
74
|
+
>
|
|
75
|
+
<View pointerEvents="none" style={resolvedStateLayerStyle} />
|
|
76
|
+
{selectedDisplay ? (
|
|
77
|
+
customDisplay
|
|
78
|
+
) : selectedOptions.length > 0 ? (
|
|
79
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
|
80
|
+
{selectedOptions.map((option) => (
|
|
81
|
+
<Chip key={option.id} size="md" color={context.config.color} variant="tonal">
|
|
82
|
+
<ThemedText>{option.label ?? String(option.value)}</ThemedText>
|
|
83
|
+
{!context.readOnly && (
|
|
84
|
+
<View
|
|
85
|
+
style={{
|
|
86
|
+
position: 'relative',
|
|
87
|
+
width: iconSizeSm,
|
|
88
|
+
height: iconSizeSm,
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<IconButton
|
|
92
|
+
accessibilityLabel={translation('remove')}
|
|
93
|
+
size="sm"
|
|
94
|
+
color={theme.colors.negative}
|
|
95
|
+
variant="foreground"
|
|
96
|
+
disabled={!interactive}
|
|
97
|
+
onPress={() => context.toggleSelection(option.id, false)}
|
|
98
|
+
style={{
|
|
99
|
+
position: 'absolute',
|
|
100
|
+
left: '50%',
|
|
101
|
+
top: '50%',
|
|
102
|
+
transform: [
|
|
103
|
+
{ translateX: '-50%' },
|
|
104
|
+
{ translateY: '-50%' },
|
|
105
|
+
],
|
|
106
|
+
}}
|
|
107
|
+
icon={HightideIconRegistry.X}
|
|
108
|
+
/>
|
|
109
|
+
</View>
|
|
110
|
+
)}
|
|
111
|
+
</Chip>
|
|
112
|
+
))}
|
|
113
|
+
</View>
|
|
114
|
+
) : typeof placeholder === 'string' || typeof placeholder === 'number' ? (
|
|
115
|
+
<ThemedText style={resolvedTriggerTextStyle}>
|
|
116
|
+
{placeholder}
|
|
117
|
+
</ThemedText>
|
|
118
|
+
) : (
|
|
119
|
+
placeholder
|
|
120
|
+
)}
|
|
121
|
+
</ThemedPressable>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -22,6 +22,7 @@ import { useTranslation } from '@helpwave/hightide-utils/context'
|
|
|
22
22
|
import type { ColorPairToken } from '@helpwave/hightide-design/theme-tokens'
|
|
23
23
|
import { useTheme } from '../../global-contexts/theme/ThemeContext'
|
|
24
24
|
import { HightideIconRegistry } from '../../icons/HightideIconRegistry'
|
|
25
|
+
import { useMemoizedTheme } from '../../hooks/useMemoizedTheme'
|
|
25
26
|
import type {
|
|
26
27
|
SearchBarContainerStyle,
|
|
27
28
|
SearchBarIconButtonStyle,
|
|
@@ -124,22 +125,15 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(function SearchBa
|
|
|
124
125
|
}), [color, disabled, invalid, readOnly, interactive, isHovered, isPressed, isFocused])
|
|
125
126
|
|
|
126
127
|
const searchBarTheme = theme.components.searchBar
|
|
127
|
-
const resolvedContainerStyle =
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
)
|
|
135
|
-
const resolvedPlaceholderStyle = useMemo(
|
|
136
|
-
() => searchBarTheme.placeholder(state),
|
|
137
|
-
[searchBarTheme, state]
|
|
138
|
-
)
|
|
139
|
-
const resolvedIconButtonStyle = useMemo(
|
|
140
|
-
() => searchBarTheme.iconButton(state, iconButtonStyle),
|
|
141
|
-
[searchBarTheme, state, iconButtonStyle]
|
|
128
|
+
const resolvedContainerStyle = useMemoizedTheme(searchBarTheme.container, state, containerStyle)
|
|
129
|
+
const resolvedInputStyle = useMemoizedTheme(searchBarTheme.input, state, inputStyle)
|
|
130
|
+
const resolvedPlaceholderStyle = useMemoizedTheme(searchBarTheme.placeholder, state)
|
|
131
|
+
const resolvedIconButtonStyle = useMemoizedTheme(
|
|
132
|
+
searchBarTheme.iconButton,
|
|
133
|
+
state,
|
|
134
|
+
iconButtonStyle ?? searchButtonProps?.style
|
|
142
135
|
)
|
|
136
|
+
const resolvedIconButtonColor = useMemoizedTheme(searchBarTheme.iconButtonColor, state)
|
|
143
137
|
|
|
144
138
|
const commitSearch = (nextValue: string) => {
|
|
145
139
|
onSearch(nextValue)
|
|
@@ -211,11 +205,11 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(function SearchBa
|
|
|
211
205
|
icon={HightideIconRegistry.Search}
|
|
212
206
|
accessibilityLabel={translation('search')}
|
|
213
207
|
size="sm"
|
|
214
|
-
color={searchButtonProps?.color ??
|
|
208
|
+
color={searchButtonProps?.color ?? resolvedIconButtonColor}
|
|
215
209
|
variant={searchButtonProps?.variant ?? 'foreground'}
|
|
216
210
|
disabled={disabled || searchButtonProps?.disabled}
|
|
217
211
|
onPress={() => commitSearch(value ?? '')}
|
|
218
|
-
style={
|
|
212
|
+
style={resolvedIconButtonStyle}
|
|
219
213
|
/>
|
|
220
214
|
</View>
|
|
221
215
|
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import type { SelectRootProps } from './SelectRoot'
|
|
5
|
+
import { SelectRoot } from './SelectRoot'
|
|
6
|
+
import type { SelectOptionType } from './SelectContext'
|
|
7
|
+
import { SelectMenu, type SelectMenuProps } from './SelectMenu'
|
|
8
|
+
import { SelectTrigger, type SelectTriggerProps } from './SelectTrigger'
|
|
9
|
+
|
|
10
|
+
export type SelectProps<T = string> = Omit<SelectRootProps<T>, 'children'> & {
|
|
11
|
+
children?: ReactNode,
|
|
12
|
+
placeholder?: SelectTriggerProps<T>['placeholder'],
|
|
13
|
+
selectedDisplay?: (option: SelectOptionType<T> | null) => ReactNode,
|
|
14
|
+
triggerProps?: SelectTriggerProps<T>,
|
|
15
|
+
menuProps?: Omit<SelectMenuProps, 'children'>,
|
|
16
|
+
style?: StyleProp<ViewStyle>,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const Select = <T,>({
|
|
20
|
+
children,
|
|
21
|
+
placeholder,
|
|
22
|
+
selectedDisplay,
|
|
23
|
+
triggerProps,
|
|
24
|
+
menuProps,
|
|
25
|
+
style,
|
|
26
|
+
...props
|
|
27
|
+
}: SelectProps<T>) => {
|
|
28
|
+
return (
|
|
29
|
+
<SelectRoot<T> {...props}>
|
|
30
|
+
<View style={style}>
|
|
31
|
+
<SelectTrigger<T>
|
|
32
|
+
placeholder={placeholder}
|
|
33
|
+
selectedDisplay={selectedDisplay}
|
|
34
|
+
{...triggerProps}
|
|
35
|
+
/>
|
|
36
|
+
<SelectMenu {...menuProps}>{children}</SelectMenu>
|
|
37
|
+
</View>
|
|
38
|
+
</SelectRoot>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { createContext, useContext } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { ColorPairToken } from '@helpwave/hightide-design/theme-tokens'
|
|
5
|
+
|
|
6
|
+
import type { UseSelectFirstHighlightBehavior } from '../../../hooks/useSelect'
|
|
7
|
+
import type { FormFieldInteractionStates } from '../../../types/formField'
|
|
8
|
+
|
|
9
|
+
export type SelectOptionType<T = string> = {
|
|
10
|
+
id: string,
|
|
11
|
+
value: T,
|
|
12
|
+
label?: string,
|
|
13
|
+
display?: ReactNode,
|
|
14
|
+
disabled?: boolean,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SelectContextState<T> = Partial<FormFieldInteractionStates> & {
|
|
18
|
+
selectedId: string | null,
|
|
19
|
+
options: ReadonlyArray<SelectOptionType<T>>,
|
|
20
|
+
highlightedId: string | null,
|
|
21
|
+
isOpen: boolean,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SelectContextComputedState<T> = {
|
|
25
|
+
visibleOptionIds: ReadonlyArray<string>,
|
|
26
|
+
idToOptionMap: Record<string, SelectOptionType<T>>,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SelectContextActions<T> = {
|
|
30
|
+
registerOption: (option: SelectOptionType<T>) => () => void,
|
|
31
|
+
toggleSelection: (id: string) => void,
|
|
32
|
+
highlightFirst: () => void,
|
|
33
|
+
highlightLast: () => void,
|
|
34
|
+
highlightNext: () => void,
|
|
35
|
+
highlightPrevious: () => void,
|
|
36
|
+
highlightItem: (id: string) => void,
|
|
37
|
+
handleTypeaheadKey: (key: string) => void,
|
|
38
|
+
setIsOpen: (open: boolean, behavior?: UseSelectFirstHighlightBehavior) => void,
|
|
39
|
+
toggleIsOpen: (behavior?: UseSelectFirstHighlightBehavior) => void,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type SelectContextSearch = {
|
|
43
|
+
hasSearch: boolean,
|
|
44
|
+
searchQuery: string,
|
|
45
|
+
setSearchQuery: (query: string) => void,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type SelectContextConfig = {
|
|
49
|
+
searchableThreshold: number,
|
|
50
|
+
color?: ColorPairToken,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type SelectContextType<T> = SelectContextActions<T>
|
|
54
|
+
& SelectContextState<T>
|
|
55
|
+
& SelectContextComputedState<T>
|
|
56
|
+
& {
|
|
57
|
+
config: SelectContextConfig,
|
|
58
|
+
search: SelectContextSearch,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const SelectContext = createContext<SelectContextType<unknown> | null>(null)
|
|
62
|
+
|
|
63
|
+
export function useSelectContext<T>(): SelectContextType<T> {
|
|
64
|
+
const ctx = useContext(SelectContext)
|
|
65
|
+
if (!ctx) {
|
|
66
|
+
throw new Error('useSelectContext must be used within SelectRoot')
|
|
67
|
+
}
|
|
68
|
+
return ctx as SelectContextType<T>
|
|
69
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Modal, Pressable, ScrollView, View } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import { useTranslation } from '@helpwave/hightide-utils/context'
|
|
5
|
+
|
|
6
|
+
import { useTheme } from '../../../global-contexts/theme/ThemeContext'
|
|
7
|
+
import { useMemoizedTheme } from '../../../hooks/useMemoizedTheme'
|
|
8
|
+
import { ThemedText } from '../../visualization-and-display/ThemedText'
|
|
9
|
+
import { SearchBar } from '../SearchBar'
|
|
10
|
+
import { useSelectContext } from './SelectContext'
|
|
11
|
+
|
|
12
|
+
export type SelectMenuProps = {
|
|
13
|
+
children?: ReactNode,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const SelectMenu = ({ children }: SelectMenuProps) => {
|
|
17
|
+
const { theme } = useTheme()
|
|
18
|
+
const translation = useTranslation()
|
|
19
|
+
const context = useSelectContext()
|
|
20
|
+
const selectTheme = theme.components.select
|
|
21
|
+
const isSearchVisible = context.search.hasSearch
|
|
22
|
+
const visibleCount = context.visibleOptionIds.length
|
|
23
|
+
const showEmptySearchResults = isSearchVisible
|
|
24
|
+
&& context.search.searchQuery.trim().length > 0
|
|
25
|
+
&& visibleCount === 0
|
|
26
|
+
|
|
27
|
+
const resolvedOverlayStyle = useMemoizedTheme(selectTheme.overlay, {})
|
|
28
|
+
const resolvedMenuStyle = useMemoizedTheme(selectTheme.menu, { hasSearch: isSearchVisible })
|
|
29
|
+
const resolvedHeaderStyle = useMemoizedTheme(selectTheme.header, {})
|
|
30
|
+
const resolvedEmptyTextStyle = useMemoizedTheme(selectTheme.emptyText, {})
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
{!context.isOpen && (
|
|
35
|
+
<View
|
|
36
|
+
collapsable={false}
|
|
37
|
+
pointerEvents="none"
|
|
38
|
+
style={{ width: 0, height: 0, overflow: 'hidden' }}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</View>
|
|
42
|
+
)}
|
|
43
|
+
<Modal
|
|
44
|
+
visible={context.isOpen}
|
|
45
|
+
transparent
|
|
46
|
+
animationType="fade"
|
|
47
|
+
onRequestClose={() => context.setIsOpen(false)}
|
|
48
|
+
>
|
|
49
|
+
<Pressable
|
|
50
|
+
style={resolvedOverlayStyle}
|
|
51
|
+
onPress={() => context.setIsOpen(false)}
|
|
52
|
+
>
|
|
53
|
+
<Pressable
|
|
54
|
+
style={resolvedMenuStyle}
|
|
55
|
+
onPress={(event) => event.stopPropagation()}
|
|
56
|
+
>
|
|
57
|
+
{isSearchVisible && (
|
|
58
|
+
<View style={resolvedHeaderStyle}>
|
|
59
|
+
<SearchBar
|
|
60
|
+
value={context.search.searchQuery}
|
|
61
|
+
onValueChange={context.search.setSearchQuery}
|
|
62
|
+
onSearch={context.search.setSearchQuery}
|
|
63
|
+
/>
|
|
64
|
+
</View>
|
|
65
|
+
)}
|
|
66
|
+
{showEmptySearchResults && (
|
|
67
|
+
<View
|
|
68
|
+
style={{
|
|
69
|
+
flex: 1,
|
|
70
|
+
justifyContent: 'center',
|
|
71
|
+
alignItems: 'center',
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<ThemedText style={resolvedEmptyTextStyle}>
|
|
75
|
+
{translation('nothingFound')}
|
|
76
|
+
</ThemedText>
|
|
77
|
+
</View>
|
|
78
|
+
)}
|
|
79
|
+
<ScrollView>
|
|
80
|
+
{children}
|
|
81
|
+
</ScrollView>
|
|
82
|
+
</Pressable>
|
|
83
|
+
</Pressable>
|
|
84
|
+
</Modal>
|
|
85
|
+
</>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useEffect, useMemo } from 'react'
|
|
3
|
+
|
|
4
|
+
import { useTheme } from '../../../global-contexts/theme/ThemeContext'
|
|
5
|
+
import { useMemoizedTheme } from '../../../hooks/useMemoizedTheme'
|
|
6
|
+
import { HightideIconRegistry } from '../../../icons/HightideIconRegistry'
|
|
7
|
+
import type { IconStyle } from '../../../icons'
|
|
8
|
+
import type { MultiSelectOptionState } from '../../../theme/types/components/multiSelect'
|
|
9
|
+
import { ListActionItem } from '../../list/ListActionItem'
|
|
10
|
+
import { ThemedIcon } from '../../visualization-and-display/ThemedIcon'
|
|
11
|
+
import { useSelectContext } from './SelectContext'
|
|
12
|
+
|
|
13
|
+
export type SelectOptionProps<T = string> = {
|
|
14
|
+
id: string,
|
|
15
|
+
value: T,
|
|
16
|
+
label: string,
|
|
17
|
+
disabled?: boolean,
|
|
18
|
+
children?: ReactNode,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const SelectOption = <T,>({
|
|
22
|
+
id,
|
|
23
|
+
value,
|
|
24
|
+
label,
|
|
25
|
+
disabled = false,
|
|
26
|
+
children,
|
|
27
|
+
}: SelectOptionProps<T>) => {
|
|
28
|
+
const { theme } = useTheme()
|
|
29
|
+
const context = useSelectContext<T>()
|
|
30
|
+
const { registerOption } = context
|
|
31
|
+
const display = children ?? label
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
return registerOption({
|
|
35
|
+
id,
|
|
36
|
+
value,
|
|
37
|
+
label,
|
|
38
|
+
display,
|
|
39
|
+
disabled,
|
|
40
|
+
})
|
|
41
|
+
}, [disabled, display, id, label, registerOption, value])
|
|
42
|
+
|
|
43
|
+
const isSelected = context.selectedId === id
|
|
44
|
+
const isVisible = context.visibleOptionIds.includes(id)
|
|
45
|
+
|
|
46
|
+
const optionState = useMemo((): MultiSelectOptionState => ({
|
|
47
|
+
isSelected,
|
|
48
|
+
isHighlighted: context.highlightedId === id,
|
|
49
|
+
isDisabled: disabled,
|
|
50
|
+
}), [context.highlightedId, disabled, id, isSelected])
|
|
51
|
+
|
|
52
|
+
const checkIcon = useMemoizedTheme<MultiSelectOptionState, IconStyle>(
|
|
53
|
+
theme.components.listItem.action.icon,
|
|
54
|
+
optionState
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if (!context.isOpen || !isVisible) {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<ListActionItem
|
|
63
|
+
title={children ? undefined : label}
|
|
64
|
+
content={children}
|
|
65
|
+
disabled={disabled}
|
|
66
|
+
onPress={() => context.toggleSelection(id)}
|
|
67
|
+
leading={(
|
|
68
|
+
<ThemedIcon
|
|
69
|
+
icon={HightideIconRegistry.Check}
|
|
70
|
+
size={checkIcon.size}
|
|
71
|
+
strokeWidth={checkIcon.strokeWidth}
|
|
72
|
+
color={isSelected ? theme.colors.primary.color : 'transparent'}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { ColorPairToken } from '@helpwave/hightide-design/theme-tokens'
|
|
5
|
+
import { useEventCallbackStabilizer } from '@helpwave/hightide-utils/hooks'
|
|
6
|
+
|
|
7
|
+
import { useSelect } from '../../../hooks/useSelect'
|
|
8
|
+
import type {
|
|
9
|
+
FormFieldDataHandling,
|
|
10
|
+
FormFieldInteractionStates
|
|
11
|
+
} from '../../../types/formField'
|
|
12
|
+
import {
|
|
13
|
+
SelectContext,
|
|
14
|
+
type SelectContextType,
|
|
15
|
+
type SelectOptionType
|
|
16
|
+
} from './SelectContext'
|
|
17
|
+
|
|
18
|
+
export type SelectRootProps<T> = Partial<FormFieldDataHandling<T>>
|
|
19
|
+
& Partial<FormFieldInteractionStates>
|
|
20
|
+
& {
|
|
21
|
+
value?: T | null,
|
|
22
|
+
initialValue?: T | null,
|
|
23
|
+
compareFunction?: (a: T | null, b: T | null) => boolean,
|
|
24
|
+
initialIsOpen?: boolean,
|
|
25
|
+
onClose?: () => void,
|
|
26
|
+
onIsOpenChange?: (isOpen: boolean) => void,
|
|
27
|
+
searchableThreshold?: number,
|
|
28
|
+
color?: ColorPairToken,
|
|
29
|
+
children: ReactNode,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const mergeOptionMaps = <T,>(
|
|
33
|
+
snapshots: Record<string, SelectOptionType<T>>,
|
|
34
|
+
live: ReadonlyArray<SelectOptionType<T>>
|
|
35
|
+
): Record<string, SelectOptionType<T>> => {
|
|
36
|
+
const merged = { ...snapshots }
|
|
37
|
+
for (const option of live) {
|
|
38
|
+
merged[option.id] = option
|
|
39
|
+
}
|
|
40
|
+
return merged
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function SelectRoot<T>({
|
|
44
|
+
children,
|
|
45
|
+
value,
|
|
46
|
+
onValueChange,
|
|
47
|
+
onEditComplete,
|
|
48
|
+
initialValue,
|
|
49
|
+
compareFunction,
|
|
50
|
+
initialIsOpen = false,
|
|
51
|
+
onClose,
|
|
52
|
+
onIsOpenChange,
|
|
53
|
+
searchableThreshold = 6,
|
|
54
|
+
color,
|
|
55
|
+
invalid = false,
|
|
56
|
+
disabled = false,
|
|
57
|
+
readOnly = false,
|
|
58
|
+
required = false,
|
|
59
|
+
}: SelectRootProps<T>) {
|
|
60
|
+
const [options, setOptions] = useState<SelectOptionType<T>[]>([])
|
|
61
|
+
const [optionSnapshots, setOptionSnapshots] = useState<Record<string, SelectOptionType<T>>>({})
|
|
62
|
+
|
|
63
|
+
const registerOption = useCallback((item: SelectOptionType<T>) => {
|
|
64
|
+
setOptionSnapshots((previous) => ({ ...previous, [item.id]: item }))
|
|
65
|
+
setOptions((previous) => {
|
|
66
|
+
const next = previous.filter((option) => option.value !== item.value)
|
|
67
|
+
next.push(item)
|
|
68
|
+
return next
|
|
69
|
+
})
|
|
70
|
+
return () => {
|
|
71
|
+
setOptions((previous) => previous.filter((option) => option.id !== item.id))
|
|
72
|
+
}
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
const compare = useMemo(() => compareFunction ?? Object.is, [compareFunction])
|
|
76
|
+
const idToOptionMap = useMemo(
|
|
77
|
+
() => mergeOptionMaps(optionSnapshots, options),
|
|
78
|
+
[optionSnapshots, options]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const mappedValueId = useMemo(() => {
|
|
82
|
+
if (value === undefined) {
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
return Object.values(idToOptionMap).find((option) => compare(option.value, value))?.id ?? null
|
|
86
|
+
}, [compare, idToOptionMap, value])
|
|
87
|
+
|
|
88
|
+
const mappedInitialValueId = useMemo(() => {
|
|
89
|
+
if (initialValue === undefined) {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
return Object.values(idToOptionMap).find((option) => compare(option.value, initialValue))?.id ?? null
|
|
93
|
+
}, [compare, idToOptionMap, initialValue])
|
|
94
|
+
|
|
95
|
+
const onValueChangeStable = useEventCallbackStabilizer(onValueChange)
|
|
96
|
+
const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete)
|
|
97
|
+
const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange)
|
|
98
|
+
|
|
99
|
+
const onValueChangeWrapper = useCallback((id: string) => {
|
|
100
|
+
const option = idToOptionMap[id]
|
|
101
|
+
if (option === undefined) {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
onValueChangeStable(option.value)
|
|
105
|
+
}, [idToOptionMap, onValueChangeStable])
|
|
106
|
+
|
|
107
|
+
const onEditCompleteWrapper = useCallback((id: string) => {
|
|
108
|
+
const option = idToOptionMap[id]
|
|
109
|
+
if (option === undefined) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
onEditCompleteStable(option.value)
|
|
113
|
+
}, [idToOptionMap, onEditCompleteStable])
|
|
114
|
+
|
|
115
|
+
const hookOptions = useMemo(
|
|
116
|
+
() => options.map((option) => ({
|
|
117
|
+
id: option.id,
|
|
118
|
+
label: option.label,
|
|
119
|
+
disabled: option.disabled,
|
|
120
|
+
})),
|
|
121
|
+
[options]
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const state = useSelect({
|
|
125
|
+
value: mappedValueId,
|
|
126
|
+
initialValue: mappedInitialValueId,
|
|
127
|
+
onValueChange: onValueChangeWrapper,
|
|
128
|
+
onEditComplete: onEditCompleteWrapper,
|
|
129
|
+
options: hookOptions,
|
|
130
|
+
initialIsOpen,
|
|
131
|
+
onClose,
|
|
132
|
+
onIsOpenChange: onIsOpenChangeStable,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const knownOptionCount = Math.max(options.length, Object.keys(optionSnapshots).length)
|
|
136
|
+
|
|
137
|
+
const contextValue = useMemo(() => ({
|
|
138
|
+
invalid,
|
|
139
|
+
disabled,
|
|
140
|
+
readOnly,
|
|
141
|
+
required,
|
|
142
|
+
selectedId: state.value,
|
|
143
|
+
highlightedId: state.highlightedValue,
|
|
144
|
+
isOpen: state.isOpen,
|
|
145
|
+
options,
|
|
146
|
+
visibleOptionIds: state.visibleOptionIds,
|
|
147
|
+
idToOptionMap,
|
|
148
|
+
registerOption,
|
|
149
|
+
toggleSelection: state.selectValue,
|
|
150
|
+
highlightFirst: state.highlightFirst,
|
|
151
|
+
highlightLast: state.highlightLast,
|
|
152
|
+
highlightNext: state.highlightNext,
|
|
153
|
+
highlightPrevious: state.highlightPrevious,
|
|
154
|
+
highlightItem: state.highlightItem,
|
|
155
|
+
handleTypeaheadKey: state.handleTypeaheadKey,
|
|
156
|
+
setIsOpen: state.setIsOpen,
|
|
157
|
+
toggleIsOpen: state.toggleOpen,
|
|
158
|
+
config: {
|
|
159
|
+
searchableThreshold,
|
|
160
|
+
color,
|
|
161
|
+
},
|
|
162
|
+
search: {
|
|
163
|
+
hasSearch: knownOptionCount >= searchableThreshold,
|
|
164
|
+
searchQuery: state.searchQuery,
|
|
165
|
+
setSearchQuery: state.setSearchQuery,
|
|
166
|
+
},
|
|
167
|
+
}), [
|
|
168
|
+
color,
|
|
169
|
+
disabled,
|
|
170
|
+
idToOptionMap,
|
|
171
|
+
invalid,
|
|
172
|
+
knownOptionCount,
|
|
173
|
+
options,
|
|
174
|
+
readOnly,
|
|
175
|
+
registerOption,
|
|
176
|
+
required,
|
|
177
|
+
searchableThreshold,
|
|
178
|
+
state,
|
|
179
|
+
])
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<SelectContext.Provider value={contextValue as SelectContextType<unknown>}>
|
|
183
|
+
{children}
|
|
184
|
+
</SelectContext.Provider>
|
|
185
|
+
)
|
|
186
|
+
}
|