@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
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ export * from './MultiSelect'
2
+ export * from './MultiSelectContext'
3
+ export * from './MultiSelectMenu'
4
+ export * from './MultiSelectOption'
5
+ export * from './MultiSelectRoot'
6
+ export * from './MultiSelectTrigger'
@@ -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 = useMemo(
128
- () => searchBarTheme.container(state, containerStyle),
129
- [searchBarTheme, state, containerStyle]
130
- )
131
- const resolvedInputStyle = useMemo(
132
- () => searchBarTheme.input(state, inputStyle),
133
- [searchBarTheme, state, inputStyle]
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 ?? searchBarTheme.iconButtonColor(state)}
208
+ color={searchButtonProps?.color ?? resolvedIconButtonColor}
215
209
  variant={searchButtonProps?.variant ?? 'foreground'}
216
210
  disabled={disabled || searchButtonProps?.disabled}
217
211
  onPress={() => commitSearch(value ?? '')}
218
- style={[resolvedIconButtonStyle, searchButtonProps?.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
+ }