@helpwave/hightide-native 0.4.0 → 0.5.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.
- package/package.json +7 -3
- package/src/components/user-interaction/MultiSelect.tsx +8 -5
- package/src/components/user-interaction/Select.tsx +14 -15
- package/src/components/visualization-and-display/ThemedIcon.tsx +10 -7
- package/src/components/visualization-and-display/ThemedText.tsx +9 -10
- package/src/global-contexts/content-theme/ContentThemeContext.ts +9 -7
- package/src/global-contexts/content-theme/ContentThemeProvider.tsx +30 -16
- package/src/global-contexts/theme/ThemeProvider.tsx +1 -0
- package/src/theme/resolvers/input.ts +1 -1
- package/src/theme/resolvers/searchBar.ts +1 -1
- package/src/theme/types/semantics.ts +3 -3
- package/src/utils/hex.ts +193 -0
- package/src/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
12
|
"license": "AGPL-3.0-only",
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.5.0",
|
|
14
14
|
"type": "module",
|
|
15
15
|
"files": [
|
|
16
16
|
"src"
|
|
@@ -39,12 +39,16 @@
|
|
|
39
39
|
"./types": {
|
|
40
40
|
"types": "./src/types/index.ts",
|
|
41
41
|
"default": "./src/types/index.ts"
|
|
42
|
+
},
|
|
43
|
+
"./utils": {
|
|
44
|
+
"types": "./src/utils/index.ts",
|
|
45
|
+
"default": "./src/utils/index.ts"
|
|
42
46
|
}
|
|
43
47
|
},
|
|
44
48
|
"dependencies": {
|
|
45
49
|
"lucide-react-native": "0.561.0",
|
|
46
|
-
"@helpwave/hightide-
|
|
47
|
-
"@helpwave/hightide-
|
|
50
|
+
"@helpwave/hightide-design": "0.5.0",
|
|
51
|
+
"@helpwave/hightide-utils": "0.1.0"
|
|
48
52
|
},
|
|
49
53
|
"peerDependencies": {
|
|
50
54
|
"@react-native-async-storage/async-storage": ">=2.0.0",
|
|
@@ -67,6 +67,8 @@ export const MultiSelect = ({
|
|
|
67
67
|
onEditComplete,
|
|
68
68
|
})
|
|
69
69
|
|
|
70
|
+
const isSearchVisible = showSearch && options.length >= 6
|
|
71
|
+
|
|
70
72
|
const selectedOptions = useMemo(() => {
|
|
71
73
|
return options.filter((option) => multiSelect.isSelected(option.id))
|
|
72
74
|
}, [multiSelect, options])
|
|
@@ -88,8 +90,8 @@ export const MultiSelect = ({
|
|
|
88
90
|
[multiSelectTheme]
|
|
89
91
|
)
|
|
90
92
|
const resolvedMenuStyle = useMemo(
|
|
91
|
-
() => multiSelectTheme.menu({ hasSearch:
|
|
92
|
-
[multiSelectTheme,
|
|
93
|
+
() => multiSelectTheme.menu({ hasSearch: isSearchVisible }),
|
|
94
|
+
[multiSelectTheme, isSearchVisible]
|
|
93
95
|
)
|
|
94
96
|
const resolvedHeaderStyle = useMemo(
|
|
95
97
|
() => multiSelectTheme.header({}),
|
|
@@ -103,7 +105,7 @@ export const MultiSelect = ({
|
|
|
103
105
|
() => options.filter((option) => multiSelect.visibleOptionIds.includes(option.id)),
|
|
104
106
|
[options, multiSelect.visibleOptionIds]
|
|
105
107
|
)
|
|
106
|
-
const showEmptySearchResults =
|
|
108
|
+
const showEmptySearchResults = isSearchVisible
|
|
107
109
|
&& multiSelect.searchQuery.trim().length > 0
|
|
108
110
|
&& visibleOptions.length === 0
|
|
109
111
|
|
|
@@ -210,7 +212,7 @@ export const MultiSelect = ({
|
|
|
210
212
|
style={resolvedMenuStyle}
|
|
211
213
|
onPress={(event) => event.stopPropagation()}
|
|
212
214
|
>
|
|
213
|
-
{
|
|
215
|
+
{isSearchVisible && (
|
|
214
216
|
<View style={resolvedHeaderStyle}>
|
|
215
217
|
<SearchBar
|
|
216
218
|
value={multiSelect.searchQuery}
|
|
@@ -260,7 +262,8 @@ export const MultiSelect = ({
|
|
|
260
262
|
<View style={multiSelectTheme.checkbox(optionState)}>
|
|
261
263
|
<ThemedIcon
|
|
262
264
|
icon={HightideIconRegistry.Check}
|
|
263
|
-
size=
|
|
265
|
+
size={checkboxIcon.size}
|
|
266
|
+
strokeWidth={checkboxIcon.strokeWidth}
|
|
264
267
|
color={checkboxIcon.color}
|
|
265
268
|
/>
|
|
266
269
|
</View>
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
type UseSelectOption
|
|
21
21
|
} from '../../hooks/useSelect'
|
|
22
22
|
import type { SelectState } from '../../theme/types/components/select'
|
|
23
|
-
import type { Color } from '../../theme/types/color'
|
|
24
23
|
import type {
|
|
25
24
|
FormFieldDataHandling,
|
|
26
25
|
FormFieldInteractionStates
|
|
@@ -69,6 +68,8 @@ export const Select = ({
|
|
|
69
68
|
onEditComplete,
|
|
70
69
|
})
|
|
71
70
|
|
|
71
|
+
const isSearchVisible = showSearch && options.length >= 6
|
|
72
|
+
|
|
72
73
|
const selectedLabel = useMemo(() => {
|
|
73
74
|
const selected = options.find((option) => option.id === select.value)
|
|
74
75
|
return selected?.label ?? placeholder
|
|
@@ -95,8 +96,8 @@ export const Select = ({
|
|
|
95
96
|
[selectTheme]
|
|
96
97
|
)
|
|
97
98
|
const resolvedMenuStyle = useMemo(
|
|
98
|
-
() => selectTheme.menu({ hasSearch:
|
|
99
|
-
[selectTheme,
|
|
99
|
+
() => selectTheme.menu({ hasSearch: isSearchVisible }),
|
|
100
|
+
[selectTheme, isSearchVisible]
|
|
100
101
|
)
|
|
101
102
|
const resolvedHeaderStyle = useMemo(
|
|
102
103
|
() => selectTheme.header({}),
|
|
@@ -106,7 +107,7 @@ export const Select = ({
|
|
|
106
107
|
() => selectTheme.emptyText({}),
|
|
107
108
|
[selectTheme]
|
|
108
109
|
)
|
|
109
|
-
const showEmptySearchResults =
|
|
110
|
+
const showEmptySearchResults = isSearchVisible
|
|
110
111
|
&& select.searchQuery.trim().length > 0
|
|
111
112
|
&& visibleOptions.length === 0
|
|
112
113
|
|
|
@@ -181,7 +182,7 @@ export const Select = ({
|
|
|
181
182
|
style={resolvedMenuStyle}
|
|
182
183
|
onPress={(event) => event.stopPropagation()}
|
|
183
184
|
>
|
|
184
|
-
{
|
|
185
|
+
{isSearchVisible && (
|
|
185
186
|
<View style={resolvedHeaderStyle}>
|
|
186
187
|
<SearchBar
|
|
187
188
|
value={select.searchQuery}
|
|
@@ -220,16 +221,14 @@ export const Select = ({
|
|
|
220
221
|
color={optionColor}
|
|
221
222
|
disabled={item.disabled}
|
|
222
223
|
onPress={() => select.selectValue(item.id)}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
)
|
|
232
|
-
: undefined}
|
|
224
|
+
leading={(
|
|
225
|
+
<ThemedIcon
|
|
226
|
+
icon={HightideIconRegistry.Check}
|
|
227
|
+
size={checkIcon.size}
|
|
228
|
+
strokeWidth={checkIcon.strokeWidth}
|
|
229
|
+
color={isSelected ? theme.colors.primary.color : 'transparent'}
|
|
230
|
+
/>
|
|
231
|
+
)}
|
|
233
232
|
/>
|
|
234
233
|
)
|
|
235
234
|
}}
|
|
@@ -5,13 +5,13 @@ import {
|
|
|
5
5
|
type ViewProps
|
|
6
6
|
} from 'react-native'
|
|
7
7
|
|
|
8
|
-
import type { HexColorToken } from '@helpwave/hightide-design/primitive-tokens'
|
|
9
8
|
import type { Appearance } from '@helpwave/hightide-design/semantic-token-resolvers'
|
|
10
9
|
import type { IconSize } from '@helpwave/hightide-design/theme-tokens'
|
|
11
10
|
|
|
12
11
|
import type { IconComponent } from '../../icons/types'
|
|
13
12
|
import { useContentTheme } from '../../global-contexts/content-theme/ContentThemeContext'
|
|
14
13
|
import { useTheme } from '../../global-contexts/theme/ThemeContext'
|
|
14
|
+
import { HexColorUtils } from '../../utils/hex'
|
|
15
15
|
|
|
16
16
|
export type ThemedIconAppearance = Appearance
|
|
17
17
|
|
|
@@ -33,7 +33,7 @@ export const ThemedIcon = ({
|
|
|
33
33
|
...props
|
|
34
34
|
}: ThemedIconProps) => {
|
|
35
35
|
const { theme } = useTheme()
|
|
36
|
-
const { foreground, iconStyle } = useContentTheme()
|
|
36
|
+
const { foreground, background, iconStyle } = useContentTheme()
|
|
37
37
|
const resolvedSize = size ?? iconStyle.size ?? 'md'
|
|
38
38
|
const iconToken = typeof resolvedSize === 'number'
|
|
39
39
|
? {
|
|
@@ -42,13 +42,16 @@ export const ThemedIcon = ({
|
|
|
42
42
|
}
|
|
43
43
|
: theme.components.icon[resolvedSize]
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
const baseColor = (color
|
|
47
|
-
?? iconStyle.color
|
|
48
|
-
?? foreground) as HexColorToken
|
|
45
|
+
const baseColor = HexColorUtils.tryParseColorValue(color ?? iconStyle.color ?? foreground) ?? foreground
|
|
49
46
|
const resolvedColor = appearance === 'normal'
|
|
50
47
|
? baseColor
|
|
51
|
-
: theme.semantics.withAppearance({
|
|
48
|
+
: theme.semantics.withAppearance({
|
|
49
|
+
colorPair: {
|
|
50
|
+
color: background,
|
|
51
|
+
onColor: baseColor,
|
|
52
|
+
},
|
|
53
|
+
appearance,
|
|
54
|
+
})
|
|
52
55
|
|
|
53
56
|
return (
|
|
54
57
|
<View
|
|
@@ -6,8 +6,6 @@ import {
|
|
|
6
6
|
type TextProps
|
|
7
7
|
} from 'react-native'
|
|
8
8
|
|
|
9
|
-
import type { HexColorToken } from '@helpwave/hightide-design/primitive-tokens'
|
|
10
|
-
|
|
11
9
|
import { useContentTheme } from '../../global-contexts/content-theme/ContentThemeContext'
|
|
12
10
|
import { useTheme } from '../../global-contexts/theme/ThemeContext'
|
|
13
11
|
|
|
@@ -23,20 +21,21 @@ export const ThemedText = forwardRef<React.ComponentRef<typeof RNText>, ThemedTe
|
|
|
23
21
|
...props
|
|
24
22
|
}, ref) {
|
|
25
23
|
const { theme } = useTheme()
|
|
26
|
-
const { foreground, textStyle } = useContentTheme()
|
|
27
|
-
// TODO use a hex color parser
|
|
28
|
-
const foregroundColor = (typeof textStyle.color === 'string'
|
|
29
|
-
? textStyle.color
|
|
30
|
-
: foreground) as HexColorToken
|
|
24
|
+
const { foreground, background, textStyle } = useContentTheme()
|
|
31
25
|
const color = appearance === 'description'
|
|
32
|
-
? theme.semantics.asDescription({
|
|
33
|
-
|
|
26
|
+
? theme.semantics.asDescription({
|
|
27
|
+
colorPair: {
|
|
28
|
+
color: background,
|
|
29
|
+
onColor: foreground,
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
: undefined
|
|
34
33
|
|
|
35
34
|
return (
|
|
36
35
|
<RNText
|
|
37
36
|
{...props}
|
|
38
37
|
ref={ref}
|
|
39
|
-
style={[textStyle, { color }, style]}
|
|
38
|
+
style={[textStyle, color === undefined ? undefined : { color }, style]}
|
|
40
39
|
/>
|
|
41
40
|
)
|
|
42
41
|
})
|
|
@@ -2,16 +2,18 @@ import {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useContext
|
|
4
4
|
} from 'react'
|
|
5
|
-
import type {
|
|
5
|
+
import type { TextStyle } from 'react-native'
|
|
6
|
+
|
|
7
|
+
import type { HexColorToken } from '@helpwave/hightide-design/primitive-tokens'
|
|
6
8
|
|
|
7
9
|
import type { IconStyle } from '../../icons'
|
|
8
10
|
|
|
9
|
-
export type ContentThemeContextValue = {
|
|
10
|
-
foreground:
|
|
11
|
-
background:
|
|
12
|
-
textStyle: TextStyle
|
|
13
|
-
iconStyle: IconStyle
|
|
14
|
-
}
|
|
11
|
+
export type ContentThemeContextValue = Readonly<{
|
|
12
|
+
foreground: HexColorToken,
|
|
13
|
+
background: HexColorToken,
|
|
14
|
+
textStyle: Readonly<TextStyle>,
|
|
15
|
+
iconStyle: Readonly<IconStyle>,
|
|
16
|
+
}>
|
|
15
17
|
|
|
16
18
|
export const ContentThemeContext = createContext<ContentThemeContextValue | null>(null)
|
|
17
19
|
|
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
} from 'react'
|
|
5
5
|
import type { ColorValue, TextStyle } from 'react-native'
|
|
6
6
|
|
|
7
|
+
import type { HexColorToken } from '@helpwave/hightide-design/primitive-tokens'
|
|
8
|
+
|
|
7
9
|
import type { IconStyle } from '../../icons'
|
|
10
|
+
import { HexColorUtils } from '../../utils/hex'
|
|
8
11
|
import {
|
|
9
12
|
ContentThemeContext,
|
|
10
13
|
useContentTheme,
|
|
@@ -36,15 +39,15 @@ export const ContentThemeRootProvider = ({
|
|
|
36
39
|
|
|
37
40
|
type ColorOverride =
|
|
38
41
|
| ColorValue
|
|
39
|
-
| ((prev:
|
|
42
|
+
| ((prev: HexColorToken) => ColorValue)
|
|
40
43
|
|
|
41
44
|
type TextStyleOverride =
|
|
42
45
|
| TextStyle
|
|
43
|
-
| ((prev: TextStyle, foreground:
|
|
46
|
+
| ((prev: TextStyle, foreground: HexColorToken) => TextStyle)
|
|
44
47
|
|
|
45
48
|
type IconStyleOverride =
|
|
46
49
|
| IconStyle
|
|
47
|
-
| ((prev: IconStyle, foreground:
|
|
50
|
+
| ((prev: IconStyle, foreground: HexColorToken) => IconStyle)
|
|
48
51
|
|
|
49
52
|
export type ContentThemeOverrideProviderProps = PropsWithChildren & {
|
|
50
53
|
foreground?: ColorOverride,
|
|
@@ -55,10 +58,22 @@ export type ContentThemeOverrideProviderProps = PropsWithChildren & {
|
|
|
55
58
|
isKeepingIconColor?: boolean,
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
const resolveColorOverride = (
|
|
62
|
+
override: ColorOverride | undefined,
|
|
63
|
+
previous: HexColorToken
|
|
64
|
+
): HexColorToken | undefined => {
|
|
65
|
+
if (override === undefined) {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const raw = typeof override === 'function' ? override(previous) : override
|
|
70
|
+
return HexColorUtils.tryParseColorValue(raw)
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
export const ContentThemeOverrideProvider = ({
|
|
59
74
|
children,
|
|
60
75
|
foreground: foregroundOverride,
|
|
61
|
-
background:
|
|
76
|
+
background: backgroundOverride,
|
|
62
77
|
textStyle: textStyleOverride,
|
|
63
78
|
iconStyle: iconStyleOverride,
|
|
64
79
|
isKeepingIconColor = false,
|
|
@@ -67,25 +82,24 @@ export const ContentThemeOverrideProvider = ({
|
|
|
67
82
|
const parent = useContentTheme()
|
|
68
83
|
|
|
69
84
|
const value = useMemo((): ContentThemeContextValue => {
|
|
70
|
-
const newForeground =
|
|
71
|
-
|
|
72
|
-
: foregroundOverride
|
|
85
|
+
const newForeground = resolveColorOverride(foregroundOverride, parent.foreground)
|
|
86
|
+
const newBackground = resolveColorOverride(backgroundOverride, parent.background)
|
|
73
87
|
|
|
74
|
-
|
|
75
|
-
? backroundOverride(parent.background)
|
|
76
|
-
: backroundOverride
|
|
77
|
-
|
|
78
|
-
const textStyle = typeof textStyleOverride === 'function'
|
|
88
|
+
let textStyle = typeof textStyleOverride === 'function'
|
|
79
89
|
? textStyleOverride(parent.textStyle, parent.foreground)
|
|
80
90
|
: textStyleOverride ?? parent.textStyle
|
|
81
91
|
|
|
82
|
-
if(!isKeepingTextColor && newForeground !== undefined)
|
|
92
|
+
if (!isKeepingTextColor && newForeground !== undefined) {
|
|
93
|
+
textStyle = { ...textStyle, color: newForeground }
|
|
94
|
+
}
|
|
83
95
|
|
|
84
|
-
|
|
96
|
+
let iconStyle = typeof iconStyleOverride === 'function'
|
|
85
97
|
? iconStyleOverride(parent.iconStyle, parent.foreground)
|
|
86
98
|
: iconStyleOverride ?? parent.iconStyle
|
|
87
99
|
|
|
88
|
-
if(!isKeepingIconColor && newForeground !== undefined)
|
|
100
|
+
if (!isKeepingIconColor && newForeground !== undefined) {
|
|
101
|
+
iconStyle = { ...iconStyle, color: newForeground }
|
|
102
|
+
}
|
|
89
103
|
|
|
90
104
|
return {
|
|
91
105
|
foreground: newForeground ?? parent.foreground,
|
|
@@ -93,7 +107,7 @@ export const ContentThemeOverrideProvider = ({
|
|
|
93
107
|
textStyle,
|
|
94
108
|
iconStyle,
|
|
95
109
|
}
|
|
96
|
-
}, [parent, foregroundOverride, textStyleOverride, iconStyleOverride,
|
|
110
|
+
}, [parent, foregroundOverride, textStyleOverride, iconStyleOverride, backgroundOverride, isKeepingTextColor, isKeepingIconColor])
|
|
97
111
|
|
|
98
112
|
return (
|
|
99
113
|
<ContentThemeContext.Provider value={value}>
|
|
@@ -95,6 +95,7 @@ export const ThemeProvider = ({
|
|
|
95
95
|
<ThemeContext.Provider value={contextValue}>
|
|
96
96
|
<ContentThemeRootProvider
|
|
97
97
|
foreground={contextValue.theme.colors.surface.onColor}
|
|
98
|
+
background={contextValue.theme.colors.surface.color}
|
|
98
99
|
textStyle={contextValue.theme.typography.body.md}
|
|
99
100
|
iconStyle={{
|
|
100
101
|
size: contextValue.theme.icongraphy.sizes.md,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from '../types/resolver'
|
|
18
18
|
|
|
19
19
|
import { StyleAdapterUtils } from '../adapters'
|
|
20
|
-
import { HexColorUtils } from '
|
|
20
|
+
import { HexColorUtils } from '../../utils/hex'
|
|
21
21
|
|
|
22
22
|
const toDesignInputState = (state: InputState = {}): DesignInputState => {
|
|
23
23
|
const active = new Set<InputStateValue>()
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from '../types/resolver'
|
|
19
19
|
|
|
20
20
|
import { StyleAdapterUtils } from '../adapters'
|
|
21
|
-
import { HexColorUtils } from '
|
|
21
|
+
import { HexColorUtils } from '../../utils/hex'
|
|
22
22
|
|
|
23
23
|
const toDesignSearchBarState = (state: SearchBarState = {}): DesignInputState => {
|
|
24
24
|
const active = new Set<InputStateValue>()
|
|
@@ -61,13 +61,13 @@ export type HightideThemeSemantics = {
|
|
|
61
61
|
tintStrength?: TintStrength,
|
|
62
62
|
}, HexColorToken>,
|
|
63
63
|
withAppearance: BoundSemanticResolver<{
|
|
64
|
-
|
|
64
|
+
colorPair: ColorPairToken,
|
|
65
65
|
appearance: Appearance,
|
|
66
66
|
}, HexColorToken>,
|
|
67
67
|
asFaded: BoundSemanticResolver<{
|
|
68
|
-
|
|
68
|
+
colorPair: ColorPairToken,
|
|
69
69
|
}, HexColorToken>,
|
|
70
70
|
asDescription: BoundSemanticResolver<{
|
|
71
|
-
|
|
71
|
+
colorPair: ColorPairToken,
|
|
72
72
|
}, HexColorToken>,
|
|
73
73
|
} & ElementLayoutTokens
|
package/src/utils/hex.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { processColor, type ColorValue } from 'react-native'
|
|
2
|
+
|
|
3
|
+
import type { HexColorToken } from '@helpwave/hightide-design/primitive-tokens'
|
|
4
|
+
import { HexColorUtils as DesignHexColorUtils } from '@helpwave/hightide-design/utils'
|
|
5
|
+
|
|
6
|
+
const clampByte = (value: number): number => Math.round(Math.min(255, Math.max(0, value)))
|
|
7
|
+
|
|
8
|
+
const channelHex = (value: number): string => clampByte(value).toString(16).padStart(2, '0')
|
|
9
|
+
|
|
10
|
+
const toHex = (r: number, g: number, b: number, alpha: number = 1): HexColorToken => {
|
|
11
|
+
if (alpha >= 1) {
|
|
12
|
+
return `#${channelHex(r)}${channelHex(g)}${channelHex(b)}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return `#${channelHex(r)}${channelHex(g)}${channelHex(b)}${channelHex(alpha * 255)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const argbNumberToHex = (argb: number): HexColorToken => {
|
|
19
|
+
const unsigned = argb >>> 0
|
|
20
|
+
const alpha = ((unsigned >>> 24) & 0xff) / 255
|
|
21
|
+
const r = (unsigned >>> 16) & 0xff
|
|
22
|
+
const g = (unsigned >>> 8) & 0xff
|
|
23
|
+
const b = unsigned & 0xff
|
|
24
|
+
|
|
25
|
+
return toHex(r, g, b, alpha)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const parseHexString = (value: string): HexColorToken | undefined => {
|
|
29
|
+
const normalized = value.startsWith('#') ? value.slice(1) : value
|
|
30
|
+
if (!/^[0-9a-fA-F]+$/.test(normalized)) {
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (normalized.length === 3 || normalized.length === 4) {
|
|
35
|
+
const expanded = normalized.split('').map((char) => `${char}${char}`).join('')
|
|
36
|
+
return parseHexString(expanded)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (normalized.length !== 6 && normalized.length !== 8) {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const r = Number.parseInt(normalized.slice(0, 2), 16)
|
|
44
|
+
const g = Number.parseInt(normalized.slice(2, 4), 16)
|
|
45
|
+
const b = Number.parseInt(normalized.slice(4, 6), 16)
|
|
46
|
+
const alpha = normalized.length === 8
|
|
47
|
+
? Number.parseInt(normalized.slice(6, 8), 16) / 255
|
|
48
|
+
: 1
|
|
49
|
+
|
|
50
|
+
return toHex(r, g, b, alpha)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parseNumericChannel = (raw: string, percentScale: number): number => {
|
|
54
|
+
const trimmed = raw.trim()
|
|
55
|
+
if (trimmed.endsWith('%')) {
|
|
56
|
+
return (Number.parseFloat(trimmed.slice(0, -1)) / 100) * percentScale
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Number.parseFloat(trimmed)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parseRgbString = (value: string): HexColorToken | undefined => {
|
|
63
|
+
const match = /^rgba?\(\s*([^\s,/]+)[\s,]+([^\s,/]+)[\s,]+([^\s,/]+)(?:\s*[,/]\s*([^\s,/]+))?\s*\)$/i.exec(value)
|
|
64
|
+
if (!match) {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const r = parseNumericChannel(match[1], 255)
|
|
69
|
+
const g = parseNumericChannel(match[2], 255)
|
|
70
|
+
const b = parseNumericChannel(match[3], 255)
|
|
71
|
+
const alpha = match[4] === undefined ? 1 : parseNumericChannel(match[4], 1)
|
|
72
|
+
|
|
73
|
+
if ([r, g, b, alpha].some((channel) => Number.isNaN(channel))) {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return toHex(r, g, b, alpha)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hueToRgb = (p: number, q: number, t: number): number => {
|
|
81
|
+
let wrapped = t
|
|
82
|
+
if (wrapped < 0) {
|
|
83
|
+
wrapped += 1
|
|
84
|
+
}
|
|
85
|
+
if (wrapped > 1) {
|
|
86
|
+
wrapped -= 1
|
|
87
|
+
}
|
|
88
|
+
if (wrapped < 1 / 6) {
|
|
89
|
+
return p + (q - p) * 6 * wrapped
|
|
90
|
+
}
|
|
91
|
+
if (wrapped < 1 / 2) {
|
|
92
|
+
return q
|
|
93
|
+
}
|
|
94
|
+
if (wrapped < 2 / 3) {
|
|
95
|
+
return p + (q - p) * (2 / 3 - wrapped) * 6
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return p
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hslToRgb = (hue: number, saturation: number, lightness: number): [number, number, number] => {
|
|
102
|
+
if (saturation === 0) {
|
|
103
|
+
const gray = lightness * 255
|
|
104
|
+
return [gray, gray, gray]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const q = lightness < 0.5
|
|
108
|
+
? lightness * (1 + saturation)
|
|
109
|
+
: lightness + saturation - lightness * saturation
|
|
110
|
+
const p = 2 * lightness - q
|
|
111
|
+
const h = (((hue % 360) + 360) % 360) / 360
|
|
112
|
+
|
|
113
|
+
return [
|
|
114
|
+
hueToRgb(p, q, h + 1 / 3) * 255,
|
|
115
|
+
hueToRgb(p, q, h) * 255,
|
|
116
|
+
hueToRgb(p, q, h - 1 / 3) * 255,
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const parseHslString = (value: string): HexColorToken | undefined => {
|
|
121
|
+
const match = /^hsla?\(\s*([^\s,/]+)(?:deg)?[\s,]+([^\s,/]+)[\s,]+([^\s,/]+)(?:\s*[,/]\s*([^\s,/]+))?\s*\)$/i.exec(value)
|
|
122
|
+
if (!match) {
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const hue = Number.parseFloat(match[1])
|
|
127
|
+
const saturation = parseNumericChannel(match[2], 1)
|
|
128
|
+
const lightness = parseNumericChannel(match[3], 1)
|
|
129
|
+
const alpha = match[4] === undefined ? 1 : parseNumericChannel(match[4], 1)
|
|
130
|
+
|
|
131
|
+
if ([hue, saturation, lightness, alpha].some((channel) => Number.isNaN(channel))) {
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const [r, g, b] = hslToRgb(hue, saturation, lightness)
|
|
136
|
+
return toHex(r, g, b, alpha)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const parseColorLiteral = (color: ColorValue | number): HexColorToken | undefined => {
|
|
140
|
+
if (typeof color === 'number') {
|
|
141
|
+
return argbNumberToHex(color)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof color !== 'string') {
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const trimmed = color.trim()
|
|
149
|
+
if (trimmed.length === 0) {
|
|
150
|
+
return undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (trimmed.toLowerCase() === 'transparent') {
|
|
154
|
+
return DesignHexColorUtils.resolveColorToken('transparent')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return parseHexString(trimmed)
|
|
158
|
+
?? parseRgbString(trimmed)
|
|
159
|
+
?? parseHslString(trimmed)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const tryParseColorValue = (color: ColorValue | number): HexColorToken | undefined => {
|
|
163
|
+
const fromLiteral = parseColorLiteral(color)
|
|
164
|
+
if (fromLiteral !== undefined) {
|
|
165
|
+
return fromLiteral
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const processed = processColor(color)
|
|
169
|
+
if (typeof processed === 'number') {
|
|
170
|
+
return argbNumberToHex(processed)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (typeof processed === 'string' && processed !== color) {
|
|
174
|
+
return parseColorLiteral(processed)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return undefined
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const parseColorValue = (color: ColorValue | number): HexColorToken => {
|
|
181
|
+
const parsed = tryParseColorValue(color)
|
|
182
|
+
if (parsed === undefined) {
|
|
183
|
+
throw new Error(`Unable to parse ColorValue to hex: ${String(color)}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return parsed
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const HexColorUtils = {
|
|
190
|
+
...DesignHexColorUtils,
|
|
191
|
+
tryParseColorValue,
|
|
192
|
+
parseColorValue,
|
|
193
|
+
}
|
package/src/utils/index.ts
CHANGED