@helpwave/hightide-native 0.0.1

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 (65) hide show
  1. package/LICENSE +208 -0
  2. package/package.json +93 -0
  3. package/src/components/Button/Button.tsx +101 -0
  4. package/src/components/Button/index.ts +1 -0
  5. package/src/components/Checkbox/Checkbox.tsx +103 -0
  6. package/src/components/Checkbox/index.ts +1 -0
  7. package/src/components/Chip/Chip.tsx +93 -0
  8. package/src/components/Chip/index.ts +1 -0
  9. package/src/components/IconButton/IconButton.tsx +89 -0
  10. package/src/components/IconButton/index.ts +1 -0
  11. package/src/components/Input/Input.tsx +114 -0
  12. package/src/components/Input/index.ts +1 -0
  13. package/src/components/MultiSelect/MultiSelect.tsx +153 -0
  14. package/src/components/MultiSelect/index.ts +1 -0
  15. package/src/components/Select/Select.tsx +136 -0
  16. package/src/components/Select/index.ts +1 -0
  17. package/src/components/index.ts +7 -0
  18. package/src/global-contexts/HightideContext.ts +18 -0
  19. package/src/global-contexts/HightideProvider.tsx +57 -0
  20. package/src/global-contexts/hightide-config/HightideConfigUtils.ts +25 -0
  21. package/src/global-contexts/hightide-config/forward-exports.ts +1 -0
  22. package/src/global-contexts/hightide-config/index.ts +2 -0
  23. package/src/global-contexts/index.ts +6 -0
  24. package/src/global-contexts/localization/LocalizationProvider.tsx +31 -0
  25. package/src/global-contexts/localization/forward-exports.ts +8 -0
  26. package/src/global-contexts/localization/index.ts +2 -0
  27. package/src/global-contexts/theme/ThemeContext.ts +23 -0
  28. package/src/global-contexts/theme/ThemeProvider.tsx +101 -0
  29. package/src/global-contexts/theme/forward-exports.ts +7 -0
  30. package/src/global-contexts/theme/index.ts +3 -0
  31. package/src/global-contexts/translation/forward-exports.ts +11 -0
  32. package/src/global-contexts/translation/index.ts +1 -0
  33. package/src/hooks/index.ts +3 -0
  34. package/src/hooks/useMultiSelect.ts +232 -0
  35. package/src/hooks/useNativeKeyValueStore.ts +59 -0
  36. package/src/hooks/useSelect.ts +198 -0
  37. package/src/icons/Icon.tsx +35 -0
  38. package/src/icons/index.ts +1 -0
  39. package/src/storybook/helper.tsx +36 -0
  40. package/src/theme/index.ts +3 -0
  41. package/src/theme/resolvers/button.ts +74 -0
  42. package/src/theme/resolvers/checkbox.ts +86 -0
  43. package/src/theme/resolvers/chip.ts +64 -0
  44. package/src/theme/resolvers/coloring.ts +90 -0
  45. package/src/theme/resolvers/iconButton.ts +62 -0
  46. package/src/theme/resolvers/index.ts +8 -0
  47. package/src/theme/resolvers/input.ts +48 -0
  48. package/src/theme/resolvers/multiSelect.ts +95 -0
  49. package/src/theme/resolvers/select.ts +77 -0
  50. package/src/theme/themes/createTheme.ts +26 -0
  51. package/src/theme/themes/index.ts +2 -0
  52. package/src/theme/themes/nativeThemes.ts +15 -0
  53. package/src/theme/types/button.ts +41 -0
  54. package/src/theme/types/checkbox.ts +36 -0
  55. package/src/theme/types/chip.ts +41 -0
  56. package/src/theme/types/components.ts +17 -0
  57. package/src/theme/types/iconButton.ts +34 -0
  58. package/src/theme/types/index.ts +10 -0
  59. package/src/theme/types/input.ts +25 -0
  60. package/src/theme/types/multiSelect.ts +65 -0
  61. package/src/theme/types/resolver.ts +10 -0
  62. package/src/theme/types/select.ts +77 -0
  63. package/src/theme/types/theme.ts +13 -0
  64. package/src/types/formField.ts +12 -0
  65. package/src/types/index.ts +1 -0
