@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.
- package/LICENSE +208 -0
- package/package.json +93 -0
- package/src/components/Button/Button.tsx +101 -0
- package/src/components/Button/index.ts +1 -0
- package/src/components/Checkbox/Checkbox.tsx +103 -0
- package/src/components/Checkbox/index.ts +1 -0
- package/src/components/Chip/Chip.tsx +93 -0
- package/src/components/Chip/index.ts +1 -0
- package/src/components/IconButton/IconButton.tsx +89 -0
- package/src/components/IconButton/index.ts +1 -0
- package/src/components/Input/Input.tsx +114 -0
- package/src/components/Input/index.ts +1 -0
- package/src/components/MultiSelect/MultiSelect.tsx +153 -0
- package/src/components/MultiSelect/index.ts +1 -0
- package/src/components/Select/Select.tsx +136 -0
- package/src/components/Select/index.ts +1 -0
- package/src/components/index.ts +7 -0
- package/src/global-contexts/HightideContext.ts +18 -0
- package/src/global-contexts/HightideProvider.tsx +57 -0
- package/src/global-contexts/hightide-config/HightideConfigUtils.ts +25 -0
- package/src/global-contexts/hightide-config/forward-exports.ts +1 -0
- package/src/global-contexts/hightide-config/index.ts +2 -0
- package/src/global-contexts/index.ts +6 -0
- package/src/global-contexts/localization/LocalizationProvider.tsx +31 -0
- package/src/global-contexts/localization/forward-exports.ts +8 -0
- package/src/global-contexts/localization/index.ts +2 -0
- package/src/global-contexts/theme/ThemeContext.ts +23 -0
- package/src/global-contexts/theme/ThemeProvider.tsx +101 -0
- package/src/global-contexts/theme/forward-exports.ts +7 -0
- package/src/global-contexts/theme/index.ts +3 -0
- package/src/global-contexts/translation/forward-exports.ts +11 -0
- package/src/global-contexts/translation/index.ts +1 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useMultiSelect.ts +232 -0
- package/src/hooks/useNativeKeyValueStore.ts +59 -0
- package/src/hooks/useSelect.ts +198 -0
- package/src/icons/Icon.tsx +35 -0
- package/src/icons/index.ts +1 -0
- package/src/storybook/helper.tsx +36 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/resolvers/button.ts +74 -0
- package/src/theme/resolvers/checkbox.ts +86 -0
- package/src/theme/resolvers/chip.ts +64 -0
- package/src/theme/resolvers/coloring.ts +90 -0
- package/src/theme/resolvers/iconButton.ts +62 -0
- package/src/theme/resolvers/index.ts +8 -0
- package/src/theme/resolvers/input.ts +48 -0
- package/src/theme/resolvers/multiSelect.ts +95 -0
- package/src/theme/resolvers/select.ts +77 -0
- package/src/theme/themes/createTheme.ts +26 -0
- package/src/theme/themes/index.ts +2 -0
- package/src/theme/themes/nativeThemes.ts +15 -0
- package/src/theme/types/button.ts +41 -0
- package/src/theme/types/checkbox.ts +36 -0
- package/src/theme/types/chip.ts +41 -0
- package/src/theme/types/components.ts +17 -0
- package/src/theme/types/iconButton.ts +34 -0
- package/src/theme/types/index.ts +10 -0
- package/src/theme/types/input.ts +25 -0
- package/src/theme/types/multiSelect.ts +65 -0
- package/src/theme/types/resolver.ts +10 -0
- package/src/theme/types/select.ts +77 -0
- package/src/theme/types/theme.ts +13 -0
- package/src/types/formField.ts +12 -0
- 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,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,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'
|