@medway-ui/native 1.8.1 → 1.9.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.
Files changed (35) hide show
  1. package/README.md +18 -9
  2. package/lib/commonjs/components/Button.js +1 -1
  3. package/lib/commonjs/components/Button.js.map +1 -1
  4. package/lib/commonjs/components/Input.js +128 -29
  5. package/lib/commonjs/components/Input.js.map +1 -1
  6. package/lib/commonjs/components/Switch.js +5 -2
  7. package/lib/commonjs/components/Switch.js.map +1 -1
  8. package/lib/commonjs/editor/editorHtml.generated.js +3 -3
  9. package/lib/commonjs/editor/index.js.map +1 -1
  10. package/lib/module/components/Button.js +1 -1
  11. package/lib/module/components/Button.js.map +1 -1
  12. package/lib/module/components/Input.js +130 -31
  13. package/lib/module/components/Input.js.map +1 -1
  14. package/lib/module/components/Switch.js +5 -2
  15. package/lib/module/components/Switch.js.map +1 -1
  16. package/lib/module/editor/editorHtml.generated.js +3 -3
  17. package/lib/module/editor/index.js.map +1 -1
  18. package/lib/typescript/components/Input.d.ts +5 -1
  19. package/lib/typescript/components/Input.d.ts.map +1 -1
  20. package/lib/typescript/components/Switch.d.ts +3 -1
  21. package/lib/typescript/components/Switch.d.ts.map +1 -1
  22. package/lib/typescript/editor/editorHtml.generated.d.ts +3 -3
  23. package/lib/typescript/editor/index.d.ts +2 -1
  24. package/lib/typescript/editor/index.d.ts.map +1 -1
  25. package/package.json +11 -23
  26. package/setup/MedwayUiProvider.tsx +30 -8
  27. package/setup/babel-brownfield.js +14 -34
  28. package/setup/metro.js +6 -67
  29. package/setup/monorepo.js +102 -0
  30. package/setup/tailwind.js +5 -6
  31. package/src/components/Button.tsx +1 -1
  32. package/src/components/Input.tsx +147 -26
  33. package/src/components/Switch.tsx +6 -6
  34. package/src/editor/editorHtml.generated.ts +3 -3
  35. package/src/editor/index.ts +2 -4
