@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.
Files changed (181) hide show
  1. package/dist/AppShell/AppShell.d.ts +4 -0
  2. package/dist/AppShell/AppShell.d.ts.map +1 -0
  3. package/dist/AppShell/AppShell.styles.d.ts +16 -0
  4. package/dist/AppShell/AppShell.styles.d.ts.map +1 -0
  5. package/dist/AppShell/AppShell.types.d.ts +8 -0
  6. package/dist/AppShell/AppShell.types.d.ts.map +1 -0
  7. package/dist/AppShell/index.d.ts +3 -0
  8. package/dist/AppShell/index.d.ts.map +1 -0
  9. package/dist/Button/Button.d.ts +9 -4
  10. package/dist/Button/Button.d.ts.map +1 -1
  11. package/dist/Button/Button.styles.d.ts +33 -26
  12. package/dist/Button/Button.styles.d.ts.map +1 -1
  13. package/dist/Button/Button.types.d.ts +17 -2
  14. package/dist/Button/Button.types.d.ts.map +1 -1
  15. package/dist/ColorScaleSlider/ColorScaleSlider.d.ts +13 -0
  16. package/dist/ColorScaleSlider/ColorScaleSlider.d.ts.map +1 -0
  17. package/dist/ColorScaleSlider/ColorScaleSlider.styles.d.ts +54 -0
  18. package/dist/ColorScaleSlider/ColorScaleSlider.styles.d.ts.map +1 -0
  19. package/dist/ColorScaleSlider/ColorScaleSlider.types.d.ts +25 -0
  20. package/dist/ColorScaleSlider/ColorScaleSlider.types.d.ts.map +1 -0
  21. package/dist/ColorScaleSlider/index.d.ts +3 -0
  22. package/dist/ColorScaleSlider/index.d.ts.map +1 -0
  23. package/dist/Frame/Frame.d.ts +48 -0
  24. package/dist/Frame/Frame.d.ts.map +1 -0
  25. package/dist/Frame/Frame.styles.d.ts +39 -0
  26. package/dist/Frame/Frame.styles.d.ts.map +1 -0
  27. package/dist/Frame/Frame.types.d.ts +115 -0
  28. package/dist/Frame/Frame.types.d.ts.map +1 -0
  29. package/dist/Frame/Frame.utils.d.ts +39 -0
  30. package/dist/Frame/Frame.utils.d.ts.map +1 -0
  31. package/dist/Frame/index.d.ts +4 -0
  32. package/dist/Frame/index.d.ts.map +1 -0
  33. package/dist/HueSlider/HueSlider.d.ts +1 -1
  34. package/dist/HueSlider/HueSlider.d.ts.map +1 -1
  35. package/dist/HueSlider/HueSlider.styles.d.ts +47 -5
  36. package/dist/HueSlider/HueSlider.styles.d.ts.map +1 -1
  37. package/dist/HueSlider/HueSlider.types.d.ts +1 -0
  38. package/dist/HueSlider/HueSlider.types.d.ts.map +1 -1
  39. package/dist/Icon/Icon.d.ts +36 -0
  40. package/dist/Icon/Icon.d.ts.map +1 -0
  41. package/dist/Navbar/Navbar.d.ts +4 -0
  42. package/dist/Navbar/Navbar.d.ts.map +1 -0
  43. package/dist/Navbar/Navbar.styles.d.ts +31 -0
  44. package/dist/Navbar/Navbar.styles.d.ts.map +1 -0
  45. package/dist/Navbar/Navbar.types.d.ts +14 -0
  46. package/dist/Navbar/Navbar.types.d.ts.map +1 -0
  47. package/dist/Navbar/index.d.ts +3 -0
  48. package/dist/Navbar/index.d.ts.map +1 -0
  49. package/dist/Popover/Popover.d.ts +4 -0
  50. package/dist/Popover/Popover.d.ts.map +1 -0
  51. package/dist/Popover/Popover.styles.d.ts +9 -0
  52. package/dist/Popover/Popover.styles.d.ts.map +1 -0
  53. package/dist/Popover/Popover.types.d.ts +37 -0
  54. package/dist/Popover/Popover.types.d.ts.map +1 -0
  55. package/dist/Popover/index.d.ts +4 -0
  56. package/dist/Popover/index.d.ts.map +1 -0
  57. package/dist/Popover/usePopover.d.ts +3 -0
  58. package/dist/Popover/usePopover.d.ts.map +1 -0
  59. package/dist/Select/Select.d.ts +1 -8
  60. package/dist/Select/Select.d.ts.map +1 -1
  61. package/dist/Select/Select.styles.d.ts +32 -5
  62. package/dist/Select/Select.styles.d.ts.map +1 -1
  63. package/dist/Select/Select.types.d.ts +25 -1
  64. package/dist/Select/Select.types.d.ts.map +1 -1
  65. package/dist/Select/SelectOption.d.ts +13 -0
  66. package/dist/Select/SelectOption.d.ts.map +1 -0
  67. package/dist/Select/useSelect.d.ts +15 -0
  68. package/dist/Select/useSelect.d.ts.map +1 -0
  69. package/dist/Sidebar/Sidebar.d.ts +4 -0
  70. package/dist/Sidebar/Sidebar.d.ts.map +1 -0
  71. package/dist/Sidebar/Sidebar.styles.d.ts +31 -0
  72. package/dist/Sidebar/Sidebar.styles.d.ts.map +1 -0
  73. package/dist/Sidebar/Sidebar.types.d.ts +14 -0
  74. package/dist/Sidebar/Sidebar.types.d.ts.map +1 -0
  75. package/dist/Sidebar/index.d.ts +3 -0
  76. package/dist/Sidebar/index.d.ts.map +1 -0
  77. package/dist/Slider/Slider.d.ts +1 -1
  78. package/dist/Slider/Slider.d.ts.map +1 -1
  79. package/dist/Slider/Slider.styles.d.ts +48 -8
  80. package/dist/Slider/Slider.styles.d.ts.map +1 -1
  81. package/dist/Slider/Slider.types.d.ts +1 -0
  82. package/dist/Slider/Slider.types.d.ts.map +1 -1
  83. package/dist/TextInput/TextInput.styles.d.ts +3 -1
  84. package/dist/TextInput/TextInput.styles.d.ts.map +1 -1
  85. package/dist/Toggle/Toggle.styles.d.ts +2 -1
  86. package/dist/Toggle/Toggle.styles.d.ts.map +1 -1
  87. package/dist/fonts/GoogleFontLoader.d.ts +19 -0
  88. package/dist/fonts/GoogleFontLoader.d.ts.map +1 -0
  89. package/dist/fonts/IconFontLoader.d.ts +13 -0
  90. package/dist/fonts/IconFontLoader.d.ts.map +1 -0
  91. package/dist/fonts/buildGoogleFontsUrl.d.ts +17 -0
  92. package/dist/fonts/buildGoogleFontsUrl.d.ts.map +1 -0
  93. package/dist/fonts/googleFonts.d.ts +20 -0
  94. package/dist/fonts/googleFonts.d.ts.map +1 -0
  95. package/dist/index.cjs +2303 -205
  96. package/dist/index.cjs.map +1 -1
  97. package/dist/index.d.ts +27 -3
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +2279 -200
  100. package/dist/index.js.map +1 -1
  101. package/dist/registry/codegen.d.ts +11 -0
  102. package/dist/registry/codegen.d.ts.map +1 -0
  103. package/dist/registry/index.d.ts +4 -0
  104. package/dist/registry/index.d.ts.map +1 -0
  105. package/dist/registry/registry.d.ts +7 -0
  106. package/dist/registry/registry.d.ts.map +1 -0
  107. package/dist/registry/types.d.ts +32 -0
  108. package/dist/registry/types.d.ts.map +1 -0
  109. package/dist/theme/FrameContext.d.ts +24 -0
  110. package/dist/theme/FrameContext.d.ts.map +1 -0
  111. package/dist/theme/NewtoneProvider.d.ts.map +1 -1
  112. package/dist/theme/defaults.d.ts.map +1 -1
  113. package/dist/theme/types.d.ts +64 -1
  114. package/dist/theme/types.d.ts.map +1 -1
  115. package/dist/tokens/computeTokens.d.ts +55 -3
  116. package/dist/tokens/computeTokens.d.ts.map +1 -1
  117. package/dist/tokens/types.d.ts +52 -0
  118. package/dist/tokens/types.d.ts.map +1 -1
  119. package/dist/tokens/useTokens.d.ts +12 -9
  120. package/dist/tokens/useTokens.d.ts.map +1 -1
  121. package/package.json +1 -1
  122. package/src/AppShell/AppShell.styles.ts +20 -0
  123. package/src/AppShell/AppShell.tsx +17 -0
  124. package/src/AppShell/AppShell.types.ts +8 -0
  125. package/src/AppShell/index.ts +2 -0
  126. package/src/Button/Button.styles.ts +74 -41
  127. package/src/Button/Button.tsx +36 -17
  128. package/src/Button/Button.types.ts +20 -2
  129. package/src/Card/Card.styles.ts +2 -2
  130. package/src/ColorScaleSlider/ColorScaleSlider.styles.ts +60 -0
  131. package/src/ColorScaleSlider/ColorScaleSlider.tsx +156 -0
  132. package/src/ColorScaleSlider/ColorScaleSlider.types.ts +25 -0
  133. package/src/ColorScaleSlider/index.ts +2 -0
  134. package/src/Frame/Frame.styles.ts +213 -0
  135. package/src/Frame/Frame.tsx +242 -0
  136. package/src/Frame/Frame.types.ts +181 -0
  137. package/src/Frame/Frame.utils.ts +189 -0
  138. package/src/Frame/index.ts +21 -0
  139. package/src/HueSlider/HueSlider.styles.ts +58 -39
  140. package/src/HueSlider/HueSlider.tsx +97 -25
  141. package/src/HueSlider/HueSlider.types.ts +1 -0
  142. package/src/Icon/Icon.tsx +76 -0
  143. package/src/Navbar/Navbar.styles.ts +37 -0
  144. package/src/Navbar/Navbar.tsx +32 -0
  145. package/src/Navbar/Navbar.types.ts +14 -0
  146. package/src/Navbar/index.ts +2 -0
  147. package/src/Popover/Popover.styles.ts +39 -0
  148. package/src/Popover/Popover.tsx +103 -0
  149. package/src/Popover/Popover.types.ts +40 -0
  150. package/src/Popover/index.ts +3 -0
  151. package/src/Popover/usePopover.ts +26 -0
  152. package/src/Select/Select.styles.ts +49 -10
  153. package/src/Select/Select.tsx +127 -36
  154. package/src/Select/Select.types.ts +30 -1
  155. package/src/Select/SelectOption.tsx +104 -0
  156. package/src/Select/useSelect.ts +129 -0
  157. package/src/Sidebar/Sidebar.styles.ts +37 -0
  158. package/src/Sidebar/Sidebar.tsx +27 -0
  159. package/src/Sidebar/Sidebar.types.ts +14 -0
  160. package/src/Sidebar/index.ts +2 -0
  161. package/src/Slider/Slider.styles.ts +53 -25
  162. package/src/Slider/Slider.tsx +89 -24
  163. package/src/Slider/Slider.types.ts +1 -0
  164. package/src/TextInput/TextInput.styles.ts +9 -7
  165. package/src/Toggle/Toggle.styles.ts +4 -3
  166. package/src/fonts/GoogleFontLoader.tsx +63 -0
  167. package/src/fonts/IconFontLoader.tsx +49 -0
  168. package/src/fonts/buildGoogleFontsUrl.ts +31 -0
  169. package/src/fonts/googleFonts.ts +87 -0
  170. package/src/index.ts +70 -2
  171. package/src/registry/codegen.ts +132 -0
  172. package/src/registry/index.ts +17 -0
  173. package/src/registry/registry.ts +402 -0
  174. package/src/registry/types.ts +35 -0
  175. package/src/theme/FrameContext.tsx +29 -0
  176. package/src/theme/NewtoneProvider.tsx +9 -1
  177. package/src/theme/defaults.ts +51 -0
  178. package/src/theme/types.ts +66 -1
  179. package/src/tokens/computeTokens.ts +103 -46
  180. package/src/tokens/types.ts +52 -0
  181. 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(tokens: ResolvedTokens, disabled: boolean) {
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: 4,
20
+ gap: tokens.spacing.xs,
21
+ zIndex: isOpen ? 999 : 0,
9
22
  },
