@helpwave/hightide-native 0.6.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.
Files changed (86) hide show
  1. package/package.json +3 -3
  2. package/src/components/card/Card.tsx +2 -4
  3. package/src/components/chat/ChatAttachmentMessageBubble.tsx +152 -90
  4. package/src/components/chat/ChatConversationList.tsx +4 -12
  5. package/src/components/chat/ChatConversationRow.tsx +159 -164
  6. package/src/components/chat/ChatDateDivider.tsx +3 -8
  7. package/src/components/chat/ChatMessageBubble.tsx +51 -45
  8. package/src/components/chat/ChatMessageComposer.tsx +4 -12
  9. package/src/components/chat/ChatMessageList.tsx +3 -9
  10. package/src/components/chat/ChatQuickReplyChip.tsx +41 -27
  11. package/src/components/chat/ChatSystemLine.tsx +4 -12
  12. package/src/components/chat/ChatThreadHeader.tsx +136 -101
  13. package/src/components/layout/Divider.tsx +2 -4
  14. package/src/components/list/ListActionItem.tsx +57 -65
  15. package/src/components/list/ListItem.tsx +8 -28
  16. package/src/components/list/ListItemTextContent.tsx +4 -2
  17. package/src/components/list/ListNavigationItem.tsx +55 -63
  18. package/src/components/user-interaction/Button.tsx +43 -49
  19. package/src/components/user-interaction/Checkbox.tsx +47 -48
  20. package/src/components/user-interaction/IconButton.tsx +40 -42
  21. package/src/components/user-interaction/Input.tsx +69 -80
  22. package/src/components/user-interaction/MultiSelect/MultiSelect.tsx +40 -0
  23. package/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx +70 -0
  24. package/src/components/user-interaction/MultiSelect/MultiSelectMenu.tsx +87 -0
  25. package/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx +87 -0
  26. package/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx +194 -0
  27. package/src/components/user-interaction/MultiSelect/MultiSelectTrigger.tsx +123 -0
  28. package/src/components/user-interaction/MultiSelect/index.ts +6 -0
  29. package/src/components/user-interaction/SearchBar.tsx +11 -17
  30. package/src/components/user-interaction/Select/Select.tsx +40 -0
  31. package/src/components/user-interaction/Select/SelectContext.tsx +69 -0
  32. package/src/components/user-interaction/Select/SelectMenu.tsx +87 -0
  33. package/src/components/user-interaction/Select/SelectOption.tsx +77 -0
  34. package/src/components/user-interaction/Select/SelectRoot.tsx +186 -0
  35. package/src/components/user-interaction/Select/SelectTrigger.tsx +92 -0
  36. package/src/components/user-interaction/Select/index.ts +6 -0
  37. package/src/components/user-interaction/Switch.tsx +46 -98
  38. package/src/components/user-interaction/Textarea.tsx +165 -12
  39. package/src/components/user-interaction/ThemedPressable.tsx +48 -49
  40. package/src/components/visualization-and-display/Avatar.tsx +19 -17
  41. package/src/components/visualization-and-display/Chip.tsx +4 -12
  42. package/src/components/visualization-and-display/ThemedText.tsx +12 -5
  43. package/src/enums/chatMessageDirection.ts +18 -0
  44. package/src/enums/chatMessageStatus.ts +18 -0
  45. package/src/enums/hightideThemeModes.ts +18 -0
  46. package/src/enums/index.ts +7 -0
  47. package/src/enums/listItemContentOrder.ts +18 -0
  48. package/src/enums/themedTextAppearance.ts +18 -0
  49. package/src/enums/useMultiSelectFirstHighlightBehavior.ts +18 -0
  50. package/src/enums/useSelectFirstHighlightBehavior.ts +18 -0
  51. package/src/global-contexts/content-theme/ContentThemeProvider.tsx +9 -5
  52. package/src/global-contexts/theme/ThemeProvider.tsx +4 -1
  53. package/src/hooks/index.ts +2 -0
  54. package/src/hooks/useAnimatedStyleTransition.ts +460 -0
  55. package/src/hooks/useMemoizedTheme.ts +23 -0
  56. package/src/hooks/useMultiSelect.ts +3 -1
  57. package/src/hooks/useSelect.ts +3 -1
  58. package/src/icons/HightideIconRegistry.ts +2 -0
  59. package/src/theme/adapters/style-adapter-utils.ts +121 -27
  60. package/src/theme/resolvers/avatar.ts +48 -39
  61. package/src/theme/resolvers/button.ts +7 -17
  62. package/src/theme/resolvers/chat/attachment-message-bubble.ts +5 -6
  63. package/src/theme/resolvers/chat/conversation-row.ts +4 -6
  64. package/src/theme/resolvers/chat/message-bubble.ts +1 -2
  65. package/src/theme/resolvers/chat/message-composer.ts +1 -2
  66. package/src/theme/resolvers/chat/quick-reply-chip.ts +2 -3
  67. package/src/theme/resolvers/chat/system-line.ts +1 -2
  68. package/src/theme/resolvers/chat/thread-header.ts +3 -4
  69. package/src/theme/resolvers/checkbox.ts +2 -2
  70. package/src/theme/resolvers/chip.ts +1 -2
  71. package/src/theme/resolvers/iconButton.ts +4 -13
  72. package/src/theme/resolvers/input.ts +30 -14
  73. package/src/theme/resolvers/listItem.ts +2 -3
  74. package/src/theme/resolvers/multiSelect.ts +1 -2
  75. package/src/theme/resolvers/searchBar.ts +1 -2
  76. package/src/theme/resolvers/select.ts +1 -2
  77. package/src/theme/resolvers/themedPressable.ts +4 -15
  78. package/src/theme/themes/hightideThemes.ts +3 -1
  79. package/src/theme/types/components/chat.ts +6 -21
  80. package/src/theme/types/components/input.ts +3 -0
  81. package/src/theme/types/components/textarea.ts +1 -0
  82. package/src/theme/types/resolver.ts +4 -27
  83. package/src/utils/index.ts +1 -0
  84. package/src/utils/pressableInteraction.ts +7 -0
  85. package/src/components/user-interaction/MultiSelect.tsx +0 -282
  86. package/src/components/user-interaction/Select.tsx +0 -242