@@ -0,0 +1,23 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { ThemeConfigValue, ThemeInformation } from './forward-exports'
3
+ import type { DesignTheme, ThemeMode } from '../../theme'
4
+
5
+ export type { ThemeInformation }
6
+
7
+ export type ThemeContextValue = Omit<ThemeConfigValue, 'theme'> & {
8
+ themeMode: ThemeMode,
9
+ theme: DesignTheme,
10
+ isInitialized: boolean,
11
+ }
12
+
13
+ export const ThemeContext = createContext<ThemeContextValue | null>(null)
14
+
15
+ export const useTheme = (): ThemeContextValue => {
16
+ const context = useContext(ThemeContext)
17
+ if (!context) {
18
+ throw new Error(
19
+ 'useTheme must be used within ThemeContext. Try adding a ThemeProvider around your app.'
20
+ )
21
+ }
22
+ return context
23
+ }
@@ -0,0 +1,101 @@
1
+ import { useEffect, useMemo, useState, type PropsWithChildren } from 'react'
2
+ import { Appearance } from 'react-native'
3
+ import { useNativeKeyValueStore } from '../../hooks/useNativeKeyValueStore'
4
+ import { getTheme, themes, type ThemeMode } from '../../theme'
5
+ import { HightideConfigUtils } from '../hightide-config/HightideConfigUtils'
6
+ import type { SystemTheme, ThemeInformation } from './forward-exports'
7
+ import { useCreateThemeConfig } from './forward-exports'
8
+ import { ThemeContext, useTheme, type ThemeContextValue } from './ThemeContext'
9
+
10
+ export type ThemeProviderProps = PropsWithChildren & {
11
+ theme?: string | null,
12
+ systemTheme?: SystemTheme,
13
+ fallbackTheme?: ThemeMode,
14
+ supportedThemes?: readonly ThemeInformation[],
15
+ onChangedTheme?: (theme: string) => void,
16
+ }
17
+
18
+ const resolveThemeMode = (theme: string): ThemeMode => {
19
+ if (theme in themes) {
20
+ return theme as ThemeMode
21
+ }
22
+ return 'light'
23
+ }
24
+
25
+ export const useThemeMode = (): ThemeMode => {
26
+ return useTheme().themeMode
27
+ }
28
+
29
+ const detectNativeSystemTheme = (): SystemTheme | undefined => {
30
+ const colorScheme = Appearance.getColorScheme()
31
+ if (colorScheme === 'dark' || colorScheme === 'light') {
32
+ return colorScheme
33
+ }
34
+ return undefined
35
+ }
36
+
37
+ const useNativeSystemTheme = (enabled: boolean) => {
38
+ const [systemTheme, setSystemTheme] = useState<SystemTheme | undefined>(() =>
39
+ (enabled ? detectNativeSystemTheme() : undefined))
40
+
41
+ useEffect(() => {
42
+ if (!enabled) return
43
+
44
+ const subscription = Appearance.addChangeListener(({ colorScheme }) => {
45
+ if (colorScheme === 'dark' || colorScheme === 'light') {
46
+ setSystemTheme(colorScheme)
47
+ }
48
+ })
49
+
50
+ return () => subscription.remove()
51
+ }, [enabled])
52
+
53
+ return systemTheme
54
+ }
55
+
56
+ export const ThemeProvider = ({
57
+ children,
58
+ theme,
59
+ fallbackTheme = 'light',
60
+ systemTheme: systemThemeOverride,
61
+ supportedThemes = HightideConfigUtils.defaultSupportedThemes,
62
+ onChangedTheme,
63
+ }: ThemeProviderProps) => {
64
+ const store = useNativeKeyValueStore()
65
+ const detectedSystemTheme = useNativeSystemTheme(systemThemeOverride === undefined)
66
+ const systemTheme = systemThemeOverride ?? detectedSystemTheme
67
+
68
+ const themeConfig = useCreateThemeConfig({
69
+ store,
70
+ fallbackTheme,
71
+ supportedThemes,
72
+ theme,
73
+ systemTheme,
74
+ onChangedTheme,
75
+ })
76
+
77
+ const value = useMemo((): ThemeContextValue => {
78
+ const themeMode = resolveThemeMode(themeConfig.theme)
79
+
80
+ return {
81
+ preferredTheme: themeConfig.preferredTheme,
82
+ setTheme: themeConfig.setTheme,
83
+ supportedThemes: themeConfig.supportedThemes,
84
+ themeMode,
85
+ theme: getTheme(themeMode),
86
+ isInitialized: store.isInitialized,
87
+ }
88
+ }, [
89
+ store.isInitialized,
90
+ themeConfig.preferredTheme,
91
+ themeConfig.setTheme,
92
+ themeConfig.supportedThemes,
93
+ themeConfig.theme,
94
+ ])
95
+
96
+ return (
97
+ <ThemeContext.Provider value={value}>
98
+ {children}
99
+ </ThemeContext.Provider>
100
+ )
101
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ useCreateThemeConfig,
3
+ type ThemeInformation,
4
+ type ThemeConfigValue,
5
+ type SystemTheme,
6
+ type UseCreateThemeConfigProps,
7
+ } from '@helpwave/hightide-utils/context/theme'
@@ -0,0 +1,3 @@
1
+ export * from './ThemeContext'
2
+ export * from './ThemeProvider'
3
+ export * from './forward-exports'
@@ -0,0 +1,11 @@
1
+ export {
2
+ TranslationContext,
3
+ TranslationProvider,
4
+ useTranslation,
5
+ useHightideTranslation,
6
+ useICUTranslation,
7
+ useSingleKeyTranslation,
8
+ type TranslationContextValue,
9
+ type TranslationProviderProps,
10
+ type UseCreateTranslationContextProps,
11
+ } from '@helpwave/hightide-utils/context/translation'
@@ -0,0 +1 @@
1
+ export * from './forward-exports'
@@ -0,0 +1,3 @@
1
+ export * from './useMultiSelect'
2
+ export * from './useNativeKeyValueStore'
3
+ export * from './useSelect'
@@ -0,0 +1,232 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useState
6
+ } from 'react'
7
+ import {
8
+ useEventCallbackStabilizer,
9
+ useListNavigation,
10
+ useMultiSelection,
11
+ useSearch,
12
+ useTypeAheadSearch
13
+ } from '@helpwave/hightide-utils/hooks'
14
+
15
+ export interface UseMultiSelectOption {
16
+ id: string,
17
+ label?: string,
18
+ disabled?: boolean,
19
+ }
20
+
21
+ export interface UseMultiSelectOptions {
22
+ options: ReadonlyArray<UseMultiSelectOption>,
23
+ value?: ReadonlyArray<string>,
24
+ onValueChange?: (value: string[]) => void,
25
+ onEditComplete?: (value: string[]) => void,
26
+ initialValue?: string[],
27
+ initialIsOpen?: boolean,
28
+ onClose?: () => void,
29
+ typeAheadResetMs?: number,
30
+ }
31
+
32
+ export type UseMultiSelectFirstHighlightBehavior = 'first' | 'last'
33
+
34
+ export interface UseMultiSelectState {
35
+ value: string[],
36
+ highlightedId: string | null,
37
+ isOpen: boolean,
38
+ searchQuery: string,
39
+ options: ReadonlyArray<UseMultiSelectOption>,
40
+ }
41
+
42
+ export interface UseMultiSelectComputedState {
43
+ visibleOptionIds: ReadonlyArray<string>,
44
+ }
45
+
46
+ export interface UseMultiSelectActions {
47
+ setIsOpen: (isOpen: boolean, behavior?: UseMultiSelectFirstHighlightBehavior) => void,
48
+ toggleOpen: (behavior?: UseMultiSelectFirstHighlightBehavior) => void,
49
+ setSearchQuery: (query: string) => void,
50
+ highlightFirst: () => void,
51
+ highlightLast: () => void,
52
+ highlightNext: () => void,
53
+ highlightPrevious: () => void,
54
+ highlightItem: (id: string) => void,
55
+ toggleSelection: (id: string, isSelected?: boolean) => void,
56
+ setSelection: (ids: string[]) => void,
57
+ isSelected: (id: string) => boolean,
58
+ handleTypeaheadKey: (key: string) => void,
59
+ }
60
+
61
+ export interface UseMultiSelectReturn extends UseMultiSelectState, UseMultiSelectComputedState, UseMultiSelectActions {}
62
+
63
+ export function useMultiSelect({
64
+ options,
65
+ value: controlledValue,
66
+ onValueChange,
67
+ onEditComplete,
68
+ initialValue = [],
69
+ onClose,
70
+ initialIsOpen = false,
71
+ typeAheadResetMs = 500,
72
+ }: UseMultiSelectOptions): UseMultiSelectReturn {
73
+ const [isOpen, setIsOpen] = useState(initialIsOpen)
74
+ const [searchQuery, setSearchQuery] = useState('')
75
+
76
+ const selectionOptions = useMemo(
77
+ () => options.map((option) => ({ id: option.id, disabled: option.disabled })),
78
+ [options]
79
+ )
80
+
81
+ const { selection, toggleSelection, setSelection, isSelected } = useMultiSelection({
82
+ options: selectionOptions,
83
+ value: controlledValue,
84
+ onSelectionChange: (ids) => onValueChange?.(Array.from(ids)),
85
+ initialSelection: initialValue,
86
+ isControlled: controlledValue !== undefined,
87
+ })
88
+
89
+ const editCompleteStable = useEventCallbackStabilizer(onEditComplete)
90
+ const onCloseStable = useEventCallbackStabilizer(onClose)
91
+
92
+ const { searchResult: visibleOptions } = useSearch({
93
+ items: options,
94
+ searchQuery,
95
+ toTags: useCallback((option: UseMultiSelectOption) => [option.label ?? ''], []),
96
+ })
97
+
98
+ const visibleOptionIds = useMemo(
99
+ () => visibleOptions.map((option) => option.id),
100
+ [visibleOptions]
101
+ )
102
+
103
+ const enabledOptions = useMemo(
104
+ () => visibleOptions.filter((option) => !option.disabled),
105
+ [visibleOptions]
106
+ )
107
+
108
+ const listNav = useListNavigation({
109
+ options: enabledOptions.map((option) => option.id),
110
+ initialValue: selection[0] ?? null,
111
+ })
112
+
113
+ const typeAhead = useTypeAheadSearch({
114
+ options: enabledOptions,
115
+ resetTimer: typeAheadResetMs,
116
+ toString: (option) => option.label ?? '',
117
+ onResultChange: useCallback(
118
+ (option: UseMultiSelectOption | null) => {
119
+ if (option) listNav.highlight(option.id)
120
+ },
121
+ [listNav]
122
+ ),
123
+ })
124
+
125
+ useEffect(() => {
126
+ if (!isOpen) typeAhead.reset()
127
+ }, [isOpen, typeAhead])
128
+
129
+ const highlightItem = useCallback((id: string) => {
130
+ if (!enabledOptions.some((option) => option.id === id)) return
131
+ listNav.highlight(id)
132
+ }, [enabledOptions, listNav])
133
+
134
+ const toggleSelectionValue = useCallback((id: string, newIsSelected?: boolean) => {
135
+ const next = newIsSelected ?? !isSelected(id)
136
+ if (next) {
137
+ toggleSelection(id)
138
+ } else {
139
+ setSelection(selection.filter((selectedId) => selectedId !== id))
140
+ }
141
+ highlightItem(id)
142
+ }, [highlightItem, isSelected, selection, setSelection, toggleSelection])
143
+
144
+ const setIsOpenWrapper = useCallback(
145
+ (open: boolean, behavior?: UseMultiSelectFirstHighlightBehavior) => {
146
+ setIsOpen(open)
147
+ const highlightBehavior = behavior ?? 'first'
148
+ if (open) {
149
+ if (enabledOptions.length > 0) {
150
+ let selected: UseMultiSelectOption | undefined
151
+ if (highlightBehavior === 'first') {
152
+ selected = enabledOptions.find((option) => isSelected(option.id))
153
+ selected ??= enabledOptions[0]
154
+ } else {
155
+ selected = [...enabledOptions]
156
+ .reverse()
157
+ .find((option) => isSelected(option.id))
158
+ selected ??= enabledOptions[enabledOptions.length - 1]
159
+ }
160
+ if (selected) highlightItem(selected.id)
161
+ }
162
+ } else {
163
+ setSearchQuery('')
164
+ onCloseStable?.()
165
+ editCompleteStable?.(Array.from(selection))
166
+ }
167
+ },
168
+ [editCompleteStable, enabledOptions, highlightItem, isSelected, onCloseStable, selection]
169
+ )
170
+
171
+ const toggleOpenWrapper = useCallback(
172
+ (behavior?: UseMultiSelectFirstHighlightBehavior) => {
173
+ setIsOpenWrapper(!isOpen, behavior)
174
+ },
175
+ [isOpen, setIsOpenWrapper]
176
+ )
177
+
178
+ const state: UseMultiSelectState = useMemo(
179
+ () => ({
180
+ value: [...selection],
181
+ highlightedId: listNav.highlightedId,
182
+ isOpen,
183
+ searchQuery,
184
+ options,
185
+ }),
186
+ [selection, listNav.highlightedId, isOpen, searchQuery, options]
187
+ )
188
+
189
+ const computedState: UseMultiSelectComputedState = useMemo(
190
+ () => ({ visibleOptionIds }),
191
+ [visibleOptionIds]
192
+ )
193
+
194
+ const actions: UseMultiSelectActions = useMemo(
195
+ () => ({
196
+ setIsOpen: setIsOpenWrapper,
197
+ toggleOpen: toggleOpenWrapper,
198
+ setSearchQuery,
199
+ highlightFirst: listNav.first,
200
+ highlightLast: listNav.last,
201
+ highlightNext: listNav.next,
202
+ highlightPrevious: listNav.previous,
203
+ highlightItem,
204
+ toggleSelection: toggleSelectionValue,
205
+ setSelection,
206
+ isSelected,
207
+ handleTypeaheadKey: typeAhead.addToTypeAhead,
208
+ }),
209
+ [
210
+ setIsOpenWrapper,
211
+ toggleOpenWrapper,
212
+ listNav.first,
213
+ listNav.last,
214
+ listNav.next,
215
+ listNav.previous,
216
+ highlightItem,
217
+ toggleSelectionValue,
218
+ setSelection,
219
+ isSelected,
220
+ typeAhead.addToTypeAhead,
221
+ ]
222
+ )
223
+
224
+ return useMemo(
225
+ () => ({
226
+ ...state,
227
+ ...computedState,
228
+ ...actions,
229
+ }),
230
+ [state, computedState, actions]
231
+ )
232
+ }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import AsyncStorage from '@react-native-async-storage/async-storage'
3
+ import type { SimpleValueStore } from '@helpwave/hightide-utils/hooks'
4
+
5
+ const storagePrefix = '@helpwave/hightide/'
6
+
7
+ export const useNativeKeyValueStore = (): SimpleValueStore => {
8
+ const cacheRef = useRef(new Map<string, string | null>())
9
+ const [isInitialized, setIsInitialized] = useState(false)
10
+
11
+ useEffect(() => {
12
+ let cancelled = false
13
+
14
+ const hydrate = async () => {
15
+ try {
16
+ const keys = await AsyncStorage.getAllKeys()
17
+ const relevantKeys = keys.filter((key) => key.startsWith(storagePrefix))
18
+ if (relevantKeys.length > 0) {
19
+ const entries = await AsyncStorage.multiGet(relevantKeys)
20
+ if (cancelled) return
21
+ for (const [fullKey, value] of entries) {
22
+ cacheRef.current.set(fullKey.slice(storagePrefix.length), value)
23
+ }
24
+ }
25
+ } catch (error) {
26
+ console.error('useNativeKeyValueStore: Failed to hydrate AsyncStorage', error)
27
+ } finally {
28
+ if (!cancelled) {
29
+ setIsInitialized(true)
30
+ }
31
+ }
32
+ }
33
+
34
+ void hydrate()
35
+ return () => {
36
+ cancelled = true
37
+ }
38
+ }, [])
39
+
40
+ return useMemo(() => ({
41
+ isInitialized,
42
+ getValue: (key) => {
43
+ if (!cacheRef.current.has(key)) return null
44
+ return cacheRef.current.get(key) ?? null
45
+ },
46
+ setValue: (key, value) => {
47
+ cacheRef.current.set(key, value)
48
+ if (value === null) {
49
+ void AsyncStorage.removeItem(`${storagePrefix}${key}`)
50
+ return
51
+ }
52
+ void AsyncStorage.setItem(`${storagePrefix}${key}`, value)
53
+ },
54
+ deleteValue: (key) => {
55
+ cacheRef.current.delete(key)
56
+ void AsyncStorage.removeItem(`${storagePrefix}${key}`)
57
+ },
58
+ }), [isInitialized])
59
+ }
@@ -0,0 +1,198 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useState
6
+ } from 'react'
7
+ import {
8
+ useEventCallbackStabilizer,
9
+ useListNavigation,
10
+ useSearch,
11
+ useSingleSelection,
12
+ useTypeAheadSearch
13
+ } from '@helpwave/hightide-utils/hooks'
14
+
15
+ export interface UseSelectOption {
16
+ id: string,
17
+ label?: string,
18
+ disabled?: boolean,
19
+ }
20
+
21
+ export interface UseSelectOptions {
22
+ options: ReadonlyArray<UseSelectOption>,
23
+ value?: string | null,
24
+ initialValue?: string | null,
25
+ initialIsOpen?: boolean,
26
+ onValueChange?: (value: string) => void,
27
+ onEditComplete?: (value: string) => void,
28
+ onClose?: () => void,
29
+ onIsOpenChange?: (isOpen: boolean) => void,
30
+ typeAheadResetMs?: number,
31
+ }
32
+
33
+ export type UseSelectFirstHighlightBehavior = 'first' | 'last'
34
+
35
+ export interface UseSelectState {
36
+ value: string | null,
37
+ highlightedValue: string | null,
38
+ isOpen: boolean,
39
+ searchQuery: string,
40
+ options: ReadonlyArray<UseSelectOption>,
41
+ }
42
+
43
+ export interface UseSelectComputedState {
44
+ visibleOptionIds: ReadonlyArray<string>,
45
+ }
46
+
47
+ export interface UseSelectActions {
48
+ setIsOpen: (isOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => void,
49
+ toggleOpen: (behavior?: UseSelectFirstHighlightBehavior) => void,
50
+ setSearchQuery: (query: string) => void,
51
+ highlightFirst: () => void,
52
+ highlightLast: () => void,
53
+ highlightNext: () => void,
54
+ highlightPrevious: () => void,
55
+ highlightItem: (value: string) => void,
56
+ selectValue: (value: string) => void,
57
+ handleTypeaheadKey: (key: string) => void,
58
+ }
59
+
60
+ export interface UseSelectReturn extends UseSelectState, UseSelectComputedState, UseSelectActions {}
61
+
62
+ export function useSelect({
63
+ options,
64
+ value: controlledValue,
65
+ onValueChange,
66
+ onEditComplete,
67
+ initialValue = null,
68
+ onClose,
69
+ onIsOpenChange,
70
+ initialIsOpen = false,
71
+ typeAheadResetMs = 500,
72
+ }: UseSelectOptions): UseSelectReturn {
73
+ const [isOpen, setIsOpen] = useState(initialIsOpen)
74
+ const [searchQuery, setSearchQuery] = useState('')
75
+
76
+ const onValueChangeStable = useEventCallbackStabilizer(onValueChange)
77
+ const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete)
78
+ const onCloseStable = useEventCallbackStabilizer(onClose)
79
+ const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange)
80
+
81
+ const onSelectionChangeWrapper = useCallback((id: string | null) => {
82
+ if (id === null) return
83
+ onValueChangeStable(id)
84
+ onEditCompleteStable(id)
85
+ setIsOpen(false)
86
+ }, [onValueChangeStable, onEditCompleteStable])
87
+
88
+ const { selection, selectValue } = useSingleSelection({
89
+ options,
90
+ selection: controlledValue,
91
+ onSelectionChange: onSelectionChangeWrapper,
92
+ initialSelection: initialValue,
93
+ })
94
+
95
+ const { searchResult: visibleOptions } = useSearch({
96
+ items: options,
97
+ searchQuery,
98
+ toTags: useCallback((option: UseSelectOption) => [option.label ?? ''], []),
99
+ })
100
+
101
+ const visibleOptionIds = useMemo(() => visibleOptions.map((option) => option.id), [visibleOptions])
102
+ const enabledOptions = useMemo(() => visibleOptions.filter((option) => !option.disabled), [visibleOptions])
103
+
104
+ const {
105
+ highlightedId,
106
+ highlight: listNavHighlight,
107
+ first: listNavFirst,
108
+ last: listNavLast,
109
+ next: listNavNext,
110
+ previous: listNavPrevious,
111
+ } = useListNavigation({
112
+ options: enabledOptions.map((option) => option.id),
113
+ initialValue: selection,
114
+ })
115
+
116
+ const { addToTypeAhead, reset: typeAheadReset } = useTypeAheadSearch({
117
+ options: enabledOptions,
118
+ resetTimer: typeAheadResetMs,
119
+ toString: (option) => option.label ?? '',
120
+ onResultChange: useCallback((option: UseSelectOption | null) => {
121
+ if (option) listNavHighlight(option.id)
122
+ }, [listNavHighlight]),
123
+ })
124
+
125
+ useEffect(() => {
126
+ if (!isOpen) typeAheadReset()
127
+ }, [isOpen, typeAheadReset])
128
+
129
+ const highlightItem = useCallback((value: string) => {
130
+ if (!enabledOptions.some((option) => option.id === value)) return
131
+ listNavHighlight(value)
132
+ }, [enabledOptions, listNavHighlight])
133
+
134
+ const setIsOpenWrapper = useCallback((nextIsOpen: boolean, behavior?: UseSelectFirstHighlightBehavior) => {
135
+ const highlightBehavior = behavior ?? 'first'
136
+ if (nextIsOpen) {
137
+ if (selection == null) {
138
+ if (highlightBehavior === 'first') {
139
+ listNavFirst()
140
+ } else {
141
+ listNavLast()
142
+ }
143
+ } else {
144
+ highlightItem(selection)
145
+ }
146
+ } else {
147
+ setSearchQuery('')
148
+ onCloseStable?.()
149
+ }
150
+ setIsOpen(nextIsOpen)
151
+ onIsOpenChangeStable(nextIsOpen)
152
+ }, [highlightItem, listNavFirst, listNavLast, onCloseStable, onIsOpenChangeStable, selection])
153
+
154
+ const toggleOpenWrapper = useCallback((behavior?: UseSelectFirstHighlightBehavior) => {
155
+ setIsOpenWrapper(!isOpen, behavior)
156
+ }, [isOpen, setIsOpenWrapper])
157
+
158
+ const state: UseSelectState = useMemo(() => ({
159
+ value: selection,
160
+ highlightedValue: highlightedId,
161
+ isOpen,
162
+ searchQuery,
163
+ options,
164
+ }), [selection, highlightedId, isOpen, searchQuery, options])
165
+
166
+ const computedState: UseSelectComputedState = useMemo(() => ({
167
+ visibleOptionIds,
168
+ }), [visibleOptionIds])
169
+
170
+ const actions: UseSelectActions = useMemo(() => ({
171
+ selectValue,
172
+ setIsOpen: setIsOpenWrapper,
173
+ toggleOpen: toggleOpenWrapper,
174
+ setSearchQuery,
175
+ highlightFirst: listNavFirst,
176
+ highlightLast: listNavLast,
177
+ highlightNext: listNavNext,
178
+ highlightPrevious: listNavPrevious,
179
+ highlightItem,
180
+ handleTypeaheadKey: addToTypeAhead,
181
+ }), [
182
+ selectValue,
183
+ setIsOpenWrapper,
184
+ toggleOpenWrapper,
185
+ listNavFirst,
186
+ listNavLast,
187
+ listNavNext,
188
+ listNavPrevious,
189
+ highlightItem,
190
+ addToTypeAhead,
191
+ ])
192
+
193
+ return useMemo(() => ({
194
+ ...state,
195
+ ...computedState,
196
+ ...actions,
197
+ }), [state, computedState, actions])
198
+ }
@@ -0,0 +1,35 @@
1
+ import { getIconSizePx, getIconStrokeWidth, type ElementSize } from '@helpwave/hightide-design'
2
+ import type { LucideIcon } from 'lucide-react-native'
3
+
4
+ export type IconProps = {
5
+ icon: LucideIcon,
6
+ size?: ElementSize,
7
+ color?: string,
8
+ }
9
+
10
+ export const Icon = ({
11
+ icon: IconComponent,
12
+ size = 'md',
13
+ color,
14
+ }: IconProps) => {
15
+ return (
16
+ <IconComponent
17
+ size={getIconSizePx(size)}
18
+ strokeWidth={getIconStrokeWidth(size)}
19
+ color={color}
20
+ />
21
+ )
22
+ }
23
+
24
+ export const resolveIconProps = (
25
+ size: ElementSize = 'md',
26
+ color?: string
27
+ ): {
28
+ size: number,
29
+ strokeWidth: number,
30
+ color?: string,
31
+ } => ({
32
+ size: getIconSizePx(size),
33
+ strokeWidth: getIconStrokeWidth(size),
34
+ color,
35
+ })
@@ -0,0 +1 @@
1
+ export * from './Icon'