@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.
Files changed (92) hide show
  1. package/package.json +3 -3
  2. package/src/components/card/Card.tsx +2 -4
  3. package/src/components/chat/ChatAttachmentMessageBubble.tsx +155 -94
  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 +54 -52
  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 +70 -67
  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 +179 -0
  39. package/src/components/user-interaction/ThemedPressable.tsx +48 -49
  40. package/src/components/user-interaction/index.ts +1 -0
  41. package/src/components/visualization-and-display/Avatar.tsx +19 -17
  42. package/src/components/visualization-and-display/Chip.tsx +4 -12
  43. package/src/components/visualization-and-display/ThemedText.tsx +12 -5
  44. package/src/enums/chatMessageDirection.ts +18 -0
  45. package/src/enums/chatMessageStatus.ts +18 -0
  46. package/src/enums/hightideThemeModes.ts +18 -0
  47. package/src/enums/index.ts +7 -0
  48. package/src/enums/listItemContentOrder.ts +18 -0
  49. package/src/enums/themedTextAppearance.ts +18 -0
  50. package/src/enums/useMultiSelectFirstHighlightBehavior.ts +18 -0
  51. package/src/enums/useSelectFirstHighlightBehavior.ts +18 -0
  52. package/src/global-contexts/content-theme/ContentThemeProvider.tsx +9 -5
  53. package/src/global-contexts/theme/ThemeProvider.tsx +4 -1
  54. package/src/hooks/index.ts +2 -0
  55. package/src/hooks/useAnimatedStyleTransition.ts +460 -0
  56. package/src/hooks/useMemoizedTheme.ts +23 -0
  57. package/src/hooks/useMultiSelect.ts +3 -1
  58. package/src/hooks/useSelect.ts +3 -1
  59. package/src/icons/HightideIconRegistry.ts +2 -0
  60. package/src/theme/adapters/style-adapter-utils.ts +121 -27
  61. package/src/theme/resolvers/avatar.ts +48 -39
  62. package/src/theme/resolvers/button.ts +7 -17
  63. package/src/theme/resolvers/chat/attachment-message-bubble.ts +5 -6
  64. package/src/theme/resolvers/chat/conversation-row.ts +4 -6
  65. package/src/theme/resolvers/chat/message-bubble.ts +1 -2
  66. package/src/theme/resolvers/chat/message-composer.ts +1 -2
  67. package/src/theme/resolvers/chat/quick-reply-chip.ts +2 -3
  68. package/src/theme/resolvers/chat/system-line.ts +1 -2
  69. package/src/theme/resolvers/chat/thread-header.ts +3 -4
  70. package/src/theme/resolvers/checkbox.ts +2 -2
  71. package/src/theme/resolvers/chip.ts +1 -2
  72. package/src/theme/resolvers/iconButton.ts +4 -13
  73. package/src/theme/resolvers/index.ts +1 -0
  74. package/src/theme/resolvers/input.ts +30 -14
  75. package/src/theme/resolvers/listItem.ts +2 -3
  76. package/src/theme/resolvers/multiSelect.ts +1 -3
  77. package/src/theme/resolvers/searchBar.ts +1 -2
  78. package/src/theme/resolvers/select.ts +1 -2
  79. package/src/theme/resolvers/textarea.ts +14 -0
  80. package/src/theme/resolvers/themedPressable.ts +4 -15
  81. package/src/theme/themes/createHightideTheme.ts +6 -0
  82. package/src/theme/themes/hightideThemes.ts +3 -1
  83. package/src/theme/types/components/chat.ts +6 -21
  84. package/src/theme/types/components/hightide.ts +2 -0
  85. package/src/theme/types/components/index.ts +1 -0
  86. package/src/theme/types/components/input.ts +3 -0
  87. package/src/theme/types/components/textarea.ts +9 -0
  88. package/src/theme/types/resolver.ts +4 -27
  89. package/src/utils/index.ts +1 -0
  90. package/src/utils/pressableInteraction.ts +7 -0
  91. package/src/components/user-interaction/MultiSelect.tsx +0 -281
  92. package/src/components/user-interaction/Select.tsx +0 -242
@@ -5,9 +5,8 @@ import {
5
5
  } from 'react'
6
6
  import {
7
7
  TextInput,
8
- type StyleProp,
9
- type TextInputProps,
10
- type TextStyle
8
+ View,
9
+ type TextInputProps
11
10
  } from 'react-native'
12
11
 
