@newtonedev/components 0.1.0 → 0.1.2
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/dist/AppShell/AppShell.d.ts +4 -0
- package/dist/AppShell/AppShell.d.ts.map +1 -0
- package/dist/AppShell/AppShell.styles.d.ts +16 -0
- package/dist/AppShell/AppShell.styles.d.ts.map +1 -0
- package/dist/AppShell/AppShell.types.d.ts +8 -0
- package/dist/AppShell/AppShell.types.d.ts.map +1 -0
- package/dist/AppShell/index.d.ts +3 -0
- package/dist/AppShell/index.d.ts.map +1 -0
- package/dist/Button/Button.d.ts +9 -4
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.styles.d.ts +33 -26
- package/dist/Button/Button.styles.d.ts.map +1 -1
- package/dist/Button/Button.types.d.ts +17 -2
- package/dist/Button/Button.types.d.ts.map +1 -1
- package/dist/ColorScaleSlider/ColorScaleSlider.d.ts +13 -0
- package/dist/ColorScaleSlider/ColorScaleSlider.d.ts.map +1 -0
- package/dist/ColorScaleSlider/ColorScaleSlider.styles.d.ts +54 -0
- package/dist/ColorScaleSlider/ColorScaleSlider.styles.d.ts.map +1 -0
- package/dist/ColorScaleSlider/ColorScaleSlider.types.d.ts +25 -0
- package/dist/ColorScaleSlider/ColorScaleSlider.types.d.ts.map +1 -0
- package/dist/ColorScaleSlider/index.d.ts +3 -0
- package/dist/ColorScaleSlider/index.d.ts.map +1 -0
- package/dist/Frame/Frame.d.ts +48 -0
- package/dist/Frame/Frame.d.ts.map +1 -0
- package/dist/Frame/Frame.styles.d.ts +39 -0
- package/dist/Frame/Frame.styles.d.ts.map +1 -0
- package/dist/Frame/Frame.types.d.ts +115 -0
- package/dist/Frame/Frame.types.d.ts.map +1 -0
- package/dist/Frame/Frame.utils.d.ts +39 -0
- package/dist/Frame/Frame.utils.d.ts.map +1 -0
- package/dist/Frame/index.d.ts +4 -0
- package/dist/Frame/index.d.ts.map +1 -0
- package/dist/HueSlider/HueSlider.d.ts +1 -1
- package/dist/HueSlider/HueSlider.d.ts.map +1 -1
- package/dist/HueSlider/HueSlider.styles.d.ts +47 -5
- package/dist/HueSlider/HueSlider.styles.d.ts.map +1 -1
- package/dist/HueSlider/HueSlider.types.d.ts +1 -0
- package/dist/HueSlider/HueSlider.types.d.ts.map +1 -1
- package/dist/Icon/Icon.d.ts +36 -0
- package/dist/Icon/Icon.d.ts.map +1 -0
- package/dist/Navbar/Navbar.d.ts +4 -0
- package/dist/Navbar/Navbar.d.ts.map +1 -0
- package/dist/Navbar/Navbar.styles.d.ts +31 -0
- package/dist/Navbar/Navbar.styles.d.ts.map +1 -0
- package/dist/Navbar/Navbar.types.d.ts +14 -0
- package/dist/Navbar/Navbar.types.d.ts.map +1 -0
- package/dist/Navbar/index.d.ts +3 -0
- package/dist/Navbar/index.d.ts.map +1 -0
- package/dist/Popover/Popover.d.ts +4 -0
- package/dist/Popover/Popover.d.ts.map +1 -0
- package/dist/Popover/Popover.styles.d.ts +9 -0
- package/dist/Popover/Popover.styles.d.ts.map +1 -0
- package/dist/Popover/Popover.types.d.ts +37 -0
- package/dist/Popover/Popover.types.d.ts.map +1 -0
- package/dist/Popover/index.d.ts +4 -0
- package/dist/Popover/index.d.ts.map +1 -0
- package/dist/Popover/usePopover.d.ts +3 -0
- package/dist/Popover/usePopover.d.ts.map +1 -0
- package/dist/Select/Select.d.ts +1 -8
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.styles.d.ts +32 -5
- package/dist/Select/Select.styles.d.ts.map +1 -1
- package/dist/Select/Select.types.d.ts +25 -1
- package/dist/Select/Select.types.d.ts.map +1 -1
- package/dist/Select/SelectOption.d.ts +13 -0
- package/dist/Select/SelectOption.d.ts.map +1 -0
- package/dist/Select/useSelect.d.ts +15 -0
- package/dist/Select/useSelect.d.ts.map +1 -0
- package/dist/Sidebar/Sidebar.d.ts +4 -0
- package/dist/Sidebar/Sidebar.d.ts.map +1 -0
- package/dist/Sidebar/Sidebar.styles.d.ts +31 -0
- package/dist/Sidebar/Sidebar.styles.d.ts.map +1 -0
- package/dist/Sidebar/Sidebar.types.d.ts +14 -0
- package/dist/Sidebar/Sidebar.types.d.ts.map +1 -0
- package/dist/Sidebar/index.d.ts +3 -0
- package/dist/Sidebar/index.d.ts.map +1 -0
- package/dist/Slider/Slider.d.ts +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.styles.d.ts +48 -8
- package/dist/Slider/Slider.styles.d.ts.map +1 -1
- package/dist/Slider/Slider.types.d.ts +1 -0
- package/dist/Slider/Slider.types.d.ts.map +1 -1
- package/dist/TextInput/TextInput.styles.d.ts +3 -1
- package/dist/TextInput/TextInput.styles.d.ts.map +1 -1
- package/dist/Toggle/Toggle.styles.d.ts +2 -1
- package/dist/Toggle/Toggle.styles.d.ts.map +1 -1
- package/dist/fonts/GoogleFontLoader.d.ts +19 -0
- package/dist/fonts/GoogleFontLoader.d.ts.map +1 -0
- package/dist/fonts/IconFontLoader.d.ts +13 -0
- package/dist/fonts/IconFontLoader.d.ts.map +1 -0
- package/dist/fonts/buildGoogleFontsUrl.d.ts +17 -0
- package/dist/fonts/buildGoogleFontsUrl.d.ts.map +1 -0
- package/dist/fonts/googleFonts.d.ts +20 -0
- package/dist/fonts/googleFonts.d.ts.map +1 -0
- package/dist/index.cjs +2303 -205
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +27 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2279 -200
- package/dist/index.js.map +1 -1
- package/dist/registry/codegen.d.ts +11 -0
- package/dist/registry/codegen.d.ts.map +1 -0
- package/dist/registry/index.d.ts +4 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/registry.d.ts +7 -0
- package/dist/registry/registry.d.ts.map +1 -0
- package/dist/registry/types.d.ts +32 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/theme/FrameContext.d.ts +24 -0
- package/dist/theme/FrameContext.d.ts.map +1 -0
- package/dist/theme/NewtoneProvider.d.ts.map +1 -1
- package/dist/theme/defaults.d.ts.map +1 -1
- package/dist/theme/types.d.ts +64 -1
- package/dist/theme/types.d.ts.map +1 -1
- package/dist/tokens/computeTokens.d.ts +55 -3
- package/dist/tokens/computeTokens.d.ts.map +1 -1
- package/dist/tokens/types.d.ts +52 -0
- package/dist/tokens/types.d.ts.map +1 -1
- package/dist/tokens/useTokens.d.ts +12 -9
- package/dist/tokens/useTokens.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/AppShell/AppShell.styles.ts +20 -0
- package/src/AppShell/AppShell.tsx +17 -0
- package/src/AppShell/AppShell.types.ts +8 -0
- package/src/AppShell/index.ts +2 -0
- package/src/Button/Button.styles.ts +74 -41
- package/src/Button/Button.tsx +36 -17
- package/src/Button/Button.types.ts +20 -2
- package/src/Card/Card.styles.ts +2 -2
- package/src/ColorScaleSlider/ColorScaleSlider.styles.ts +60 -0
- package/src/ColorScaleSlider/ColorScaleSlider.tsx +156 -0
- package/src/ColorScaleSlider/ColorScaleSlider.types.ts +25 -0
- package/src/ColorScaleSlider/index.ts +2 -0
- package/src/Frame/Frame.styles.ts +213 -0
- package/src/Frame/Frame.tsx +242 -0
- package/src/Frame/Frame.types.ts +181 -0
- package/src/Frame/Frame.utils.ts +189 -0
- package/src/Frame/index.ts +21 -0
- package/src/HueSlider/HueSlider.styles.ts +58 -39
- package/src/HueSlider/HueSlider.tsx +97 -25
- package/src/HueSlider/HueSlider.types.ts +1 -0
- package/src/Icon/Icon.tsx +76 -0
- package/src/Navbar/Navbar.styles.ts +37 -0
- package/src/Navbar/Navbar.tsx +32 -0
- package/src/Navbar/Navbar.types.ts +14 -0
- package/src/Navbar/index.ts +2 -0
- package/src/Popover/Popover.styles.ts +39 -0
- package/src/Popover/Popover.tsx +103 -0
- package/src/Popover/Popover.types.ts +40 -0
- package/src/Popover/index.ts +3 -0
- package/src/Popover/usePopover.ts +26 -0
- package/src/Select/Select.styles.ts +49 -10
- package/src/Select/Select.tsx +127 -36
- package/src/Select/Select.types.ts +30 -1
- package/src/Select/SelectOption.tsx +104 -0
- package/src/Select/useSelect.ts +129 -0
- package/src/Sidebar/Sidebar.styles.ts +37 -0
- package/src/Sidebar/Sidebar.tsx +27 -0
- package/src/Sidebar/Sidebar.types.ts +14 -0
- package/src/Sidebar/index.ts +2 -0
- package/src/Slider/Slider.styles.ts +53 -25
- package/src/Slider/Slider.tsx +89 -24
- package/src/Slider/Slider.types.ts +1 -0
- package/src/TextInput/TextInput.styles.ts +9 -7
- package/src/Toggle/Toggle.styles.ts +4 -3
- package/src/fonts/GoogleFontLoader.tsx +63 -0
- package/src/fonts/IconFontLoader.tsx +49 -0
- package/src/fonts/buildGoogleFontsUrl.ts +31 -0
- package/src/fonts/googleFonts.ts +87 -0
- package/src/index.ts +70 -2
- package/src/registry/codegen.ts +132 -0
- package/src/registry/index.ts +17 -0
- package/src/registry/registry.ts +402 -0
- package/src/registry/types.ts +35 -0
- package/src/theme/FrameContext.tsx +29 -0
- package/src/theme/NewtoneProvider.tsx +9 -1
- package/src/theme/defaults.ts +51 -0
- package/src/theme/types.ts +66 -1
- package/src/tokens/computeTokens.ts +103 -46
- package/src/tokens/types.ts +52 -0
- package/src/tokens/useTokens.ts +30 -15
|
@@ -2,28 +2,67 @@ import { StyleSheet } from 'react-native';
|
|
|
2
2
|
import { srgbToHex } from 'newtone';
|
|
3
3
|
import type { ResolvedTokens } from '../tokens/types';
|
|
4
4
|
|
|
5
|
-
export function getSelectStyles(
|
|
5
|
+
export function getSelectStyles(
|
|
6
|
+
tokens: ResolvedTokens,
|
|
7
|
+
disabled: boolean,
|
|
8
|
+
size: 'sm' | 'md',
|
|
9
|
+
isOpen: boolean
|
|
10
|
+
) {
|
|
11
|
+
const isSm = size === 'sm';
|
|
12
|
+
const fontSize = isSm ? tokens.typography.size.sm : tokens.typography.size.base;
|
|
13
|
+
const iconSize = fontSize + 2;
|
|
14
|
+
const iconSpace = iconSize + tokens.spacing.sm;
|
|
15
|
+
const paddingVertical = isSm ? tokens.spacing.xs : tokens.spacing.sm;
|
|
16
|
+
const paddingHorizontal = isSm ? tokens.spacing.sm : tokens.spacing.md;
|
|
17
|
+
|
|
6
18
|
return StyleSheet.create({
|
|
7
19
|
container: {
|
|
8
|
-
gap:
|
|
20
|
+
gap: tokens.spacing.xs,
|
|
21
|
+
zIndex: isOpen ? 999 : 0,
|
|
9
22
|
},
|
|
10
23
|
label: {
|
|
11
|
-
|
|
12
|
-
|
|
24
|
+
fontFamily: tokens.typography.fonts.default,
|
|
25
|
+
fontSize: tokens.typography.size.sm,
|
|
26
|
+
fontWeight: tokens.typography.weight.semibold as any,
|
|
13
27
|
color: srgbToHex(tokens.textSecondary.srgb),
|
|
14
28
|
},
|
|
15
|
-
|
|
29
|
+
trigger: {
|
|
30
|
+
flexDirection: 'row',
|
|
31
|
+
alignItems: 'center',
|
|
16
32
|
backgroundColor: srgbToHex(tokens.backgroundSunken.srgb),
|
|
17
33
|
borderWidth: 1,
|
|
18
34
|
borderColor: srgbToHex(tokens.border.srgb),
|
|
19
|
-
borderRadius:
|
|
20
|
-
paddingVertical
|
|
21
|
-
|
|
22
|
-
|
|
35
|
+
borderRadius: tokens.radius.md,
|
|
36
|
+
paddingVertical,
|
|
37
|
+
paddingLeft: paddingHorizontal,
|
|
38
|
+
paddingRight: iconSpace + (isSm ? tokens.spacing.xs : tokens.spacing.sm),
|
|
39
|
+
opacity: disabled ? 0.5 : 1,
|
|
40
|
+
},
|
|
41
|
+
triggerText: {
|
|
42
|
+
flex: 1,
|
|
43
|
+
fontFamily: tokens.typography.fonts.default,
|
|
44
|
+
fontSize,
|
|
23
45
|
color: disabled
|
|
24
46
|
? srgbToHex(tokens.textSecondary.srgb)
|
|
25
47
|
: srgbToHex(tokens.textPrimary.srgb),
|
|
26
|
-
|
|
48
|
+
},
|
|
49
|
+
iconWrapper: {
|
|
50
|
+
position: 'absolute',
|
|
51
|
+
right: isSm ? tokens.spacing.xs : tokens.spacing.sm,
|
|
52
|
+
top: 0,
|
|
53
|
+
bottom: 0,
|
|
54
|
+
justifyContent: 'center',
|
|
55
|
+
},
|
|
56
|
+
groupLabel: {
|
|
57
|
+
fontFamily: tokens.typography.fonts.default,
|
|
58
|
+
fontSize: tokens.typography.size.xs,
|
|
59
|
+
fontWeight: tokens.typography.weight.semibold as any,
|
|
60
|
+
color: srgbToHex(tokens.textSecondary.srgb),
|
|
61
|
+
textTransform: 'uppercase',
|
|
62
|
+
letterSpacing: 0.5,
|
|
63
|
+
paddingVertical: tokens.spacing.xs,
|
|
64
|
+
paddingHorizontal: isSm ? tokens.spacing.sm : tokens.spacing.md,
|
|
65
|
+
paddingTop: tokens.spacing.sm,
|
|
27
66
|
},
|
|
28
67
|
});
|
|
29
68
|
}
|
package/src/Select/Select.tsx
CHANGED
|
@@ -1,60 +1,151 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { View, Text,
|
|
3
|
-
import type { SelectProps } from './Select.types';
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { View, Text, Pressable, ScrollView } from 'react-native';
|
|
3
|
+
import type { SelectProps, SelectOption } from './Select.types';
|
|
4
|
+
import { isOptionGroup } from './Select.types';
|
|
4
5
|
import { useTokens } from '../tokens/useTokens';
|
|
5
6
|
import { getSelectStyles } from './Select.styles';
|
|
7
|
+
import { Icon } from '../Icon/Icon';
|
|
8
|
+
import { Popover } from '../Popover/Popover';
|
|
9
|
+
import { usePopover } from '../Popover/usePopover';
|
|
10
|
+
import { useSelect } from './useSelect';
|
|
11
|
+
import { SelectOptionRow } from './SelectOption';
|
|
12
|
+
import { srgbToHex } from 'newtone';
|
|
13
|
+
|
|
14
|
+
function flattenOptions(items: readonly (SelectOption | { readonly label: string; readonly options: readonly SelectOption[] })[]): SelectOption[] {
|
|
15
|
+
const result: SelectOption[] = [];
|
|
16
|
+
for (const item of items) {
|
|
17
|
+
if ('options' in item) {
|
|
18
|
+
result.push(...item.options);
|
|
19
|
+
} else {
|
|
20
|
+
result.push(item);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
6
25
|
|
|
7
|
-
/**
|
|
8
|
-
* Select dropdown component.
|
|
9
|
-
*
|
|
10
|
-
* Renders a native HTML <select> element styled with design tokens.
|
|
11
|
-
* On web (via react-native-web), View renders as a div which can contain
|
|
12
|
-
* standard HTML elements.
|
|
13
|
-
*/
|
|
14
26
|
export function Select({
|
|
15
27
|
options,
|
|
16
28
|
value,
|
|
17
29
|
onValueChange,
|
|
18
30
|
label,
|
|
31
|
+
placeholder,
|
|
19
32
|
disabled = false,
|
|
33
|
+
renderOption,
|
|
34
|
+
renderValue,
|
|
35
|
+
size = 'md',
|
|
20
36
|
style,
|
|
21
37
|
}: SelectProps) {
|
|
22
38
|
const tokens = useTokens(1);
|
|
39
|
+
const { isOpen, open, close, toggle } = usePopover();
|
|
23
40
|
|
|
24
|
-
const
|
|
25
|
-
() => getSelectStyles(tokens, disabled),
|
|
26
|
-
[tokens, disabled]
|
|
27
|
-
);
|
|
41
|
+
const flatOptions = useMemo(() => flattenOptions(options), [options]);
|
|
28
42
|
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
const { focusedIndex, handleKeyDown } = useSelect({
|
|
44
|
+
flatOptions,
|
|
45
|
+
value,
|
|
46
|
+
isOpen,
|
|
47
|
+
onSelect: (v) => {
|
|
48
|
+
onValueChange(v);
|
|
49
|
+
close();
|
|
32
50
|
},
|
|
33
|
-
|
|
51
|
+
onClose: close,
|
|
52
|
+
onOpen: open,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const styles = useMemo(
|
|
56
|
+
() => getSelectStyles(tokens, disabled, size, isOpen),
|
|
57
|
+
[tokens, disabled, size, isOpen]
|
|
34
58
|
);
|
|
35
59
|
|
|
36
|
-
|
|
37
|
-
const
|
|
60
|
+
const selectedOption = flatOptions.find((o) => o.value === value);
|
|
61
|
+
const displayLabel = selectedOption?.label ?? placeholder ?? value;
|
|
62
|
+
|
|
63
|
+
const iconColor = disabled
|
|
64
|
+
? srgbToHex(tokens.textSecondary.srgb)
|
|
65
|
+
: srgbToHex(tokens.textPrimary.srgb);
|
|
66
|
+
|
|
67
|
+
// onKeyDown is a web-only prop supported by react-native-web but not in RN types
|
|
68
|
+
const triggerWebProps = { onKeyDown: handleKeyDown } as any;
|
|
69
|
+
|
|
70
|
+
const trigger = (
|
|
71
|
+
<Pressable
|
|
72
|
+
onPress={disabled ? undefined : toggle}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
role="combobox"
|
|
75
|
+
aria-expanded={isOpen}
|
|
76
|
+
aria-haspopup={'listbox' as any}
|
|
77
|
+
{...triggerWebProps}
|
|
78
|
+
style={styles.trigger}
|
|
79
|
+
>
|
|
80
|
+
{renderValue ? (
|
|
81
|
+
renderValue(selectedOption)
|
|
82
|
+
) : (
|
|
83
|
+
<Text style={styles.triggerText} numberOfLines={1}>
|
|
84
|
+
{displayLabel}
|
|
85
|
+
</Text>
|
|
86
|
+
)}
|
|
87
|
+
<View style={styles.iconWrapper} pointerEvents="none">
|
|
88
|
+
<Icon
|
|
89
|
+
name={isOpen ? 'expand_less' : 'expand_more'}
|
|
90
|
+
size={size === 'sm' ? tokens.typography.size.sm + 2 : tokens.typography.size.base + 2}
|
|
91
|
+
color={iconColor}
|
|
92
|
+
/>
|
|
93
|
+
</View>
|
|
94
|
+
</Pressable>
|
|
95
|
+
);
|
|
38
96
|
|
|
39
97
|
return (
|
|
40
|
-
<View style={[styles.container, ...(Array.isArray(style) ? style : [style])]}>
|
|
98
|
+
<View style={[styles.container, ...(Array.isArray(style) ? style : style ? [style] : [])]}>
|
|
41
99
|
{label && <Text style={styles.label}>{label}</Text>}
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
style={{
|
|
47
|
-
...selectStyle,
|
|
48
|
-
cursor: disabled ? 'default' : 'pointer',
|
|
49
|
-
appearance: 'auto',
|
|
50
|
-
} as React.CSSProperties}
|
|
100
|
+
<Popover
|
|
101
|
+
isOpen={isOpen && !disabled}
|
|
102
|
+
onClose={close}
|
|
103
|
+
trigger={trigger}
|
|
51
104
|
>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
105
|
+
<ScrollView
|
|
106
|
+
bounces={false}
|
|
107
|
+
keyboardShouldPersistTaps="handled"
|
|
108
|
+
role={'listbox' as any}
|
|
109
|
+
>
|
|
110
|
+
{options.map((item) => {
|
|
111
|
+
if (isOptionGroup(item)) {
|
|
112
|
+
return (
|
|
113
|
+
<View key={item.label}>
|
|
114
|
+
<Text style={styles.groupLabel}>{item.label}</Text>
|
|
115
|
+
{item.options.map((opt) => (
|
|
116
|
+
<SelectOptionRow
|
|
117
|
+
key={opt.value}
|
|
118
|
+
option={opt}
|
|
119
|
+
isSelected={opt.value === value}
|
|
120
|
+
isFocused={flatOptions[focusedIndex]?.value === opt.value}
|
|
121
|
+
onSelect={() => {
|
|
122
|
+
onValueChange(opt.value);
|
|
123
|
+
close();
|
|
124
|
+
}}
|
|
125
|
+
renderOption={renderOption}
|
|
126
|
+
size={size}
|
|
127
|
+
/>
|
|
128
|
+
))}
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
<SelectOptionRow
|
|
134
|
+
key={item.value}
|
|
135
|
+
option={item}
|
|
136
|
+
isSelected={item.value === value}
|
|
137
|
+
isFocused={flatOptions[focusedIndex]?.value === item.value}
|
|
138
|
+
onSelect={() => {
|
|
139
|
+
onValueChange(item.value);
|
|
140
|
+
close();
|
|
141
|
+
}}
|
|
142
|
+
renderOption={renderOption}
|
|
143
|
+
size={size}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</ScrollView>
|
|
148
|
+
</Popover>
|
|
58
149
|
</View>
|
|
59
150
|
);
|
|
60
151
|
}
|
|
@@ -3,13 +3,42 @@ import type { ViewStyle } from 'react-native';
|
|
|
3
3
|
export interface SelectOption {
|
|
4
4
|
readonly label: string;
|
|
5
5
|
readonly value: string;
|
|
6
|
+
readonly disabled?: boolean;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
export interface
|
|
9
|
+
export interface SelectOptionGroup {
|
|
10
|
+
readonly label: string;
|
|
9
11
|
readonly options: readonly SelectOption[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SelectItem = SelectOption | SelectOptionGroup;
|
|
15
|
+
|
|
16
|
+
export function isOptionGroup(item: SelectItem): item is SelectOptionGroup {
|
|
17
|
+
return 'options' in item;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SelectProps {
|
|
21
|
+
/** Flat options or grouped options */
|
|
22
|
+
readonly options: readonly SelectItem[];
|
|
23
|
+
/** Currently selected value */
|
|
10
24
|
readonly value: string;
|
|
25
|
+
/** Callback when selection changes */
|
|
11
26
|
readonly onValueChange: (value: string) => void;
|
|
27
|
+
/** Field label displayed above the trigger */
|
|
12
28
|
readonly label?: string;
|
|
29
|
+
/** Placeholder text when no value is selected */
|
|
30
|
+
readonly placeholder?: string;
|
|
31
|
+
/** Disabled state */
|
|
13
32
|
readonly disabled?: boolean;
|
|
33
|
+
/** Custom render for each option row */
|
|
34
|
+
readonly renderOption?: (
|
|
35
|
+
option: SelectOption,
|
|
36
|
+
state: { readonly isSelected: boolean; readonly isFocused: boolean }
|
|
37
|
+
) => React.ReactNode;
|
|
38
|
+
/** Custom render for the trigger display text */
|
|
39
|
+
readonly renderValue?: (option: SelectOption | undefined) => React.ReactNode;
|
|
40
|
+
/** Size variant. @default 'md' */
|
|
41
|
+
readonly size?: 'sm' | 'md';
|
|
42
|
+
/** Container style override */
|
|
14
43
|
readonly style?: ViewStyle | ViewStyle[];
|
|
15
44
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import type { SelectOption as SelectOptionType, SelectProps } from './Select.types';
|
|
4
|
+
import { useTokens } from '../tokens/useTokens';
|
|
5
|
+
import { Icon } from '../Icon/Icon';
|
|
6
|
+
import { srgbToHex } from 'newtone';
|
|
7
|
+
|
|
8
|
+
interface SelectOptionRowProps {
|
|
9
|
+
readonly option: SelectOptionType;
|
|
10
|
+
readonly isSelected: boolean;
|
|
11
|
+
readonly isFocused: boolean;
|
|
12
|
+
readonly onSelect: () => void;
|
|
13
|
+
readonly renderOption?: SelectProps['renderOption'];
|
|
14
|
+
readonly size: 'sm' | 'md';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SelectOptionRow({
|
|
18
|
+
option,
|
|
19
|
+
isSelected,
|
|
20
|
+
isFocused,
|
|
21
|
+
onSelect,
|
|
22
|
+
renderOption,
|
|
23
|
+
size,
|
|
24
|
+
}: SelectOptionRowProps) {
|
|
25
|
+
const tokens = useTokens(1);
|
|
26
|
+
|
|
27
|
+
const paddingVertical = size === 'sm' ? tokens.spacing.xs : tokens.spacing.sm;
|
|
28
|
+
const paddingHorizontal = size === 'sm' ? tokens.spacing.sm : tokens.spacing.md;
|
|
29
|
+
const fontSize = size === 'sm' ? tokens.typography.size.sm : tokens.typography.size.base;
|
|
30
|
+
|
|
31
|
+
if (renderOption) {
|
|
32
|
+
return (
|
|
33
|
+
<Pressable
|
|
34
|
+
onPress={option.disabled ? undefined : onSelect}
|
|
35
|
+
disabled={option.disabled}
|
|
36
|
+
role="option"
|
|
37
|
+
aria-selected={isSelected}
|
|
38
|
+
>
|
|
39
|
+
{renderOption(option, { isSelected, isFocused })}
|
|
40
|
+
</Pressable>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Pressable
|
|
46
|
+
onPress={option.disabled ? undefined : onSelect}
|
|
47
|
+
disabled={option.disabled}
|
|
48
|
+
role="option"
|
|
49
|
+
aria-selected={isSelected}
|
|
50
|
+
style={({ pressed }) => [
|
|
51
|
+
{
|
|
52
|
+
flexDirection: 'row' as const,
|
|
53
|
+
alignItems: 'center' as const,
|
|
54
|
+
justifyContent: 'space-between' as const,
|
|
55
|
+
paddingVertical,
|
|
56
|
+
paddingHorizontal,
|
|
57
|
+
},
|
|
58
|
+
isSelected && {
|
|
59
|
+
backgroundColor: srgbToHex(tokens.backgroundSunken.srgb),
|
|
60
|
+
},
|
|
61
|
+
isFocused &&
|
|
62
|
+
!isSelected && {
|
|
63
|
+
backgroundColor: `${srgbToHex(tokens.border.srgb)}20`,
|
|
64
|
+
},
|
|
65
|
+
option.disabled && {
|
|
66
|
+
opacity: 0.5,
|
|
67
|
+
},
|
|
68
|
+
pressed && {
|
|
69
|
+
opacity: 0.7,
|
|
70
|
+
},
|
|
71
|
+
]}
|
|
72
|
+
>
|
|
73
|
+
<Text
|
|
74
|
+
style={[
|
|
75
|
+
{
|
|
76
|
+
flex: 1,
|
|
77
|
+
fontFamily: tokens.typography.fonts.default,
|
|
78
|
+
fontSize,
|
|
79
|
+
color: srgbToHex(tokens.textPrimary.srgb),
|
|
80
|
+
},
|
|
81
|
+
isSelected && {
|
|
82
|
+
fontWeight: tokens.typography.weight.semibold as any,
|
|
83
|
+
color: srgbToHex(tokens.interactive.srgb),
|
|
84
|
+
},
|
|
85
|
+
option.disabled && {
|
|
86
|
+
color: srgbToHex(tokens.textSecondary.srgb),
|
|
87
|
+
},
|
|
88
|
+
]}
|
|
89
|
+
numberOfLines={1}
|
|
90
|
+
>
|
|
91
|
+
{option.label}
|
|
92
|
+
</Text>
|
|
93
|
+
{isSelected && (
|
|
94
|
+
<View style={{ marginLeft: tokens.spacing.sm }}>
|
|
95
|
+
<Icon
|
|
96
|
+
name="check"
|
|
97
|
+
size={fontSize}
|
|
98
|
+
color={srgbToHex(tokens.interactive.srgb)}
|
|
99
|
+
/>
|
|
100
|
+
</View>
|
|
101
|
+
)}
|
|
102
|
+
</Pressable>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { SelectOption } from './Select.types';
|
|
3
|
+
|
|
4
|
+
interface UseSelectOptions {
|
|
5
|
+
readonly flatOptions: readonly SelectOption[];
|
|
6
|
+
readonly value: string;
|
|
7
|
+
readonly isOpen: boolean;
|
|
8
|
+
readonly onSelect: (value: string) => void;
|
|
9
|
+
readonly onClose: () => void;
|
|
10
|
+
readonly onOpen: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function findNextEnabled(
|
|
14
|
+
options: readonly SelectOption[],
|
|
15
|
+
startIndex: number,
|
|
16
|
+
direction: 1 | -1
|
|
17
|
+
): number {
|
|
18
|
+
const len = options.length;
|
|
19
|
+
if (len === 0) return -1;
|
|
20
|
+
|
|
21
|
+
for (let i = 1; i <= len; i++) {
|
|
22
|
+
const idx = (startIndex + i * direction + len) % len;
|
|
23
|
+
if (!options[idx].disabled) return idx;
|
|
24
|
+
}
|
|
25
|
+
return -1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useSelect({
|
|
29
|
+
flatOptions,
|
|
30
|
+
value,
|
|
31
|
+
isOpen,
|
|
32
|
+
onSelect,
|
|
33
|
+
onClose,
|
|
34
|
+
onOpen,
|
|
35
|
+
}: UseSelectOptions) {
|
|
36
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
37
|
+
const typeAheadRef = useRef('');
|
|
38
|
+
const typeAheadTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
39
|
+
|
|
40
|
+
// When opening, focus the selected option or first enabled option
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (isOpen) {
|
|
43
|
+
const selectedIdx = flatOptions.findIndex((o) => o.value === value);
|
|
44
|
+
if (selectedIdx >= 0 && !flatOptions[selectedIdx].disabled) {
|
|
45
|
+
setFocusedIndex(selectedIdx);
|
|
46
|
+
} else {
|
|
47
|
+
const firstEnabled = flatOptions.findIndex((o) => !o.disabled);
|
|
48
|
+
setFocusedIndex(firstEnabled >= 0 ? firstEnabled : -1);
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
setFocusedIndex(-1);
|
|
52
|
+
}
|
|
53
|
+
}, [isOpen, flatOptions, value]);
|
|
54
|
+
|
|
55
|
+
const handleKeyDown = useCallback(
|
|
56
|
+
(e: any) => {
|
|
57
|
+
const key: string = e.key;
|
|
58
|
+
|
|
59
|
+
// When closed, open on ArrowDown/ArrowUp/Enter/Space
|
|
60
|
+
if (!isOpen) {
|
|
61
|
+
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ') {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
onOpen();
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
switch (key) {
|
|
69
|
+
case 'ArrowDown': {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
const next = findNextEnabled(flatOptions, focusedIndex, 1);
|
|
72
|
+
if (next >= 0) setFocusedIndex(next);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case 'ArrowUp': {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
const prev = findNextEnabled(flatOptions, focusedIndex, -1);
|
|
78
|
+
if (prev >= 0) setFocusedIndex(prev);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case 'Enter':
|
|
82
|
+
case ' ': {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
if (focusedIndex >= 0 && !flatOptions[focusedIndex].disabled) {
|
|
85
|
+
onSelect(flatOptions[focusedIndex].value);
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case 'Escape': {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
onClose();
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'Home': {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
const first = flatOptions.findIndex((o) => !o.disabled);
|
|
97
|
+
if (first >= 0) setFocusedIndex(first);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case 'End': {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
const lastEnabled = [...flatOptions].reverse().findIndex((o) => !o.disabled);
|
|
103
|
+
if (lastEnabled >= 0) setFocusedIndex(flatOptions.length - 1 - lastEnabled);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
default: {
|
|
107
|
+
// Type-ahead: single character matching
|
|
108
|
+
if (key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
|
109
|
+
clearTimeout(typeAheadTimerRef.current);
|
|
110
|
+
typeAheadRef.current += key.toLowerCase();
|
|
111
|
+
|
|
112
|
+
const match = flatOptions.findIndex(
|
|
113
|
+
(o) => !o.disabled && o.label.toLowerCase().startsWith(typeAheadRef.current)
|
|
114
|
+
);
|
|
115
|
+
if (match >= 0) setFocusedIndex(match);
|
|
116
|
+
|
|
117
|
+
typeAheadTimerRef.current = setTimeout(() => {
|
|
118
|
+
typeAheadRef.current = '';
|
|
119
|
+
}, 500);
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
[isOpen, focusedIndex, flatOptions, onSelect, onClose, onOpen]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return { focusedIndex, handleKeyDown };
|
|
129
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { StyleSheet } from 'react-native';
|
|
2
|
+
import { srgbToHex } from 'newtone';
|
|
3
|
+
import type { ResolvedTokens } from '../tokens/types';
|
|
4
|
+
|
|
5
|
+
interface SidebarStyleInput {
|
|
6
|
+
readonly tokens: ResolvedTokens;
|
|
7
|
+
readonly width: number;
|
|
8
|
+
readonly bordered: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getSidebarStyles({ tokens, width, bordered }: SidebarStyleInput) {
|
|
12
|
+
const borderColor = srgbToHex(tokens.border.srgb);
|
|
13
|
+
|
|
14
|
+
return StyleSheet.create({
|
|
15
|
+
container: {
|
|
16
|
+
width,
|
|
17
|
+
flexShrink: 0,
|
|
18
|
+
flexDirection: 'column',
|
|
19
|
+
backgroundColor: srgbToHex(tokens.background.srgb),
|
|
20
|
+
borderRightWidth: bordered ? 1 : 0,
|
|
21
|
+
borderRightColor: borderColor,
|
|
22
|
+
},
|
|
23
|
+
header: {
|
|
24
|
+
flexShrink: 0,
|
|
25
|
+
borderBottomWidth: 1,
|
|
26
|
+
borderBottomColor: borderColor,
|
|
27
|
+
},
|
|
28
|
+
body: {
|
|
29
|
+
flex: 1,
|
|
30
|
+
},
|
|
31
|
+
footer: {
|
|
32
|
+
flexShrink: 0,
|
|
33
|
+
borderTopWidth: 1,
|
|
34
|
+
borderTopColor: borderColor,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, ScrollView } from 'react-native';
|
|
3
|
+
import type { SidebarProps } from './Sidebar.types';
|
|
4
|
+
import { useTokens } from '../tokens/useTokens';
|
|
5
|
+
import { getSidebarStyles } from './Sidebar.styles';
|
|
6
|
+
|
|
7
|
+
export function Sidebar({
|
|
8
|
+
children,
|
|
9
|
+
header,
|
|
10
|
+
footer,
|
|
11
|
+
width = 260,
|
|
12
|
+
bordered = true,
|
|
13
|
+
}: SidebarProps) {
|
|
14
|
+
const tokens = useTokens();
|
|
15
|
+
const styles = React.useMemo(
|
|
16
|
+
() => getSidebarStyles({ tokens, width, bordered }),
|
|
17
|
+
[tokens, width, bordered]
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<View style={styles.container}>
|
|
22
|
+
{header && <View style={styles.header}>{header}</View>}
|
|
23
|
+
<ScrollView style={styles.body}>{children}</ScrollView>
|
|
24
|
+
{footer && <View style={styles.footer}>{footer}</View>}
|
|
25
|
+
</View>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SidebarProps {
|
|
4
|
+
/** Scrollable body content (navigation items, etc.) */
|
|
5
|
+
readonly children?: ReactNode;
|
|
6
|
+
/** Sticky header slot (logo, brand) */
|
|
7
|
+
readonly header?: ReactNode;
|
|
8
|
+
/** Sticky footer slot (user menu, settings) */
|
|
9
|
+
readonly footer?: ReactNode;
|
|
10
|
+
/** Fixed width in pixels. @default 260 */
|
|
11
|
+
readonly width?: number;
|
|
12
|
+
/** Show right border. @default true */
|
|
13
|
+
readonly bordered?: boolean;
|
|
14
|
+
}
|