@newtonedev/components 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +32 -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 +2304 -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 +2280 -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 +38 -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,12 +2,12 @@ import { StyleSheet } from 'react-native';
2
2
  import { srgbToHex } from 'newtone';
3
3
  import type { ResolvedTokens } from '../tokens/types';
4
4
 
5
- /** Rainbow gradient for hue slider track */
6
- const HUE_GRADIENT =
7
- 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)';
5
+ const TRACK_HEIGHT = 22;
6
+ export const THUMB_SIZE = 18;
7
+ const SEGMENT_COUNT = 48;
8
8
 
9
- /** Convert an HSL hue (S=100%, L=50%) to a hex string for gradient stops. */
10
- function hueToHex(hue: number): string {
9
+ /** Convert an HSL hue (S=100%, L=50%) to a hex string. */
10
+ export function hueToHex(hue: number): string {
11
11
  const h = ((hue % 360) + 360) % 360;
12
12
  const x = 1 - Math.abs((h / 60) % 2 - 1);
13
13
  let r: number, g: number, b: number;
@@ -21,21 +21,19 @@ function hueToHex(hue: number): string {
21
21
  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
22
22
  }
23
23
 
24
- function buildHueGradient(min: number, max: number): string {
25
- if (min === 0 && max === 359) return HUE_GRADIENT;
26
- const steps = 7;
27
- const stops: string[] = [];
28
- for (let i = 0; i <= steps; i++) {
29
- const hue = min + (max - min) * (i / steps);
30
- stops.push(hueToHex(hue));
31
- }
32
- return `linear-gradient(to right, ${stops.join(', ')})`;
24
+ /** Build an array of hex colors for the gradient track segments. */
25
+ export function buildHueSegments(min: number, max: number): string[] {
26
+ return Array.from({ length: SEGMENT_COUNT }, (_, i) => {
27
+ const hue = min + (max - min) * (i / (SEGMENT_COUNT - 1));
28
+ return hueToHex(hue);
29
+ });
33
30
  }
34
31
 
35
- export function getHueSliderStyles(tokens: ResolvedTokens) {
32
+ export function getHueSliderStyles(tokens: ResolvedTokens, disabled: boolean) {
36
33
  return StyleSheet.create({
37
34
  container: {
38
- gap: 4,
35
+ gap: tokens.spacing.xs,
36
+ opacity: disabled ? 0.5 : 1,
39
37
  },
40
38
  labelRow: {
41
39
  flexDirection: 'row',
@@ -43,35 +41,56 @@ export function getHueSliderStyles(tokens: ResolvedTokens) {
43
41
  alignItems: 'center',
44
42
  },
45
43
  label: {
46
- fontSize: 12,
47
- fontWeight: '600',
44
+ fontFamily: tokens.typography.fonts.default,
45
+ fontSize: tokens.typography.size.sm,
46
+ fontWeight: tokens.typography.weight.semibold as any,
48
47
  color: srgbToHex(tokens.textSecondary.srgb),
49
48
  },
50
49
  value: {
51
- fontSize: 12,
52
- fontWeight: '500',
50
+ fontFamily: tokens.typography.fonts.default,
51
+ fontSize: tokens.typography.size.sm,
52
+ fontWeight: tokens.typography.weight.medium as any,
53
+ color: srgbToHex(tokens.textPrimary.srgb),
54
+ },
55
+ valueInput: {
56
+ width: 48,
57
+ paddingVertical: 0,
58
+ paddingHorizontal: 4,
59
+ borderWidth: 1,
60
+ borderColor: srgbToHex(tokens.border.srgb),
61
+ borderRadius: 4,
62
+ backgroundColor: 'transparent',
53
63
  color: srgbToHex(tokens.textPrimary.srgb),
64
+ fontFamily: tokens.typography.fonts.default,
65
+ fontSize: tokens.typography.size.sm,
66
+ fontWeight: tokens.typography.weight.medium as any,
67
+ textAlign: 'right',
54
68
  },
55
- sliderTrack: {
56
- height: 22,
57
- borderRadius: 11,
69
+ trackContainer: {
70
+ height: TRACK_HEIGHT + THUMB_SIZE,
71
+ justifyContent: 'center',
72
+ position: 'relative',
73
+ },
74
+ gradientTrack: {
75
+ position: 'absolute',
76
+ left: 0,
77
+ right: 0,
78
+ height: TRACK_HEIGHT,
79
+ borderRadius: TRACK_HEIGHT / 2,
80
+ flexDirection: 'row',
58
81
  overflow: 'hidden',
59
82
  },
83
+ segment: {
84
+ flex: 1,
85
+ },
86
+ thumb: {
87
+ position: 'absolute',
88
+ width: THUMB_SIZE,
89
+ height: THUMB_SIZE,
90
+ borderRadius: THUMB_SIZE / 2,
91
+ backgroundColor: '#ffffff',
92
+ borderWidth: 2,
93
+ borderColor: srgbToHex(tokens.border.srgb),
94
+ },
60
95
  });
61
96
  }
62
-
63
- export function getHueSliderInputStyle(
64
- disabled: boolean,
65
- min: number = 0,
66
- max: number = 359,
67
- ): React.CSSProperties {
68
- return {
69
- width: '100%',
70
- height: 22,
71
- borderRadius: 11,
72
- cursor: disabled ? 'default' : 'pointer',
73
- opacity: disabled ? 0.5 : 1,
74
- background: buildHueGradient(min, max),
75
- appearance: 'auto' as const,
76
- };
77
- }
@@ -1,8 +1,12 @@
1
1
  import React from 'react';
2
- import { View, Text } from 'react-native';
2
+ import { View, Text, TextInput, PanResponder } from 'react-native';
3
3
  import type { HueSliderProps } from './HueSlider.types';
4
4
  import { useTokens } from '../tokens/useTokens';
5
- import { getHueSliderStyles, getHueSliderInputStyle } from './HueSlider.styles';
5
+ import {
6
+ getHueSliderStyles,
7
+ buildHueSegments,
8
+ THUMB_SIZE,
9
+ } from './HueSlider.styles';
6
10
 
7
11
  /**
8
12
  * Hue slider with rainbow gradient track.
@@ -21,50 +25,118 @@ export function HueSlider({
21
25
  max = 359,
22
26
  label,
23
27
  showValue = false,
28
+ editableValue = false,
24
29
  disabled = false,
25
30
  style,
26
31
  }: HueSliderProps) {
27
32
  const tokens = useTokens(1);
28
33
 
29
34
  const styles = React.useMemo(
30
- () => getHueSliderStyles(tokens),
31
- [tokens]
35
+ () => getHueSliderStyles(tokens, disabled),
36
+ [tokens, disabled]
32
37
  );
33
38
 
34
- const inputStyle = React.useMemo(
35
- () => getHueSliderInputStyle(disabled, min, max),
36
- [disabled, min, max]
39
+ const segments = React.useMemo(
40
+ () => buildHueSegments(min, max),
41
+ [min, max]
37
42
  );
38
43
 
39
- // For wrapping ranges (max > 359), convert stored value to slider range.
40
- const sliderValue = (max > 359 && value < min) ? value + 360 : value;
44
+ const trackRef = React.useRef<View>(null);
45
+ const trackWidth = React.useRef(0);
46
+ const trackPageX = React.useRef(0);
41
47
 
42
- const handleChange = React.useCallback(
43
- (e: React.ChangeEvent<HTMLInputElement>) => {
44
- const raw = Number(e.target.value);
45
- onValueChange(((raw % 360) + 360) % 360);
48
+ // Mutable refs to avoid stale closures in PanResponder
49
+ const onValueChangeRef = React.useRef(onValueChange);
50
+ const minRef = React.useRef(min);
51
+ const maxRef = React.useRef(max);
52
+ const disabledRef = React.useRef(disabled);
53
+
54
+ React.useEffect(() => { onValueChangeRef.current = onValueChange; }, [onValueChange]);
55
+ React.useEffect(() => { minRef.current = min; }, [min]);
56
+ React.useEffect(() => { maxRef.current = max; }, [max]);
57
+ React.useEffect(() => { disabledRef.current = disabled; }, [disabled]);
58
+
59
+ const computeHue = React.useCallback((pageX: number) => {
60
+ const localX = pageX - trackPageX.current;
61
+ const ratio = Math.min(1, Math.max(0, localX / trackWidth.current));
62
+ const raw = minRef.current + ratio * (maxRef.current - minRef.current);
63
+ const stepped = Math.round(raw);
64
+ return ((stepped % 360) + 360) % 360;
65
+ }, []);
66
+
67
+ const panResponder = React.useRef(
68
+ PanResponder.create({
69
+ onStartShouldSetPanResponder: () => !disabledRef.current,
70
+ onMoveShouldSetPanResponder: () => !disabledRef.current,
71
+ onPanResponderGrant: (evt) => {
72
+ onValueChangeRef.current(computeHue(evt.nativeEvent.pageX));
73
+ },
74
+ onPanResponderMove: (_evt, gestureState) => {
75
+ onValueChangeRef.current(computeHue(gestureState.moveX));
76
+ },
77
+ })
78
+ ).current;
79
+
80
+ // For wrapping ranges (max > 359), convert stored value to slider range
81
+ const sliderValue = max > 359 && value < min ? value + 360 : value;
82
+ const ratio = max > min ? (sliderValue - min) / (max - min) : 0;
83
+ const usableWidth = Math.max(0, trackWidth.current - THUMB_SIZE);
84
+ const thumbLeft = ratio * usableWidth;
85
+
86
+ const handleValueTextSubmit = React.useCallback(
87
+ (text: string) => {
88
+ const raw = Number(text);
89
+ if (!Number.isNaN(raw)) {
90
+ onValueChange(((raw % 360) + 360) % 360);
91
+ }
46
92
  },
47
93
  [onValueChange]
48
94
  );
49
95
 
96
+ const [editText, setEditText] = React.useState(String(value));
97
+ React.useEffect(() => { setEditText(String(value)); }, [value]);
98
+
99
+ const showLabel = label || showValue || editableValue;
100
+
50
101
  return (
51
102
  <View style={[styles.container, ...(Array.isArray(style) ? style : [style])]}>
52
- {(label || showValue) && (
103
+ {showLabel && (
53
104
  <View style={styles.labelRow}>
54
105
  {label && <Text style={styles.label}>{label}</Text>}
55
- {showValue && <Text style={styles.value}>{value}°</Text>}
106
+ {editableValue ? (
107
+ <TextInput
108
+ style={styles.valueInput}
109
+ value={editText}
110
+ keyboardType="numeric"
111
+ onChangeText={setEditText}
112
+ onSubmitEditing={(e) => handleValueTextSubmit(e.nativeEvent.text)}
113
+ onBlur={() => handleValueTextSubmit(editText)}
114
+ selectTextOnFocus
115
+ editable={!disabled}
116
+ />
117
+ ) : (
118
+ showValue && <Text style={styles.value}>{value}°</Text>
119
+ )}
56
120
  </View>
57
121
  )}
58
- <input
59
- type="range"
60
- min={min}
61
- max={max}
62
- step={1}
63
- value={sliderValue}
64
- onChange={handleChange}
65
- disabled={disabled}
66
- style={inputStyle}
67
- />
122
+ <View
123
+ ref={trackRef}
124
+ style={styles.trackContainer}
125
+ onLayout={(e) => {
126
+ trackWidth.current = e.nativeEvent.layout.width;
127
+ trackRef.current?.measure((_x, _y, _w, _h, pageX) => {
128
+ if (pageX != null) trackPageX.current = pageX;
129
+ });
130
+ }}
131
+ {...panResponder.panHandlers}
132
+ >
133
+ <View style={styles.gradientTrack}>
134
+ {segments.map((color, i) => (
135
+ <View key={i} style={[styles.segment, { backgroundColor: color }]} />
136
+ ))}
137
+ </View>
138
+ <View style={[styles.thumb, { left: thumbLeft }]} />
139
+ </View>
68
140
  </View>
69
141
  );
70
142
  }
@@ -7,6 +7,7 @@ export interface HueSliderProps {
7
7
  readonly max?: number;
8
8
  readonly label?: string;
9
9
  readonly showValue?: boolean;
10
+ readonly editableValue?: boolean;
10
11
  readonly disabled?: boolean;
11
12
  readonly style?: ViewStyle | ViewStyle[];
12
13
  }
@@ -0,0 +1,76 @@
1
+ import React from 'react';
2
+ import { Text, type ViewStyle, type TextStyle } from 'react-native';
3
+ import { srgbToHex } from 'newtone';
4
+ import { useTokens } from '../tokens/useTokens';
5
+ import type { ElevationLevel } from '../theme/types';
6
+
7
+ export interface IconProps {
8
+ /** Icon ligature name (e.g., 'home', 'settings', 'check') */
9
+ readonly name: string;
10
+ /** Font size in pixels (default: tokens.typography.size.base) */
11
+ readonly size?: number;
12
+ /** Optical size for variable font axis (default: same as size) */
13
+ readonly opticalSize?: number;
14
+ /** Fill state: 0=outlined, 1=filled (default: 0) */
15
+ readonly fill?: 0 | 1;
16
+ /** Icon color as hex string (default: tokens.textPrimary) */
17
+ readonly color?: string;
18
+ /** Elevation level for token computation (default: 1) */
19
+ readonly elevation?: ElevationLevel;
20
+ /** Additional styles */
21
+ readonly style?: ViewStyle;
22
+ /** Press handler */
23
+ readonly onPress?: () => void;
24
+ }
25
+
26
+ /**
27
+ * Material Symbols icon component with variable font support.
28
+ *
29
+ * Uses global icon configuration (variant, weight, auto-grade) from theme config,
30
+ * with per-instance control over size, fill, and color.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * <Icon name="home" />
35
+ * <Icon name="settings" size={24} fill={1} />
36
+ * <Icon name="check" color="#00ff00" />
37
+ * ```
38
+ */
39
+ export function Icon({
40
+ name,
41
+ size,
42
+ opticalSize,
43
+ fill = 0,
44
+ color,
45
+ elevation = 1,
46
+ style,
47
+ onPress,
48
+ }: IconProps) {
49
+ const tokens = useTokens(elevation);
50
+
51
+ const fontSize = size ?? tokens.typography.size.base;
52
+ const opsz = opticalSize ?? fontSize;
53
+ const iconColor = color ?? srgbToHex(tokens.textPrimary.srgb);
54
+
55
+ // Font family varies by variant: 'Material Symbols Rounded', etc.
56
+ const fontFamily = `Material Symbols ${tokens.icons.variant.charAt(0).toUpperCase() + tokens.icons.variant.slice(1)}`;
57
+
58
+ // Font variation settings for Material Symbols variable font
59
+ const fontVariationSettings = `'FILL' ${fill}, 'wght' ${tokens.icons.weight}, 'GRAD' ${tokens.icons.grade}, 'opsz' ${opsz}`;
60
+
61
+ return (
62
+ <Text
63
+ style={{
64
+ fontFamily,
65
+ fontSize,
66
+ color: iconColor,
67
+ userSelect: 'none',
68
+ fontVariationSettings,
69
+ ...style,
70
+ } as TextStyle}
71
+ onPress={onPress}
72
+ >
73
+ {name}
74
+ </Text>
75
+ );
76
+ }
@@ -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 NavbarStyleInput {
6
+ readonly tokens: ResolvedTokens;
7
+ readonly height: number;
8
+ readonly bordered: boolean;
9
+ }
10
+
11
+ export function getNavbarStyles({ tokens, height, bordered }: NavbarStyleInput) {
12
+ const borderColor = srgbToHex(tokens.border.srgb);
13
+
14
+ return StyleSheet.create({
15
+ container: {
16
+ flexDirection: 'row',
17
+ alignItems: 'center',
18
+ height,
19
+ flexShrink: 0,
20
+ paddingHorizontal: 24,
21
+ backgroundColor: srgbToHex(tokens.background.srgb),
22
+ borderBottomWidth: bordered ? 1 : 0,
23
+ borderBottomColor: borderColor,
24
+ },
25
+ left: {
26
+ flex: 1,
27
+ flexDirection: 'row',
28
+ alignItems: 'center',
29
+ },
30
+ right: {
31
+ flex: 1,
32
+ flexDirection: 'row',
33
+ alignItems: 'center',
34
+ justifyContent: 'flex-end',
35
+ },
36
+ });
37
+ }
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import type { NavbarProps } from './Navbar.types';
4
+ import { useTokens } from '../tokens/useTokens';
5
+ import { getNavbarStyles } from './Navbar.styles';
6
+
7
+ export function Navbar({
8
+ children,
9
+ left,
10
+ right,
11
+ height = 56,
12
+ bordered = true,
13
+ }: NavbarProps) {
14
+ const tokens = useTokens();
15
+ const styles = React.useMemo(
16
+ () => getNavbarStyles({ tokens, height, bordered }),
17
+ [tokens, height, bordered]
18
+ );
19
+
20
+ return (
21
+ <View style={styles.container}>
22
+ {children ? (
23
+ children
24
+ ) : (
25
+ <>
26
+ <View style={styles.left}>{left}</View>
27
+ <View style={styles.right}>{right}</View>
28
+ </>
29
+ )}
30
+ </View>
31
+ );
32
+ }
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface NavbarProps {
4
+ /** Full control mode — renders children directly */
5
+ readonly children?: ReactNode;
6
+ /** Left slot (e.g., page title, breadcrumbs) */
7
+ readonly left?: ReactNode;
8
+ /** Right slot (e.g., actions, user menu) */
9
+ readonly right?: ReactNode;
10
+ /** Navbar height in pixels. @default 56 */
11
+ readonly height?: number;
12
+ /** Show bottom border. @default true */
13
+ readonly bordered?: boolean;
14
+ }
@@ -0,0 +1,2 @@
1
+ export { Navbar } from './Navbar';
2
+ export type { NavbarProps } from './Navbar.types';
@@ -0,0 +1,39 @@
1
+ import { StyleSheet } from 'react-native';
2
+ import { srgbToHex } from 'newtone';
3
+ import type { ResolvedTokens } from '../tokens/types';
4
+
5
+ export function getPopoverStyles(
6
+ tokens: ResolvedTokens,
7
+ triggerHeight: number,
8
+ offset: number,
9
+ maxHeight: number,
10
+ width: 'trigger' | 'auto' | number,
11
+ isOpen: boolean
12
+ ) {
13
+ const widthStyle =
14
+ width === 'trigger'
15
+ ? { left: 0 as const, right: 0 as const }
16
+ : typeof width === 'number'
17
+ ? { width, left: 0 as const }
18
+ : { left: 0 as const };
19
+
20
+ return StyleSheet.create({
21
+ container: {
22
+ position: 'relative',
23
+ zIndex: isOpen ? 999 : 1,
24
+ },
25
+ content: {
26
+ position: 'absolute',
27
+ top: triggerHeight + offset,
28
+ ...widthStyle,
29
+ backgroundColor: srgbToHex(tokens.backgroundElevated.srgb),
30
+ borderWidth: 1,
31
+ borderColor: srgbToHex(tokens.border.srgb),
32
+ borderRadius: tokens.radius.md,
33
+ maxHeight,
34
+ zIndex: 1000,
35
+ overflow: 'hidden',
36
+ ...({ boxShadow: '0 4px 12px rgba(0,0,0,0.15)' } as any),
37
+ },
38
+ });
39
+ }
@@ -0,0 +1,103 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { View } from 'react-native';
3
+ import type { PopoverProps } from './Popover.types';
4
+ import { useTokens } from '../tokens/useTokens';
5
+ import { getPopoverStyles } from './Popover.styles';
6
+
7
+ // Module-level: set of close callbacks for all open Popovers.
8
+ // Opening one Popover automatically closes all others.
9
+ const openPopovers = new Set<() => void>();
10
+
11
+ export function Popover({
12
+ isOpen,
13
+ onClose,
14
+ trigger,
15
+ children,
16
+ width = 'trigger',
17
+ maxHeight = 240,
18
+ offset = 4,
19
+ closeOnEscape = true,
20
+ style,
21
+ contentStyle,
22
+ }: PopoverProps) {
23
+ const tokens = useTokens(1);
24
+ const containerRef = useRef<View>(null);
25
+ const [triggerHeight, setTriggerHeight] = useState(0);
26
+
27
+ const onTriggerLayout = useCallback(
28
+ (e: { nativeEvent: { layout: { height: number } } }) => {
29
+ setTriggerHeight(e.nativeEvent.layout.height);
30
+ },
31
+ []
32
+ );
33
+
34
+ // Mutual exclusion: opening this Popover closes all others
35
+ useEffect(() => {
36
+ if (!isOpen) return;
37
+ openPopovers.forEach((closeFn) => closeFn());
38
+ openPopovers.clear();
39
+ openPopovers.add(onClose);
40
+ return () => {
41
+ openPopovers.delete(onClose);
42
+ };
43
+ }, [isOpen, onClose]);
44
+
45
+ // Click-outside: capture phase so RNW Pressable can't swallow the event
46
+ useEffect(() => {
47
+ if (!isOpen) return;
48
+ const handleMouseDown = (e: MouseEvent) => {
49
+ const node = containerRef.current as unknown as HTMLElement;
50
+ if (node && !node.contains(e.target as Node)) {
51
+ onClose();
52
+ }
53
+ };
54
+ document.addEventListener('mousedown', handleMouseDown, true);
55
+ return () => document.removeEventListener('mousedown', handleMouseDown, true);
56
+ }, [isOpen, onClose]);
57
+
58
+ const handleKeyDown = useCallback(
59
+ (e: any) => {
60
+ if (closeOnEscape && e.key === 'Escape') {
61
+ e.stopPropagation();
62
+ onClose();
63
+ }
64
+ },
65
+ [closeOnEscape, onClose]
66
+ );
67
+
68
+ const styles = useMemo(
69
+ () => getPopoverStyles(tokens, triggerHeight, offset, maxHeight, width, isOpen),
70
+ [tokens, triggerHeight, offset, maxHeight, width, isOpen]
71
+ );
72
+
73
+ const containerStyles = useMemo(
74
+ () => [styles.container, ...(Array.isArray(style) ? style : style ? [style] : [])],
75
+ [styles.container, style]
76
+ );
77
+
78
+ const mergedContentStyles = useMemo(
79
+ () => [styles.content, ...(Array.isArray(contentStyle) ? contentStyle : contentStyle ? [contentStyle] : [])],
80
+ [styles.content, contentStyle]
81
+ );
82
+
83
+ // onKeyDown is a web-only prop supported by react-native-web but not in RN types
84
+ const webProps = { onKeyDown: handleKeyDown } as any;
85
+
86
+ return (
87
+ <View
88
+ ref={containerRef}
89
+ style={containerStyles}
90
+ {...webProps}
91
+ >
92
+ <View onLayout={onTriggerLayout}>
93
+ {trigger}
94
+ </View>
95
+
96
+ {isOpen && (
97
+ <View style={mergedContentStyles}>
98
+ {children}
99
+ </View>
100
+ )}
101
+ </View>
102
+ );
103
+ }
@@ -0,0 +1,40 @@
1
+ import type { ViewStyle } from 'react-native';
2
+
3
+ export type PopoverPlacement = 'bottom' | 'top';
4
+
5
+ export interface PopoverProps {
6
+ /** Whether the popover is currently open (controlled) */
7
+ readonly isOpen: boolean;
8
+ /** Callback when popover should close */
9
+ readonly onClose: () => void;
10
+ /** The trigger element — rendered always, popover content appears relative to it */
11
+ readonly trigger: React.ReactNode;
12
+ /** The popover content — rendered only when isOpen is true */
13
+ readonly children: React.ReactNode;
14
+ /** Preferred placement relative to trigger. @default 'bottom' */
15
+ readonly placement?: PopoverPlacement;
16
+ /** Width behavior: 'trigger' matches trigger width, 'auto' uses content width, number is fixed px. @default 'trigger' */
17
+ readonly width?: 'trigger' | 'auto' | number;
18
+ /** Maximum height for content area. @default 240 */
19
+ readonly maxHeight?: number;
20
+ /** Gap between trigger and content in px. @default 4 */
21
+ readonly offset?: number;
22
+ /** Whether pressing Escape closes the popover. @default true */
23
+ readonly closeOnEscape?: boolean;
24
+ /** Container style override */
25
+ readonly style?: ViewStyle | ViewStyle[];
26
+ /** Content container style override */
27
+ readonly contentStyle?: ViewStyle | ViewStyle[];
28
+ }
29
+
30
+ export interface UsePopoverOptions {
31
+ readonly initialOpen?: boolean;
32
+ readonly onOpenChange?: (isOpen: boolean) => void;
33
+ }
34
+
35
+ export interface UsePopoverReturn {
36
+ readonly isOpen: boolean;
37
+ readonly open: () => void;
38
+ readonly close: () => void;
39
+ readonly toggle: () => void;
40
+ }
@@ -0,0 +1,3 @@
1
+ export { Popover } from './Popover';
2
+ export type { PopoverProps, PopoverPlacement, UsePopoverOptions, UsePopoverReturn } from './Popover.types';
3
+ export { usePopover } from './usePopover';
@@ -0,0 +1,26 @@
1
+ import { useCallback, useState } from 'react';
2
+ import type { UsePopoverOptions, UsePopoverReturn } from './Popover.types';
3
+
4
+ export function usePopover(options?: UsePopoverOptions): UsePopoverReturn {
5
+ const [isOpen, setIsOpen] = useState(options?.initialOpen ?? false);
6
+
7
+ const open = useCallback(() => {
8
+ setIsOpen(true);
9
+ options?.onOpenChange?.(true);
10
+ }, [options]);
11
+
12
+ const close = useCallback(() => {
13
+ setIsOpen(false);
14
+ options?.onOpenChange?.(false);
15
+ }, [options]);
16
+
17
+ const toggle = useCallback(() => {
18
+ setIsOpen((prev) => {
19
+ const next = !prev;
20
+ options?.onOpenChange?.(next);
21
+ return next;
22
+ });
23
+ }, [options]);
24
+
25
+ return { isOpen, open, close, toggle };
26
+ }