13
12
  import {
@@ -18,9 +17,11 @@ import {
18
17
 
19
18
  import type { ColorPairToken } from '@helpwave/hightide-design/theme-tokens'
20
19
  import { useTheme } from '../../global-contexts/theme/ThemeContext'
20
+ import { useMemoizedTheme } from '../../hooks/useMemoizedTheme'
21
21
  import type {
22
22
  InputContainerStyle,
23
23
  InputState,
24
+ InputStateLayerStyle,
24
25
  InputTextStyle
25
26
  } from '../../theme/types/components/input'
26
27
  import type { StyleOverwrite } from '../../theme/types/resolver'
@@ -51,8 +52,8 @@ export type InputProps = Omit<TextInputProps, 'value' | 'style'>
51
52
  color?: ColorPairToken,
52
53
  editCompleteOptions?: EditCompleteOptions,
53
54
  initialValue?: string,
54
- style?: StyleProp<TextStyle>,
55
- containerStyle?: StyleOverwrite<InputState, InputContainerStyle>,
55
+ style?: StyleOverwrite<InputState, InputContainerStyle>,
56
+ stateLayerStyle?: StyleOverwrite<InputState, InputStateLayerStyle>,
56
57
  textStyle?: StyleOverwrite<InputState, InputTextStyle>,
57
58
  }
58
59
 
@@ -68,8 +69,10 @@ export const Input = forwardRef<TextInput, InputProps>(function Input({
68
69
  onEditComplete,
69
70
  editCompleteOptions,
70
71
  style,
71
- containerStyle,
72
+ stateLayerStyle,
72
73
  textStyle,
74
+ onPointerEnter,
75
+ onPointerLeave,
73
76
  ...props
74
77
  }, ref) {
75
78
  const { theme } = useTheme()
@@ -107,76 +110,76 @@ export const Input = forwardRef<TextInput, InputProps>(function Input({
107
110
  isFocused: interactive && isFocused,
108
111
  }), [color, disabled, invalid, readOnly, interactive, isHovered, isPressed, isFocused])
109
112
 
110
- const resolvedContainerStyle = useMemo(
111
- () => theme.components.input.container(state, containerStyle),
112
- [theme, state, containerStyle]
113
- )
114
- const resolvedTextStyle = useMemo(
115
- () => theme.components.input.text(state, textStyle),
116
- [theme, state, textStyle]
117
- )
118
- const resolvedPlaceholderStyle = useMemo(
119
- () => theme.components.input.placeholder(state),
120
- [theme, state]
121
- )
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)
122
117
 
123
118
  return (
124
- <TextInput
125
- {...props}
126
- ref={ref}
127
- value={value}
128
- editable={interactive}
129
- onChangeText={(nextValue) => {
130
- props.onChangeText?.(nextValue)
131
- restartTimer(() => {
132
- onEditComplete?.(nextValue)
133
- })
134
- setValue(nextValue)
135
- }}
136
- onFocus={(event) => {
137
- if (interactive) {
138
- setIsFocused(true)
139
- }
140
- props.onFocus?.(event)
141
- }}
142
- onBlur={(event) => {
143
- setIsFocused(false)
144
- props.onBlur?.(event)
145
- if (allowEditCompleteOnBlur) {
146
- onEditComplete?.(value ?? '')
147
- clearTimer()
148
- }
149
- }}
150
- onPressIn={(event) => {
151
- if (interactive) {
152
- setIsPressed(true)
153
- }
154
- props.onPressIn?.(event)
155
- }}
156
- onPressOut={(event) => {
157
- setIsPressed(false)
158
- props.onPressOut?.(event)
159
- }}
119
+ <View
120
+ style={resolvedContainerStyle}
160
121
  onPointerEnter={(event) => {
161
122
  if (interactive) {
162
123
  setIsHovered(true)
163
124
  }
164
- props.onPointerEnter?.(event)
125
+ onPointerEnter?.(event)
165
126
  }}
166
127
  onPointerLeave={(event) => {
167
128
  setIsHovered(false)
168
- props.onPointerLeave?.(event)
169
- }}
170
- onSubmitEditing={(event) => {
171
- props.onSubmitEditing?.(event)
172
- if (allowEnterComplete) {
173
- onEditComplete?.(value ?? '')
174
- clearTimer()
175
- }
129
+ onPointerLeave?.(event)
176
130
  }}
177
- placeholderTextColor={resolvedPlaceholderStyle.color}
178
- style={[resolvedContainerStyle, resolvedTextStyle, style]}
179
- accessibilityState={{ disabled, selected: required }}
180
- />
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>
181
184
  )
182
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
+ }