10
23
  label: {
11
- fontSize: 12,
12
- fontWeight: '600',
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
- select: {
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: 6,
20
- paddingVertical: 8,
21
- paddingHorizontal: 12,
22
- fontSize: 14,
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
- opacity: disabled ? 0.5 : 1,
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
  }
@@ -1,60 +1,151 @@
1
- import React from 'react';
2
- import { View, Text, StyleSheet } from 'react-native';
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 styles = React.useMemo(
25
- () => getSelectStyles(tokens, disabled),
26
- [tokens, disabled]
27
- );
41
+ const flatOptions = useMemo(() => flattenOptions(options), [options]);
28
42
 
29
- const handleChange = React.useCallback(
30
- (e: React.ChangeEvent<HTMLSelectElement>) => {
31
- onValueChange(e.target.value);
43
+ const { focusedIndex, handleKeyDown } = useSelect({
44
+ flatOptions,
45
+ value,
46
+ isOpen,
47
+ onSelect: (v) => {
48
+ onValueChange(v);
49
+ close();
32
50
  },
33
- [onValueChange]
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
- // Flatten StyleSheet styles into inline style for the native <select>
37
- const selectStyle = StyleSheet.flatten(styles.select);
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
- <select
43
- value={value}
44
- onChange={handleChange}
45
- disabled={disabled}
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
- {options.map((option) => (
53
- <option key={option.value} value={option.value}>
54
- {option.label}
55
- </option>
56
- ))}
57
- </select>
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 SelectProps {
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
+ }
@@ -0,0 +1,2 @@
1
+ export { Sidebar } from './Sidebar';
2
+ export type { SidebarProps } from './Sidebar.types';