@@ -3,11 +3,9 @@ import {
3
3
  useMemo,
4
4
  useState
5
5
  } from 'react'
6
- import type {
7
- TextStyle } from 'react-native'
8
6
  import {
9
- StyleSheet,
10
7
  TextInput,
8
+ View,
11
9
  type TextInputProps
12
10
  } from 'react-native'
13
11
 
@@ -19,9 +17,11 @@ import {
19
17
 
20
18
  import type { ColorPairToken } from '@helpwave/hightide-design/theme-tokens'
21
19
  import { useTheme } from '../../global-contexts/theme/ThemeContext'
20
+ import { useMemoizedTheme } from '../../hooks/useMemoizedTheme'
22
21
  import type {
23
22
  InputContainerStyle,
24
23
  InputState,
24
+ InputStateLayerStyle,
25
25
  InputTextStyle
26
26
  } from '../../theme/types/components/input'
27
27
  import type { StyleOverwrite } from '../../theme/types/resolver'
@@ -52,7 +52,8 @@ export type InputProps = Omit<TextInputProps, 'value' | 'style'>
52
52
  color?: ColorPairToken,
53
53
  editCompleteOptions?: EditCompleteOptions,
54
54
  initialValue?: string,
55
- style?: StyleOverwrite<InputState, InputContainerStyle>,
55
+ style?: StyleOverwrite<InputState, InputContainerStyle>,
56
+ stateLayerStyle?: StyleOverwrite<InputState, InputStateLayerStyle>,
56
57
  textStyle?: StyleOverwrite<InputState, InputTextStyle>,
57
58
  }
58
59
 