@@ -2,6 +2,7 @@ import {
2
2
  useState,
3
3
  useCallback,
4
4
  useEffect,
5
+ useRef,
5
6
  cloneElement,
6
7
  type ReactElement,
7
8
  type ReactNode,
@@ -11,6 +12,8 @@ import {
11
12
  View,
12
13
  TextInput,
13
14
  Pressable,
15
+ StyleSheet,
16
+ type LayoutChangeEvent,
14
17
  type TextInputProps,
15
18
  type ViewProps,
16
19
  } from "react-native";
@@ -20,6 +23,7 @@ import Animated, {
20
23
  withTiming,
21
24
  interpolate,
22
25
  Easing,
26
+ type SharedValue,
23
27
  } from "react-native-reanimated";
24
28
  import { cva, type VariantProps } from "class-variance-authority";
25
29
  import {
@@ -80,6 +84,10 @@ export interface InputLabelProps {
80
84
  children?: ReactNode;
81
85
  floated?: boolean;
82
86
  disabled?: boolean;
87
+ invalid?: boolean;
88
+ size?: VariantProps<typeof inputVariants>["size"];
89
+ hasStartIcon?: boolean;
90
+ hasEndIcon?: boolean;
83
91
  }
84
92
 
85
93
  export interface InputTextProps extends TextComponentProps {
@@ -92,6 +100,10 @@ const INPUT_ICON_STROKE_WIDTH = 1.6;
92
100
  const CHEVRON_SIZE_LG = 10;
93
101
  const CHEVRON_SIZE_MD = 8;
94
102
 
103
+ const LABELED_INPUT_HEIGHT_CLASS = { lg: "h-14", md: "h-11" } as const;
104
+ const LABEL_FONT_SIZE = { lg: 16, md: 14 } as const;
105
+ const FLOATED_LABEL_FONT_SIZE = 12;
106
+
95
107
  const inputVariants = cva(
96
108
  "w-full rounded-md border-2 border-gray-stroke-muted bg-surface-card px-4 text-gray-foreground",
97
109
  {
@@ -130,42 +142,112 @@ function InputField({ className, ...props }: ViewProps) {
130
142
  return <View className={cn("relative", className)} {...props} />;
131
143
  }
132
144
 
133
- function FloatingLabel({ children, floated = false, disabled = false }: InputLabelProps) {
145
+ // `left-4`/`right-4` and `left-12`/`right-12` in FloatingLabel, in rem.
146
+ const LABEL_INSET_REM = 1;
147
+ const LABEL_ICON_INSET_REM = 3;
148
+
149
+ // Reanimated views drop `className` (see Skeleton), so the classes stay on plain views.
150
+ function FloatingLabelBackground({ progress }: { progress: SharedValue<number> }) {
151
+ const style = useAnimatedStyle(() => ({ opacity: progress.value }));
152
+
153
+ return (
154
+ <Animated.View style={[StyleSheet.absoluteFill, style]}>
155
+ <View className="absolute -inset-x-1 inset-y-0 rounded-sm bg-surface-card" />
156
+ </Animated.View>
157
+ );
158
+ }
159
+
160
+ function FloatingLabel({
161
+ children,
162
+ floated = false,
163
+ disabled = false,
164
+ invalid = false,
165
+ size,
166
+ hasStartIcon = false,
167
+ hasEndIcon = false,
168
+ }: InputLabelProps) {
169
+ const resolvedSize = size ?? "lg";
134
170
  const progress = useSharedValue(floated ? 1 : 0);
171
+ const metrics = useSharedValue({ restWidth: 0, floatedWidth: 0, floatedX: 0, floatedY: 0 });
172
+ const lastFloated = useRef(floated);
173
+ const floatedScale = FLOATED_LABEL_FONT_SIZE / LABEL_FONT_SIZE[resolvedSize];
135
174
 
136
175
  useEffect(() => {
176
+ if (lastFloated.current === floated) return;
177
+ lastFloated.current = floated;
137
178
  progress.value = withTiming(floated ? 1 : 0, { duration: 200, easing: Easing.out(Easing.ease) });
138
- }, [floated]);
179
+ }, [floated, progress]);
180
+
181
+ const handleLayout = (event: LayoutChangeEvent) => {
182
+ const { x, width, height } = event.nativeEvent.layout;
183
+ const rem = x / (hasStartIcon ? LABEL_ICON_INSET_REM : LABEL_INSET_REM);
184
+ const inset = rem * LABEL_INSET_REM;
185
+ const fieldWidth = x + width + rem * (hasEndIcon ? LABEL_ICON_INSET_REM : LABEL_INSET_REM);
186
+ metrics.value = {
187
+ restWidth: width,
188
+ floatedWidth: fieldWidth - inset * 2,
189
+ floatedX: inset - x,
190
+ floatedY: -height / 2,
191
+ };
192
+ };
139
193
 
140
- const animatedStyle = useAnimatedStyle(() => ({
141
- transform: [{ translateY: interpolate(progress.value, [0, 1], [-6, -22]) }],
142
- fontSize: interpolate(progress.value, [0, 1], [15, 12]),
143
- }));
194
+ // Older React Native ignores `transformOrigin`, so it scales around the center
195
+ // and `translateX` makes up for the width lost on the left.
196
+ const labelStyle = useAnimatedStyle(() => {
197
+ const { restWidth, floatedWidth, floatedX, floatedY } = metrics.value;
198
+ const scale = interpolate(progress.value, [0, 1], [1, floatedScale]);
199
+ const width = interpolate(progress.value, [0, 1], [restWidth, floatedWidth / floatedScale]);
200
+
201
+ return {
202
+ opacity: restWidth > 0 ? 1 : 0,
203
+ width,
204
+ transform: [
205
+ { translateX: interpolate(progress.value, [0, 1], [0, floatedX]) - (width * (1 - scale)) / 2 },
206
+ { translateY: interpolate(progress.value, [0, 1], [0, floatedY]) },
207
+ { scale },
208
+ ],
209
+ };
210
+ });
144
211
 
145
212
  return (
146
- <Animated.Text
147
- style={[
148
- animatedStyle,
149
- {
150
- position: "absolute",
151
- left: 16,
152
- top: "50%",
153
- zIndex: 10,
154
- fontFamily: fontFamilyMedium,
155
- },
156
- ]}
213
+ <View
214
+ pointerEvents="none"
215
+ importantForAccessibility="no-hide-descendants"
216
+ accessibilityElementsHidden
217
+ onLayout={handleLayout}
157
218
  className={cn(
158
- "font-medium",
159
- disabled ? "text-gray-foreground-muted" : "text-gray-foreground"
219
+ "absolute inset-y-0 z-10",
220
+ hasStartIcon ? "left-12" : "left-4",
221
+ hasEndIcon ? "right-12" : "right-4"
160
222
  )}
161
223
  >
162
- {children}
163
- </Animated.Text>
224
+ <Animated.View
225
+ style={[{ flex: 1, justifyContent: "center", alignItems: "flex-start" }, labelStyle]}
226
+ >
227
+ <View className="max-w-full">
228
+ {!disabled && <FloatingLabelBackground progress={progress} />}
229
+ <Text
230
+ numberOfLines={1}
231
+ className={cn(
232
+ floated ? "font-medium" : "font-normal",
233
+ resolvedSize === "md" ? "text-sm" : "text-md",
234
+ disabled
235
+ ? "text-gray-foreground-muted"
236
+ : invalid
237
+ ? "text-feedback-error-foreground"
238
+ : "text-gray-foreground"
239
+ )}
240
+ >
241
+ {children}
242
+ </Text>
243
+ </View>
244
+ </Animated.View>
245
+ </View>
164
246
  );
165
247
  }
166
248
 
167
- function InputLabel({ children, floated = false, disabled = false }: InputLabelProps) {
168
- return <FloatingLabel floated={floated} disabled={disabled}>{children}</FloatingLabel>;
249
+ function InputLabel(props: InputLabelProps) {
250
+ return <FloatingLabel {...props} />;
169
251
  }
170
252
 
171
253
  function InputText({ invalid = false, className, ...props }: InputTextProps) {
@@ -265,11 +347,23 @@ function InputPassword({
265
347
  )}
266
348
  </InputStartIcon>
267
349
  <View className="relative flex-1">
268
- {label && <InputLabel floated={isFloated} disabled={props.editable === false}>{label}</InputLabel>}
350
+ {label && (
351
+ <InputLabel
352
+ floated={isFloated}
353
+ disabled={props.editable === false}
354
+ invalid={invalid}
355
+ size={size}
356
+ hasStartIcon
357
+ hasEndIcon
358
+ >
359
+ {label}
360
+ </InputLabel>
361
+ )}
269
362
  <TextInput
270
363
  secureTextEntry={!visible}
271
364
  className={cn(
272
365
  inputVariants({ size, invalid, focused, disabled: props.editable === false }),
366
+ label ? LABELED_INPUT_HEIGHT_CLASS[size ?? "lg"] : "",
273
367
  "pl-12 pr-12 font-medium",
274
368
  className
275
369
  )}
@@ -277,6 +371,7 @@ function InputPassword({
277
371
  defaultValue={defaultValue}
278
372
  accessibilityLabel={label}
279
373
  {...props}
374
+ placeholderTextColor={label ? "transparent" : props.placeholderTextColor}
280
375
  onFocus={handleFocus}
281
376
  onBlur={handleBlur}
282
377
  onChangeText={handleChangeText}
@@ -348,7 +443,18 @@ function InputNumeric({
348
443
  </InputStartIcon>
349
444
  )}
350
445
  <View className="relative flex-1">
351
- {label && <InputLabel floated={isFloated} disabled={!editable}>{label}</InputLabel>}
446
+ {label && (
447
+ <InputLabel
448
+ floated={isFloated}
449
+ disabled={!editable}
450
+ invalid={invalid}
451
+ size={size}
452
+ hasStartIcon={!!iconLeft}
453
+ hasEndIcon
454
+ >
455
+ {label}
456
+ </InputLabel>
457
+ )}
352
458
  <TextInput
353
459
  keyboardType={allowNegative ? "numbers-and-punctuation" : "numeric"}
354
460
  value={displayValue}
@@ -362,12 +468,14 @@ function InputNumeric({
362
468
  editable={editable}
363
469
  className={cn(
364
470
  inputVariants({ size, invalid, focused, disabled: !editable }),
471
+ label ? LABELED_INPUT_HEIGHT_CLASS[size ?? "lg"] : "",
365
472
  iconLeft ? "pl-12" : "",
366
473
  "pr-12 font-medium",
367
474
  className
368
475
  )}
369
476
  accessibilityLabel={label}
370
477
  {...props}
478
+ placeholderTextColor={label ? "transparent" : props.placeholderTextColor}
371
479
  onFocus={handleFocus}
372
480
  onBlur={handleBlur}
373
481
  style={[{ fontFamily: fontFamilyMedium }, props.style]}
@@ -459,7 +567,18 @@ function InputTextual({
459
567
  </InputStartIcon>
460
568
  )}
461
569
  <View className="relative flex-1">
462
- {label && <InputLabel floated={isFloated} disabled={!editable}>{label}</InputLabel>}
570
+ {label && (
571
+ <InputLabel
572
+ floated={isFloated}
573
+ disabled={!editable}
574
+ invalid={invalid}
575
+ size={size}
576
+ hasStartIcon={!!resolvedIconLeft}
577
+ hasEndIcon={!!resolvedIconRight}
578
+ >
579
+ {label}
580
+ </InputLabel>
581
+ )}
463
582
  <TextInput
464
583
  keyboardType={type === "email" ? "email-address" : "default"}
465
584
  value={value}
@@ -467,6 +586,7 @@ function InputTextual({
467
586
  editable={editable}
468
587
  className={cn(
469
588
  inputVariants({ size, invalid, focused, disabled: !editable }),
589
+ label ? LABELED_INPUT_HEIGHT_CLASS[size ?? "lg"] : "",
470
590
  resolvedIconLeft ? "pl-12" : "",
471
591
  resolvedIconRight ? "pr-12" : "",
472
592
  "font-medium",
@@ -474,6 +594,7 @@ function InputTextual({
474
594
  )}
475
595
  accessibilityLabel={label}
476
596
  {...props}
597
+ placeholderTextColor={label ? "transparent" : props.placeholderTextColor}
477
598
  onFocus={handleFocus}
478
599
  onBlur={handleBlur}
479
600
  onChangeText={handleChangeText}
@@ -9,7 +9,7 @@ import {
9
9
  type ReactElement,
10
10
  type ReactNode,
11
11
  } from "react";
12
- import { StyleSheet, type ViewStyle } from "react-native";
12
+ import { StyleSheet, type StyleProp, type ViewStyle } from "react-native";
13
13
  import { cssInterop } from "nativewind";
14
14
  import * as SwitchPrimitive from "@rn-primitives/switch";
15
15
  import Animated, {
@@ -115,9 +115,10 @@ const switchVariants = cva("shrink-0", {
115
115
  interface SwitchProps
116
116
  extends Omit<
117
117
  SwitchPrimitive.RootProps,
118
- "checked" | "onCheckedChange" | "children"
118
+ "checked" | "onCheckedChange" | "children" | "style"
119
119
  >,
120
120
  VariantProps<typeof switchVariants> {
121
+ style?: StyleProp<ViewStyle>;
121
122
  checked?: boolean;
122
123
  defaultChecked?: boolean;
123
124
  onCheckedChange?: (checked: boolean) => void;
@@ -333,10 +334,9 @@ const Switch = memo(function Switch({
333
334
  switchVariants({ size: resolvedSize, variant }),
334
335
  className
335
336
  )}
336
- style={(state) => [
337
- baseRootStyle,
338
- typeof style === "function" ? style(state) : style,
339
- ]}
337
+ // Not a function: with a `className`, NativeWind spreads `style` into an
338
+ // object, and a function spreads to nothing.
339
+ style={[baseRootStyle, style]}
340
340
  >
341
341
  <Animated.View
342
342
  pointerEvents="none"