@helpwave/hightide-native 0.6.0 → 0.9.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 +6 -8
- package/src/components/card/Card.tsx +2 -4
- package/src/components/chat/ChatAttachmentMessageBubble.tsx +152 -90
- 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 +52 -45
- 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 +69 -80
- package/src/components/user-interaction/MultiSelect/MultiSelect.tsx +17 -0
- package/src/components/user-interaction/MultiSelect/MultiSelectComponent.tsx +40 -0
- package/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx +74 -0
- package/src/components/user-interaction/MultiSelect/MultiSelectMenu.tsx +87 -0
- package/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx +106 -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 +2 -0
- package/src/components/user-interaction/SearchBar.tsx +11 -17
- package/src/components/user-interaction/Select/Select.tsx +17 -0
- package/src/components/user-interaction/Select/SelectComponent.tsx +40 -0
- package/src/components/user-interaction/Select/SelectContext.tsx +73 -0
- package/src/components/user-interaction/Select/SelectMenu.tsx +87 -0
- package/src/components/user-interaction/Select/SelectOption.tsx +96 -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 +2 -0
- package/src/components/user-interaction/Switch.tsx +46 -98
- package/src/components/user-interaction/Textarea.tsx +165 -12
- package/src/components/user-interaction/ThemedPressable.tsx +48 -49
- 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/input.ts +30 -14
- package/src/theme/resolvers/listItem.ts +2 -3
- package/src/theme/resolvers/multiSelect.ts +1 -2
- package/src/theme/resolvers/searchBar.ts +1 -2
- package/src/theme/resolvers/select.ts +1 -2
- package/src/theme/resolvers/themedPressable.ts +4 -15
- package/src/theme/themes/hightideThemes.ts +3 -1
- package/src/theme/types/components/chat.ts +6 -21
- package/src/theme/types/components/input.ts +3 -0
- package/src/theme/types/components/textarea.ts +1 -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 -282
- package/src/components/user-interaction/Select.tsx +0 -242
|
@@ -0,0 +1,194 @@
|
|
|
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 { useMultiSelect } from '../../../hooks/useMultiSelect'
|
|
8
|
+
import type {
|
|
9
|
+
FormFieldDataHandling,
|
|
10
|
+
FormFieldInteractionStates
|
|
11
|
+
} from '../../../types/formField'
|
|
12
|
+
import {
|
|
13
|
+
MultiSelectContext,
|
|
14
|
+
type MultiSelectContextType,
|
|
15
|
+
type MultiSelectOptionType
|
|
16
|
+
} from './MultiSelectContext'
|
|
17
|
+
|
|
18
|
+
export type MultiSelectRootProps<T> = Partial<FormFieldDataHandling<T[]>>
|
|
19
|
+
& Partial<FormFieldInteractionStates>
|
|
20
|
+
& {
|
|
21
|
+
value?: T[],
|
|
22
|
+
initialValue?: T[],
|
|
23
|
+
compareFunction?: (a: T, b: T) => boolean,
|
|
24
|
+
initialIsOpen?: boolean,
|
|
25
|
+
onClose?: () => void,
|
|
26
|
+
searchableThreshold?: number,
|
|
27
|
+
color?: ColorPairToken,
|
|
28
|
+
children: ReactNode,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const mergeOptionMaps = <T,>(
|
|
32
|
+
snapshots: Record<string, MultiSelectOptionType<T>>,
|
|
33
|
+
live: ReadonlyArray<MultiSelectOptionType<T>>
|
|
34
|
+
): Record<string, MultiSelectOptionType<T>> => {
|
|
35
|
+
const merged = { ...snapshots }
|
|
36
|
+
for (const option of live) {
|
|
37
|
+
merged[option.value.id] = option
|
|
38
|
+
}
|
|
39
|
+
return merged
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function MultiSelectRoot<T>({
|
|
43
|
+
children,
|
|
44
|
+
value,
|
|
45
|
+
onValueChange,
|
|
46
|
+
onEditComplete,
|
|
47
|
+
initialValue,
|
|
48
|
+
compareFunction,
|
|
49
|
+
initialIsOpen = false,
|
|
50
|
+
onClose,
|
|
51
|
+
searchableThreshold = 6,
|
|
52
|
+
color,
|
|
53
|
+
invalid = false,
|
|
54
|
+
disabled = false,
|
|
55
|
+
readOnly = false,
|
|
56
|
+
required = false,
|
|
57
|
+
}: MultiSelectRootProps<T>) {
|
|
58
|
+
const [options, setOptions] = useState<MultiSelectOptionType<T>[]>([])
|
|
59
|
+
const [optionSnapshots, setOptionSnapshots] = useState<Record<string, MultiSelectOptionType<T>>>({})
|
|
60
|
+
|
|
61
|
+
const registerOption = useCallback((item: MultiSelectOptionType<T>) => {
|
|
62
|
+
setOptionSnapshots((previous) => ({ ...previous, [item.value.id]: item }))
|
|
63
|
+
setOptions((previous) => {
|
|
64
|
+
const next = previous.filter((option) => option.value.id !== item.value.id)
|
|
65
|
+
next.push(item)
|
|
66
|
+
return next
|
|
67
|
+
})
|
|
68
|
+
return () => {
|
|
69
|
+
setOptions((previous) => previous.filter((option) => option.value.id !== item.value.id))
|
|
70
|
+
}
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
const compare = useMemo(() => compareFunction ?? Object.is, [compareFunction])
|
|
74
|
+
const idToOptionMap = useMemo(
|
|
75
|
+
() => mergeOptionMaps(optionSnapshots, options),
|
|
76
|
+
[optionSnapshots, options]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const mappedValueIds = useMemo(() => {
|
|
80
|
+
if (value == null) {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
const known = Object.values(idToOptionMap)
|
|
84
|
+
return value
|
|
85
|
+
.map((item) => known.find((option) => compare(option.value.value, item))?.value.id)
|
|
86
|
+
.filter((id): id is string => id !== undefined)
|
|
87
|
+
}, [compare, idToOptionMap, value])
|
|
88
|
+
|
|
89
|
+
const mappedInitialValueIds = useMemo(() => {
|
|
90
|
+
if (initialValue == null) {
|
|
91
|
+
return []
|
|
92
|
+
}
|
|
93
|
+
const known = Object.values(idToOptionMap)
|
|
94
|
+
return initialValue
|
|
95
|
+
.map((item) => known.find((option) => compare(option.value.value, item))?.value.id)
|
|
96
|
+
.filter((id): id is string => id !== undefined)
|
|
97
|
+
}, [compare, idToOptionMap, initialValue])
|
|
98
|
+
|
|
99
|
+
const onValueChangeStable = useEventCallbackStabilizer(onValueChange)
|
|
100
|
+
const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete)
|
|
101
|
+
|
|
102
|
+
const onValueChangeWrapper = useCallback((ids: string[]) => {
|
|
103
|
+
const values = ids
|
|
104
|
+
.map((id) => idToOptionMap[id]?.value.value)
|
|
105
|
+
.filter((item): item is T => item != null)
|
|
106
|
+
onValueChangeStable(values)
|
|
107
|
+
}, [idToOptionMap, onValueChangeStable])
|
|
108
|
+
|
|
109
|
+
const onEditCompleteWrapper = useCallback((ids: string[]) => {
|
|
110
|
+
const values = ids
|
|
111
|
+
.map((id) => idToOptionMap[id]?.value.value)
|
|
112
|
+
.filter((item): item is T => item != null)
|
|
113
|
+
onEditCompleteStable(values)
|
|
114
|
+
}, [idToOptionMap, onEditCompleteStable])
|
|
115
|
+
|
|
116
|
+
const hookOptions = useMemo(
|
|
117
|
+
() => options.map((option) => ({
|
|
118
|
+
id: option.value.id,
|
|
119
|
+
label: option.label,
|
|
120
|
+
disabled: option.disabled,
|
|
121
|
+
})),
|
|
122
|
+
[options]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const state = useMultiSelect({
|
|
126
|
+
options: hookOptions,
|
|
127
|
+
value: mappedValueIds,
|
|
128
|
+
onValueChange: onValueChangeWrapper,
|
|
129
|
+
onEditComplete: onEditCompleteWrapper,
|
|
130
|
+
initialValue: mappedInitialValueIds,
|
|
131
|
+
initialIsOpen,
|
|
132
|
+
onClose,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const knownOptionCount = Math.max(options.length, Object.keys(optionSnapshots).length)
|
|
136
|
+
const selectedValues = useMemo(
|
|
137
|
+
() => state.value
|
|
138
|
+
.map((id) => idToOptionMap[id]?.value.value)
|
|
139
|
+
.filter((item): item is T => item != null),
|
|
140
|
+
[idToOptionMap, state.value]
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const contextValue = useMemo((): MultiSelectContextType<T> => ({
|
|
144
|
+
invalid,
|
|
145
|
+
disabled,
|
|
146
|
+
readOnly,
|
|
147
|
+
required,
|
|
148
|
+
selectedIds: state.value,
|
|
149
|
+
highlightedId: state.highlightedId,
|
|
150
|
+
isOpen: state.isOpen,
|
|
151
|
+
options,
|
|
152
|
+
visibleOptionIds: state.visibleOptionIds,
|
|
153
|
+
idToOptionMap,
|
|
154
|
+
value: selectedValues,
|
|
155
|
+
registerOption,
|
|
156
|
+
toggleSelection: state.toggleSelection,
|
|
157
|
+
highlightFirst: state.highlightFirst,
|
|
158
|
+
highlightLast: state.highlightLast,
|
|
159
|
+
highlightNext: state.highlightNext,
|
|
160
|
+
highlightPrevious: state.highlightPrevious,
|
|
161
|
+
highlightItem: state.highlightItem,
|
|
162
|
+
handleTypeaheadKey: state.handleTypeaheadKey,
|
|
163
|
+
setIsOpen: state.setIsOpen,
|
|
164
|
+
toggleIsOpen: state.toggleOpen,
|
|
165
|
+
config: {
|
|
166
|
+
searchableThreshold,
|
|
167
|
+
color,
|
|
168
|
+
},
|
|
169
|
+
search: {
|
|
170
|
+
hasSearch: knownOptionCount >= searchableThreshold,
|
|
171
|
+
searchQuery: state.searchQuery,
|
|
172
|
+
setSearchQuery: state.setSearchQuery,
|
|
173
|
+
},
|
|
174
|
+
}), [
|
|
175
|
+
color,
|
|
176
|
+
disabled,
|
|
177
|
+
idToOptionMap,
|
|
178
|
+
invalid,
|
|
179
|
+
knownOptionCount,
|
|
180
|
+
options,
|
|
181
|
+
readOnly,
|
|
182
|
+
registerOption,
|
|
183
|
+
required,
|
|
184
|
+
searchableThreshold,
|
|
185
|
+
selectedValues,
|
|
186
|
+
state,
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<MultiSelectContext.Provider value={contextValue as MultiSelectContextType<unknown>}>
|
|
191
|
+
{children}
|
|
192
|
+
</MultiSelectContext.Provider>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
@@ -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.value.id} size="md" color={context.config.color} variant="tonal">
|
|
82
|
+
<ThemedText>{option.label ?? String(option.value.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.value.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,17 @@
|
|
|
1
|
+
import { SelectComponent } from './SelectComponent'
|
|
2
|
+
import { SelectContext } from './SelectContext'
|
|
3
|
+
import { SelectMenu } from './SelectMenu'
|
|
4
|
+
import { SelectOption } from './SelectOption'
|
|
5
|
+
import { SelectRoot } from './SelectRoot'
|
|
6
|
+
import { SelectTrigger } from './SelectTrigger'
|
|
7
|
+
|
|
8
|
+
const Select = Object.assign(SelectComponent, {
|
|
9
|
+
Root: SelectRoot,
|
|
10
|
+
Trigger: SelectTrigger,
|
|
11
|
+
Option: SelectOption,
|
|
12
|
+
Menu: SelectMenu,
|
|
13
|
+
Context: SelectContext,
|
|
14
|
+
Provider: SelectContext.Provider,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export { Select }
|
|
@@ -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 SelectComponent = <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,73 @@
|
|
|
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 SelectOptionIdentity<T> = {
|
|
10
|
+
value: T,
|
|
11
|
+
id: string,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SelectOptionType<T = string> = {
|
|
15
|
+
value: SelectOptionIdentity<T>,
|
|
16
|
+
label: string,
|
|
17
|
+
display?: ReactNode,
|
|
18
|
+
disabled?: boolean,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SelectContextState<T> = Partial<FormFieldInteractionStates> & {
|
|
22
|
+
selectedId: string | null,
|
|
23
|
+
options: ReadonlyArray<SelectOptionType<T>>,
|
|
24
|
+
highlightedId: string | null,
|
|
25
|
+
isOpen: boolean,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SelectContextComputedState<T> = {
|
|
29
|
+
visibleOptionIds: ReadonlyArray<string>,
|
|
30
|
+
idToOptionMap: Record<string, SelectOptionType<T>>,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type SelectContextActions<T> = {
|
|
34
|
+
registerOption: (option: SelectOptionType<T>) => () => void,
|
|
35
|
+
toggleSelection: (id: string) => void,
|
|
36
|
+
highlightFirst: () => void,
|
|
37
|
+
highlightLast: () => void,
|
|
38
|
+
highlightNext: () => void,
|
|
39
|
+
highlightPrevious: () => void,
|
|
40
|
+
highlightItem: (id: string) => void,
|
|
41
|
+
handleTypeaheadKey: (key: string) => void,
|
|
42
|
+
setIsOpen: (open: boolean, behavior?: UseSelectFirstHighlightBehavior) => void,
|
|
43
|
+
toggleIsOpen: (behavior?: UseSelectFirstHighlightBehavior) => void,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SelectContextSearch = {
|
|
47
|
+
hasSearch: boolean,
|
|
48
|
+
searchQuery: string,
|
|
49
|
+
setSearchQuery: (query: string) => void,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type SelectContextConfig = {
|
|
53
|
+
searchableThreshold: number,
|
|
54
|
+
color?: ColorPairToken,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SelectContextType<T> = SelectContextActions<T>
|
|
58
|
+
& SelectContextState<T>
|
|
59
|
+
& SelectContextComputedState<T>
|
|
60
|
+
& {
|
|
61
|
+
config: SelectContextConfig,
|
|
62
|
+
search: SelectContextSearch,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const SelectContext = createContext<SelectContextType<unknown> | null>(null)
|
|
66
|
+
|
|
67
|
+
export function useSelectContext<T>(): SelectContextType<T> {
|
|
68
|
+
const ctx = useContext(SelectContext)
|
|
69
|
+
if (!ctx) {
|
|
70
|
+
throw new Error('useSelectContext must be used within SelectRoot')
|
|
71
|
+
}
|
|
72
|
+
return ctx as SelectContextType<T>
|
|
73
|
+
}
|
|
@@ -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,96 @@
|
|
|
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 type { SelectOptionIdentity } from './SelectContext'
|
|
12
|
+
import { useSelectContext } from './SelectContext'
|
|
13
|
+
|
|
14
|
+
export type SelectOptionProps<T = string> = (
|
|
15
|
+
T extends string
|
|
16
|
+
? {
|
|
17
|
+
valueId?: undefined,
|
|
18
|
+
value: T,
|
|
19
|
+
label?: string,
|
|
20
|
+
}
|
|
21
|
+
: {
|
|
22
|
+
valueId: string,
|
|
23
|
+
value: T,
|
|
24
|
+
label: string,
|
|
25
|
+
}
|
|
26
|
+
) & {
|
|
27
|
+
disabled?: boolean,
|
|
28
|
+
children?: ReactNode,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const toIdentity = <T,>(value: T, valueId: string | undefined): SelectOptionIdentity<T> => {
|
|
32
|
+
if (valueId === undefined) {
|
|
33
|
+
return { value, id: value as string }
|
|
34
|
+
}
|
|
35
|
+
return { value, id: valueId }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const SelectOption = <T,>({
|
|
39
|
+
value,
|
|
40
|
+
valueId,
|
|
41
|
+
label,
|
|
42
|
+
disabled = false,
|
|
43
|
+
children,
|
|
44
|
+
}: SelectOptionProps<T>) => {
|
|
45
|
+
const { theme } = useTheme()
|
|
46
|
+
const context = useSelectContext<T>()
|
|
47
|
+
const { registerOption } = context
|
|
48
|
+
const identity = useMemo(() => toIdentity<T>(value, valueId), [value, valueId])
|
|
49
|
+
const resolvedLabel: string = valueId === undefined ? (label ?? (value as string)) : label
|
|
50
|
+
const display = children ?? resolvedLabel
|
|
51
|
+
const optionId = identity.id
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
return registerOption({
|
|
55
|
+
value: identity,
|
|
56
|
+
label: resolvedLabel,
|
|
57
|
+
display,
|
|
58
|
+
disabled,
|
|
59
|
+
})
|
|
60
|
+
}, [disabled, display, identity, registerOption, resolvedLabel])
|
|
61
|
+
|
|
62
|
+
const isSelected = context.selectedId === optionId
|
|
63
|
+
const isVisible = context.visibleOptionIds.includes(optionId)
|
|
64
|
+
|
|
65
|
+
const optionState = useMemo((): MultiSelectOptionState => ({
|
|
66
|
+
isSelected,
|
|
67
|
+
isHighlighted: context.highlightedId === optionId,
|
|
68
|
+
isDisabled: disabled,
|
|
69
|
+
}), [context.highlightedId, disabled, optionId, isSelected])
|
|
70
|
+
|
|
71
|
+
const checkIcon = useMemoizedTheme<MultiSelectOptionState, IconStyle>(
|
|
72
|
+
theme.components.listItem.action.icon,
|
|
73
|
+
optionState
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if (!context.isOpen || !isVisible) {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<ListActionItem
|
|
82
|
+
title={children ? undefined : resolvedLabel}
|
|
83
|
+
content={children}
|
|
84
|
+
disabled={disabled}
|
|
85
|
+
onPress={() => context.toggleSelection(optionId)}
|
|
86
|
+
leading={(
|
|
87
|
+
<ThemedIcon
|
|
88
|
+
icon={HightideIconRegistry.Check}
|
|
89
|
+
size={checkIcon.size}
|
|
90
|
+
strokeWidth={checkIcon.strokeWidth}
|
|
91
|
+
color={isSelected ? theme.colors.primary.color : 'transparent'}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
}
|