@@ -68,7 +69,10 @@ export const Input = forwardRef<TextInput, InputProps>(function Input({
68
69
  onEditComplete,
69
70
  editCompleteOptions,
70
71
  style,
72
+ stateLayerStyle,
71
73
  textStyle,
74
+ onPointerEnter,
75
+ onPointerLeave,
72
76
  ...props
73
77
  }, ref) {
74
78
  const { theme } = useTheme()
@@ -106,91 +110,76 @@ export const Input = forwardRef<TextInput, InputProps>(function Input({
106
110
  isFocused: interactive && isFocused,
107
111
  }), [color, disabled, invalid, readOnly, interactive, isHovered, isPressed, isFocused])
108
112
 
109
- const resolvedContainerStyle = useMemo(
110
- () => theme.components.input.container(state, style),
111
- [theme, state, style]
112
- )
113
- const resolvedTextStyle = useMemo(
114
- () => theme.components.input.text(state, textStyle),
115
- [theme, state, textStyle]
116
- )
117
- const resolvedPlaceholderStyle = useMemo(
118
- () => theme.components.input.placeholder(state),
119
- [theme, state]
120
- )
121
-
122
-
123
- const resolvedStyle = useMemo(
124
- () => {
125
- const resolvedSheet: TextStyle = StyleSheet.flatten<TextStyle>([
126
- resolvedContainerStyle,
127
- resolvedTextStyle,
128
- ])
129
- // this is required for android to work
130
- // TODO find a better solution like separating container and text input
131
- delete resolvedSheet['boxShadow']
132
- return resolvedSheet
133
- },
134
- [resolvedContainerStyle, resolvedTextStyle]
135
- )
113
+ const resolvedContainerStyle = useMemoizedTheme(theme.components.input.container, state, style)
114
+ const resolvedStateLayerStyle = useMemoizedTheme(theme.components.input.stateLayer, state, stateLayerStyle)
115
+ const resolvedTextStyle = useMemoizedTheme(theme.components.input.text, state, textStyle)
116
+ const resolvedPlaceholderStyle = useMemoizedTheme(theme.components.input.placeholder, state)
136
117
 
137
118
  return (
138
- <TextInput
139
- {...props}
140
- ref={ref}
141
- value={value}
142
- editable={interactive}
143
- onChangeText={(nextValue) => {
144
- props.onChangeText?.(nextValue)
145
- restartTimer(() => {
146
- onEditComplete?.(nextValue)
147
- })
148
- setValue(nextValue)
149
- }}
150
- onFocus={(event) => {
151
- if (interactive) {
152
- setIsFocused(true)
153
- }
154
- props.onFocus?.(event)
155
- }}
156
- onBlur={(event) => {
157
- setIsFocused(false)
158
- props.onBlur?.(event)
159
- if (allowEditCompleteOnBlur) {
160
- onEditComplete?.(value ?? '')
161
- clearTimer()
162
- }
163
- }}
164
- onPressIn={(event) => {
165
- if (interactive) {
166
- setIsPressed(true)
167
- }
168
- props.onPressIn?.(event)
169
- }}
170
- onPressOut={(event) => {
171
- setIsPressed(false)
172
- props.onPressOut?.(event)
173
- }}
119
+ <View
120
+ style={resolvedContainerStyle}
174
121
  onPointerEnter={(event) => {
175
122
  if (interactive) {
176
123
  setIsHovered(true)
177
124
  }
178
- props.onPointerEnter?.(event)
125
+ onPointerEnter?.(event)
179
126
  }}
180
127
  onPointerLeave={(event) => {
181
128
  setIsHovered(false)
182
- props.onPointerLeave?.(event)
183
- }}
184
- onSubmitEditing={(event) => {
185
- props.onSubmitEditing?.(event)
186
- if (allowEnterComplete) {
187
- onEditComplete?.(value ?? '')
188
- clearTimer()
189
- }
129
+ onPointerLeave?.(event)
190
130
  }}
191
- placeholderTextColor={resolvedPlaceholderStyle.color}
192
- style={resolvedStyle}
193
- accessibilityState={{ disabled, selected: required }}
194
- />
131
+ >
132
+ <View
133
+ pointerEvents="none"
134
+ style={resolvedStateLayerStyle}
135
+ />
136
+ <TextInput
137
+ {...props}
138
+ ref={ref}
139
+ value={value}
140
+ editable={interactive}
141
+ onChangeText={(nextValue) => {
142
+ props.onChangeText?.(nextValue)
143
+ restartTimer(() => {
144
+ onEditComplete?.(nextValue)
145
+ })
146
+ setValue(nextValue)
147
+ }}
148
+ onFocus={(event) => {
149
+ if (interactive) {
150
+ setIsFocused(true)
151
+ }
152
+ props.onFocus?.(event)
153
+ }}
154
+ onBlur={(event) => {
155
+ setIsFocused(false)
156
+ props.onBlur?.(event)
157
+ if (allowEditCompleteOnBlur) {
158
+ onEditComplete?.(value ?? '')
159
+ clearTimer()
160
+ }
161
+ }}
162
+ onPressIn={(event) => {
163
+ if (interactive) {
164
+ setIsPressed(true)
165
+ }
166
+ props.onPressIn?.(event)
167
+ }}
168
+ onPressOut={(event) => {
169
+ setIsPressed(false)
170
+ props.onPressOut?.(event)
171
+ }}
172
+ onSubmitEditing={(event) => {
173
+ props.onSubmitEditing?.(event)
174
+ if (allowEnterComplete) {
175
+ onEditComplete?.(value ?? '')
176
+ clearTimer()
177
+ }
178
+ }}
179
+ placeholderTextColor={resolvedPlaceholderStyle.color}
180
+ style={resolvedTextStyle}
181
+ accessibilityState={{ disabled, selected: required }}
182
+ />
183
+ </View>
195
184
  )
196
185
  })
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react'
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native'
3
+
4
+ import type { MultiSelectRootProps } from './MultiSelectRoot'
5
+ import { MultiSelectRoot } from './MultiSelectRoot'
6
+ import type { MultiSelectOptionType } from './MultiSelectContext'
7
+ import { MultiSelectMenu, type MultiSelectMenuProps } from './MultiSelectMenu'
8
+ import { MultiSelectTrigger, type MultiSelectTriggerProps } from './MultiSelectTrigger'
9
+
10
+ export type MultiSelectProps<T = string> = Omit<MultiSelectRootProps<T>, 'children'> & {
11
+ children?: ReactNode,
12
+ placeholder?: MultiSelectTriggerProps<T>['placeholder'],
13
+ selectedDisplay?: (options: ReadonlyArray<MultiSelectOptionType<T>>) => ReactNode,
14
+ triggerProps?: MultiSelectTriggerProps<T>,
15
+ menuProps?: Omit<MultiSelectMenuProps, 'children'>,
16
+ style?: StyleProp<ViewStyle>,
17
+ }
18
+
19
+ export const MultiSelect = <T,>({
20
+ children,
21
+ placeholder,
22
+ selectedDisplay,
23
+ triggerProps,
24
+ menuProps,
25
+ style,
26
+ ...props
27
+ }: MultiSelectProps<T>) => {
28
+ return (
29
+ <MultiSelectRoot<T> {...props}>
30
+ <View style={[{ width: '100%' }, style]}>
31
+ <MultiSelectTrigger<T>
32
+ placeholder={placeholder}
33
+ selectedDisplay={selectedDisplay}
34
+ {...triggerProps}
35
+ />
36
+ <MultiSelectMenu {...menuProps}>{children}</MultiSelectMenu>
37
+ </View>
38
+ </MultiSelectRoot>
39
+ )
40
+ }
@@ -0,0 +1,70 @@
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 { UseMultiSelectFirstHighlightBehavior } from '../../../hooks/useMultiSelect'
7
+ import type { FormFieldInteractionStates } from '../../../types/formField'
8
+
9
+ export type MultiSelectOptionType<T = string> = {
10
+ id: string,
11
+ value: T,
12
+ label?: string,
13
+ display?: ReactNode,
14
+ disabled?: boolean,
15
+ }
16
+
17
+ export type MultiSelectContextState<T> = Partial<FormFieldInteractionStates> & {
18
+ value: T[],
19
+ options: ReadonlyArray<MultiSelectOptionType<T>>,
20
+ selectedIds: string[],
21
+ highlightedId: string | null,
22
+ isOpen: boolean,
23
+ }
24
+
25
+ export type MultiSelectContextComputedState<T> = {
26
+ visibleOptionIds: ReadonlyArray<string>,
27
+ idToOptionMap: Record<string, MultiSelectOptionType<T>>,
28
+ }
29
+
30
+ export type MultiSelectContextActions<T> = {
31
+ registerOption: (option: MultiSelectOptionType<T>) => () => void,
32
+ toggleSelection: (id: string, isSelected?: boolean) => void,
33
+ highlightFirst: () => void,
34
+ highlightLast: () => void,
35
+ highlightNext: () => void,
36
+ highlightPrevious: () => void,
37
+ highlightItem: (id: string) => void,
38
+ handleTypeaheadKey: (key: string) => void,
39
+ setIsOpen: (open: boolean, behavior?: UseMultiSelectFirstHighlightBehavior) => void,
40
+ toggleIsOpen: (behavior?: UseMultiSelectFirstHighlightBehavior) => void,
41
+ }
42
+
43
+ export type MultiSelectContextSearch = {
44
+ hasSearch: boolean,
45
+ searchQuery: string,
46
+ setSearchQuery: (query: string) => void,
47
+ }
48
+
49
+ export type MultiSelectContextConfig = {
50
+ searchableThreshold: number,
51
+ color?: ColorPairToken,
52
+ }
53
+
54
+ export type MultiSelectContextType<T> = MultiSelectContextActions<T>
55
+ & MultiSelectContextState<T>
56
+ & MultiSelectContextComputedState<T>
57
+ & {
58
+ config: MultiSelectContextConfig,
59
+ search: MultiSelectContextSearch,
60
+ }
61
+
62
+ export const MultiSelectContext = createContext<MultiSelectContextType<unknown> | null>(null)
63
+
64
+ export function useMultiSelectContext<T>(): MultiSelectContextType<T> {
65
+ const ctx = useContext(MultiSelectContext)
66
+ if (!ctx) {
67
+ throw new Error('useMultiSelectContext must be used within MultiSelectRoot')
68
+ }
69
+ return ctx as MultiSelectContextType<T>
70
+ }
@@ -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 { useMultiSelectContext } from './MultiSelectContext'
11
+
12
+ export type MultiSelectMenuProps = {
13
+ children?: ReactNode,
14
+ }
15
+
16
+ export const MultiSelectMenu = ({ children }: MultiSelectMenuProps) => {
17
+ const { theme } = useTheme()
18
+ const translation = useTranslation()
19
+ const context = useMultiSelectContext()
20
+ const multiSelectTheme = theme.components.multiSelect
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(multiSelectTheme.overlay, {})
28
+ const resolvedMenuStyle = useMemoizedTheme(multiSelectTheme.menu, { hasSearch: isSearchVisible })
29
+ const resolvedHeaderStyle = useMemoizedTheme(multiSelectTheme.header, {})
30
+ const resolvedEmptyTextStyle = useMemoizedTheme(multiSelectTheme.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,87 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useEffect, useMemo } from 'react'
3
+ import { View } from 'react-native'
4
+
5
+ import { useTheme } from '../../../global-contexts/theme/ThemeContext'
6
+ import { useMemoizedTheme } from '../../../hooks/useMemoizedTheme'
7
+ import { HightideIconRegistry } from '../../../icons/HightideIconRegistry'
8
+ import type { IconStyle } from '../../../icons'
9
+ import type { MultiSelectOptionState } from '../../../theme/types/components/multiSelect'
10
+ import { ListActionItem } from '../../list/ListActionItem'
11
+ import { ThemedIcon } from '../../visualization-and-display/ThemedIcon'
12
+ import { useMultiSelectContext } from './MultiSelectContext'
13
+
14
+ export type MultiSelectOptionProps<T = string> = {
15
+ id: string,
16
+ value: T,
17
+ label: string,
18
+ disabled?: boolean,
19
+ children?: ReactNode,
20
+ }
21
+
22
+ export const MultiSelectOption = <T,>({
23
+ id,
24
+ value,
25
+ label,
26
+ disabled = false,
27
+ children,
28
+ }: MultiSelectOptionProps<T>) => {
29
+ const { theme } = useTheme()
30
+ const context = useMultiSelectContext<T>()
31
+ const { registerOption } = context
32
+ const display = children ?? label
33
+
34
+ useEffect(() => {
35
+ return registerOption({
36
+ id,
37
+ value,
38
+ label,
39
+ display,
40
+ disabled,
41
+ })
42
+ }, [disabled, display, id, label, registerOption, value])
43
+
44
+ const isSelected = context.selectedIds.includes(id)
45
+ const isVisible = context.visibleOptionIds.includes(id)
46
+ const optionColor = isSelected
47
+ ? (context.config.color ?? theme.colors.primary)
48
+ : undefined
49
+
50
+ const optionState = useMemo((): MultiSelectOptionState => ({
51
+ color: context.config.color,
52
+ isSelected,
53
+ isHighlighted: context.highlightedId === id,
54
+ isDisabled: disabled,
55
+ }), [context.config.color, context.highlightedId, disabled, id, isSelected])
56
+
57
+ const multiSelectTheme = theme.components.multiSelect
58
+ const resolvedCheckboxStyle = useMemoizedTheme(multiSelectTheme.checkbox, optionState)
59
+ const checkboxIcon = useMemoizedTheme<MultiSelectOptionState, IconStyle>(
60
+ multiSelectTheme.checkboxIcon,
61
+ optionState
62
+ )
63
+
64
+ if (!context.isOpen || !isVisible) {
65
+ return null
66
+ }
67
+
68
+ return (
69
+ <ListActionItem
70
+ title={children ? undefined : label}
71
+ content={children}
72
+ color={optionColor}
73
+ disabled={disabled}
74
+ onPress={() => context.toggleSelection(id)}
75
+ leading={(
76
+ <View style={resolvedCheckboxStyle}>
77
+ <ThemedIcon
78
+ icon={HightideIconRegistry.Check}
79
+ size={checkboxIcon.size}
80
+ strokeWidth={checkboxIcon.strokeWidth}
81
+ color={checkboxIcon.color}
82
+ />
83
+ </View>
84
+ )}
85
+ />
86
+ )
87
+ }
@@ -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.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.id]: item }))
63
+ setOptions((previous) => {
64
+ const next = previous.filter((option) => option.id !== item.id)
65
+ next.push(item)
66
+ return next
67
+ })
68
+ return () => {
69
+ setOptions((previous) => previous.filter((option) => option.id !== item.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, item))?.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, item))?.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)
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)
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.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)
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
+ }