@gryt/ui-native 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +9 -1
  2. package/dist/components/AlertDialog/AlertDialog.d.ts.map +1 -1
  3. package/dist/components/Checkbox/Checkbox.d.ts +7 -1
  4. package/dist/components/Checkbox/Checkbox.d.ts.map +1 -1
  5. package/dist/components/Checkbox/Checkbox.js +47 -15
  6. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  7. package/dist/components/Dialog/Dialog.js +10 -1
  8. package/dist/components/Drawer/Drawer.d.ts +0 -7
  9. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  10. package/dist/components/Drawer/Drawer.js +229 -30
  11. package/dist/components/Progress/Progress.d.ts +11 -17
  12. package/dist/components/Progress/Progress.d.ts.map +1 -1
  13. package/dist/components/Progress/Progress.js +66 -22
  14. package/dist/components/Radio/Radio.d.ts +9 -1
  15. package/dist/components/Radio/Radio.d.ts.map +1 -1
  16. package/dist/components/Radio/Radio.js +38 -14
  17. package/dist/components/ScrollArea/ScrollArea.d.ts +6 -4
  18. package/dist/components/ScrollArea/ScrollArea.d.ts.map +1 -1
  19. package/dist/components/ScrollArea/ScrollArea.js +19 -4
  20. package/dist/components/Sheet/Sheet.d.ts +99 -0
  21. package/dist/components/Sheet/Sheet.d.ts.map +1 -0
  22. package/dist/components/Sheet/Sheet.js +201 -0
  23. package/dist/components/Slider/Slider.d.ts.map +1 -1
  24. package/dist/components/Slider/Slider.js +33 -0
  25. package/dist/components/Switch/Switch.d.ts +4 -1
  26. package/dist/components/Switch/Switch.d.ts.map +1 -1
  27. package/dist/components/Switch/Switch.js +21 -17
  28. package/dist/components/Tabs/Tabs.d.ts +38 -13
  29. package/dist/components/Tabs/Tabs.d.ts.map +1 -1
  30. package/dist/components/Tabs/Tabs.js +157 -42
  31. package/dist/components/Toggle/Toggle.d.ts +0 -1
  32. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  33. package/dist/components/Toggle/Toggle.js +15 -1
  34. package/dist/components/internal/ControlRow.d.ts +54 -0
  35. package/dist/components/internal/ControlRow.d.ts.map +1 -0
  36. package/dist/components/internal/ControlRow.js +59 -0
  37. package/dist/components/internal/dragLock.d.ts +43 -0
  38. package/dist/components/internal/dragLock.d.ts.map +1 -0
  39. package/dist/components/internal/dragLock.js +18 -0
  40. package/dist/index.d.ts +2 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +2 -0
  43. package/dist/motion/motion.d.ts +13 -3
  44. package/dist/motion/motion.d.ts.map +1 -1
  45. package/dist/motion/motion.js +6 -6
  46. package/package.json +11 -5
@@ -1,30 +1,62 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { View } from "react-native";
2
+ import { useEffect, useState } from "react";
3
+ import { View, } from "react-native";
4
+ import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from "react-native-reanimated";
5
+ import { grytDurations } from "@gryt/theme";
6
+ import { useReducedMotion } from "../../hooks/useReducedMotion.js";
3
7
  import { useTheme } from "../../theme/index.js";
8
+ /** How much of the track the sweeping bar covers. Matches the web's `width: 40%`. */
9
+ const SWEEP_FRACTION = 0.4;
4
10
  /**
5
- * Determinate only and so is the web, which is not what this used to say.
11
+ * Determinate travel follows the web deliberately: 300ms on ease-out rather
12
+ * than the spring. A progress bar is a reading, and the spring's 12% overshoot
13
+ * on a jump to 100% runs past the end of the track and comes back — which reads
14
+ * as the job finishing, unfinishing, then finishing.
6
15
  *
7
- * The previous note here claimed the web's indeterminate state was a CSS
8
- * keyframe sweeping a bar across the track, and listed this as a parity
9
- * exception. It is not one. `@gryt/ui`'s Progress passes `value={null}` to
10
- * Base UI and styles nothing for it: there is no `@keyframes` anywhere in the
11
- * package, no rule for `data-indeterminate`, and the compiled stylesheet has
12
- * zero rules matching `gryt-progress`. An indeterminate Progress renders an
13
- * empty track on the web too.
14
- *
15
- * So both platforms agree, and both are missing the feature. Implementing it
16
- * belongs on both at once — GRYT-382 — rather than here, where it would make
17
- * React Native the one that behaves differently.
18
- *
19
- * The travel that *is* implemented follows the web deliberately: 300ms on
20
- * ease-out rather than the spring. A progress bar is a reading, and the
21
- * spring's 12% overshoot on a jump to 100% runs past the end of the track and
22
- * comes back — which reads as the job finishing, unfinishing, then finishing.
16
+ * Indeterminate is a partial bar sweeping the track on a loop, at
17
+ * `grytDurations.sweep`, from the same description the web's keyframe follows.
18
+ * Both were empty until GRYT-382: the web passed `value={null}` to Base UI,
19
+ * which leaves the indicator's width unset, and nothing styled it. This file
20
+ * previously called that a parity exception, which it was not — both platforms
21
+ * were missing the same feature, and writing it down as one-sided is what
22
+ * closed the question for a while.
23
23
  */
24
24
  export function Progress({ value, tone = "accent", style }) {
25
25
  const theme = useTheme();
26
- const clamped = value === undefined ? 0 : Math.max(0, Math.min(100, value));
27
- return (_jsx(View, { accessibilityRole: "progressbar", accessibilityValue: value === undefined ? undefined : { now: clamped, min: 0, max: 100 }, style: [
26
+ const reducedMotion = useReducedMotion();
27
+ const indeterminate = value === undefined;
28
+ const clamped = indeterminate ? 0 : Math.max(0, Math.min(100, value));
29
+ // Measured rather than expressed as a percentage. The web animates
30
+ // `translateX` in percentages of the bar's own width, which React Native does
31
+ // not accept in a transform, so the same travel is computed in points from
32
+ // the track's own layout.
33
+ const [trackWidth, setTrackWidth] = useState(0);
34
+ const offset = useSharedValue(0);
35
+ const barWidth = trackWidth * SWEEP_FRACTION;
36
+ useEffect(() => {
37
+ if (!indeterminate || reducedMotion || trackWidth === 0)
38
+ return;
39
+ // Starts fully off the left edge and ends fully off the right. Leaving the
40
+ // track completely at both ends is what makes the loop seam invisible — a
41
+ // bar that restarts while still on screen flicks backwards once per pass.
42
+ // eslint-disable-next-line react-hooks/immutability
43
+ offset.value = -barWidth;
44
+ // eslint-disable-next-line react-hooks/immutability
45
+ offset.value = withRepeat(withTiming(trackWidth, {
46
+ duration: grytDurations.sweep,
47
+ easing: Easing.inOut(Easing.ease),
48
+ }), -1, false);
49
+ }, [indeterminate, reducedMotion, trackWidth, barWidth, offset]);
50
+ const sweepStyle = useAnimatedStyle(() => ({
51
+ transform: [{ translateX: offset.value }],
52
+ }));
53
+ const onLayout = (e) => {
54
+ const next = e.nativeEvent.layout.width;
55
+ // Guarded because onLayout fires on every re-render in some trees, and
56
+ // setting state unconditionally from it is an infinite loop.
57
+ setTrackWidth((prev) => (prev === next ? prev : next));
58
+ };
59
+ return (_jsx(View, { accessibilityRole: "progressbar", accessibilityValue: indeterminate ? undefined : { now: clamped, min: 0, max: 100 }, onLayout: indeterminate ? onLayout : undefined, style: [
28
60
  {
29
61
  height: 6,
30
62
  borderRadius: theme.radius.full,
@@ -32,10 +64,22 @@ export function Progress({ value, tone = "accent", style }) {
32
64
  overflow: "hidden",
33
65
  },
34
66
  style,
35
- ], children: _jsx(View, { style: {
67
+ ], children: indeterminate ? (_jsx(Animated.View, { style: [
68
+ {
69
+ // Full width and dimmed under reduce-motion rather than a frozen
70
+ // partial bar, which reads as a job that stalled 40% in. Matches
71
+ // what the web's `prefers-reduced-motion` rule does.
72
+ width: reducedMotion ? "100%" : barWidth,
73
+ opacity: reducedMotion ? 0.4 : 1,
74
+ height: "100%",
75
+ borderRadius: theme.radius.full,
76
+ backgroundColor: theme.scales[tone][8],
77
+ },
78
+ reducedMotion ? null : sweepStyle,
79
+ ] })) : (_jsx(View, { style: {
36
80
  width: `${clamped}%`,
37
81
  height: "100%",
38
82
  borderRadius: theme.radius.full,
39
83
  backgroundColor: theme.scales[tone][8],
40
- } }) }));
84
+ } })) }));
41
85
  }
@@ -21,8 +21,16 @@ export interface RadioProps {
21
21
  value: string | number;
22
22
  disabled?: boolean;
23
23
  tone?: RadioTone;
24
+ /**
25
+ * Tapping this selects the radio, which is what a `<label>` does on the web.
26
+ *
27
+ * Radios need it more than anything else here: they come in stacks, so a
28
+ * 20pt target missed by a few points does not do nothing — it selects the
29
+ * neighbour, which is worse than no response at all.
30
+ */
31
+ label?: ReactNode;
24
32
  style?: StyleProp<ViewStyle>;
25
33
  accessibilityLabel?: string;
26
34
  }
27
- export declare function Radio({ value, disabled, tone, style, accessibilityLabel, }: RadioProps): import("react").JSX.Element;
35
+ export declare function Radio({ value, disabled, tone, label, style, accessibilityLabel, }: RadioProps): import("react").JSX.Element;
28
36
  //# sourceMappingURL=Radio.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Radio.d.ts","sourceRoot":"","sources":["../../../src/components/Radio/Radio.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC5E,OAAO,EAAmB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAI/E,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC;AAUvE,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,EACzB,KAAK,EAAE,UAAU,EACjB,YAAY,EACZ,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,KAAK,GACN,EAAE,eAAe,+BAsBjB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAID,wBAAgB,KAAK,CAAC,EACpB,KAAK,EACL,QAAQ,EACR,IAAgB,EAChB,KAAK,EACL,kBAAkB,GACnB,EAAE,UAAU,+BA0CZ"}
1
+ {"version":3,"file":"Radio.d.ts","sourceRoot":"","sources":["../../../src/components/Radio/Radio.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAkD,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACvF,OAAO,EAAQ,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AASpE,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC;AAUvE,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,EACzB,KAAK,EAAE,UAAU,EACjB,YAAY,EACZ,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,KAAK,GACN,EAAE,eAAe,+BAsBjB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAID,wBAAgB,KAAK,CAAC,EACpB,KAAK,EACL,QAAQ,EACR,IAAgB,EAChB,KAAK,EACL,KAAK,EACL,kBAAkB,GACnB,EAAE,UAAU,+BAoEZ"}
@@ -1,7 +1,12 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext, useState } from "react";
3
- import { Pressable, View } from "react-native";
2
+ import { createContext, useContext, useEffect, useState } from "react";
3
+ import { View } from "react-native";
4
+ import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
5
+ import { grytScaleSteps } from "@gryt/theme";
6
+ import { useReducedMotion } from "../../hooks/useReducedMotion.js";
7
+ import { springy } from "../../motion/index.js";
4
8
  import { toneRamp, useTheme } from "../../theme/index.js";
9
+ import { ControlRow } from "../internal/ControlRow.js";
5
10
  const RadioGroupContext = createContext(null);
6
11
  /**
7
12
  * The group holds the value, which is the part a radio cannot do alone.
@@ -24,30 +29,49 @@ export function RadioGroup({ value: controlled, defaultValue, onValueChange, dis
24
29
  }, children: _jsx(View, { accessibilityRole: "radiogroup", style: style, children: children }) }));
25
30
  }
26
31
  const SIZE = 20;
27
- export function Radio({ value, disabled, tone = "primary", style, accessibilityLabel, }) {
32
+ export function Radio({ value, disabled, tone = "primary", label, style, accessibilityLabel, }) {
28
33
  const theme = useTheme();
34
+ const reducedMotion = useReducedMotion();
29
35
  const group = useContext(RadioGroupContext);
30
36
  if (!group)
31
37
  throw new Error("Radio must be rendered inside a RadioGroup.");
32
38
  const selected = group.value === value;
33
39
  const isDisabled = disabled || group.disabled;
34
40
  const ramp = toneRamp(theme, tone);
35
- return (_jsx(Pressable, { accessibilityRole: "radio", accessibilityState: { checked: selected, disabled: !!isDisabled }, accessibilityLabel: accessibilityLabel, disabled: isDisabled, onPress: () => group.setValue(value), style: [
36
- {
41
+ // Scales from 0 for the same reason the checkbox tick does see the comment
42
+ // there. The dot used to be mounted and unmounted, which is that transition
43
+ // with the duration set to nothing.
44
+ const dot = useSharedValue(selected ? 1 : 0);
45
+ useEffect(() => {
46
+ const to = selected ? 1 : 0;
47
+ // eslint-disable-next-line react-hooks/immutability
48
+ dot.value = reducedMotion ? to : springy(to);
49
+ }, [selected, dot, reducedMotion]);
50
+ const dotStyle = useAnimatedStyle(() => ({
51
+ transform: [{ scale: dot.value }],
52
+ opacity: dot.value,
53
+ }));
54
+ return (_jsx(ControlRow, { label: label, onPress: () => group.setValue(value), disabled: isDisabled, pressScale: grytScaleSteps.radio.press, accessibilityRole: "radio", accessibilityState: { checked: selected, disabled: !!isDisabled }, accessibilityLabel: accessibilityLabel, style: style, children: _jsx(View, { style: {
37
55
  width: SIZE,
38
56
  height: SIZE,
39
57
  borderRadius: SIZE / 2,
40
58
  alignItems: "center",
41
59
  justifyContent: "center",
42
- borderWidth: 1.5,
60
+ // 1, matching the web. It was 1.5, which reads heavier than the
61
+ // checkbox sitting next to it in the same form.
62
+ borderWidth: 1,
43
63
  borderColor: selected ? ramp[8] : theme.color.border,
64
+ // The web fills the circle and outlines it; only the dot is new when
65
+ // it is selected.
66
+ backgroundColor: theme.color.surfaceRaised,
44
67
  opacity: isDisabled ? 0.5 : 1,
45
- },
46
- style,
47
- ], children: selected ? (_jsx(View, { style: {
48
- width: SIZE / 2,
49
- height: SIZE / 2,
50
- borderRadius: SIZE / 4,
51
- backgroundColor: ramp[8],
52
- } })) : null }));
68
+ }, children: _jsx(Animated.View, { style: [
69
+ {
70
+ width: SIZE / 2,
71
+ height: SIZE / 2,
72
+ borderRadius: SIZE / 4,
73
+ backgroundColor: ramp[8],
74
+ },
75
+ dotStyle,
76
+ ] }) }) }));
53
77
  }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import { type ReactNode } from "react";
2
2
  import { type ScrollViewProps, type StyleProp, type ViewStyle } from "react-native";
3
3
  export interface ScrollAreaProps extends Omit<ScrollViewProps, "style"> {
4
4
  children?: ReactNode;
@@ -15,8 +15,10 @@ export interface ScrollAreaProps extends Omit<ScrollViewProps, "style"> {
15
15
  * device. So the component stays for call-site parity and does almost nothing,
16
16
  * which is the honest version.
17
17
  *
18
- * Its one job is defaulting the indicator off, since the web version hides the
19
- * native scrollbar too.
18
+ * Its one job was defaulting the indicator off, since the web version hides the
19
+ * native scrollbar too. It has a second now: it publishes a drag lock, so a
20
+ * control inside it can hold it still while it is being dragged. See
21
+ * internal/dragLock — a Slider is unusable in a scrolling screen without it.
20
22
  */
21
- export declare function ScrollArea({ children, horizontal, style, contentStyle, showsVerticalScrollIndicator, showsHorizontalScrollIndicator, ...rest }: ScrollAreaProps): import("react").JSX.Element;
23
+ export declare function ScrollArea({ children, horizontal, style, contentStyle, showsVerticalScrollIndicator, showsHorizontalScrollIndicator, scrollEnabled, ...rest }: ScrollAreaProps): import("react").JSX.Element;
22
24
  //# sourceMappingURL=ScrollArea.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ScrollArea.d.ts","sourceRoot":"","sources":["../../../src/components/ScrollArea/ScrollArea.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,EAAc,KAAK,eAAe,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAEhG,MAAM,WAAW,eAAgB,SAAQ,IAAI,CAAC,eAAe,EAAE,OAAO,CAAC;IACrE,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,YAAY,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CACrC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,UAAkB,EAClB,KAAK,EACL,YAAY,EACZ,4BAAoC,EACpC,8BAAsC,EACtC,GAAG,IAAI,EACR,EAAE,eAAe,+BAajB"}
1
+ {"version":3,"file":"ScrollArea.d.ts","sourceRoot":"","sources":["../../../src/components/ScrollArea/ScrollArea.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACvE,OAAO,EAAc,KAAK,eAAe,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAIhG,MAAM,WAAW,eAAgB,SAAQ,IAAI,CAAC,eAAe,EAAE,OAAO,CAAC;IACrE,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,YAAY,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CACrC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,UAAkB,EAClB,KAAK,EACL,YAAY,EACZ,4BAAoC,EACpC,8BAAsC,EACtC,aAAa,EACb,GAAG,IAAI,EACR,EAAE,eAAe,+BAgCjB"}
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useMemo, useState } from "react";
2
3
  import { ScrollView } from "react-native";
4
+ import { DragLockContext } from "../internal/dragLock.js";
3
5
  /**
4
6
  * A thin pass over ScrollView.
5
7
  *
@@ -9,9 +11,22 @@ import { ScrollView } from "react-native";
9
11
  * device. So the component stays for call-site parity and does almost nothing,
10
12
  * which is the honest version.
11
13
  *
12
- * Its one job is defaulting the indicator off, since the web version hides the
13
- * native scrollbar too.
14
+ * Its one job was defaulting the indicator off, since the web version hides the
15
+ * native scrollbar too. It has a second now: it publishes a drag lock, so a
16
+ * control inside it can hold it still while it is being dragged. See
17
+ * internal/dragLock — a Slider is unusable in a scrolling screen without it.
14
18
  */
15
- export function ScrollArea({ children, horizontal = false, style, contentStyle, showsVerticalScrollIndicator = false, showsHorizontalScrollIndicator = false, ...rest }) {
16
- return (_jsx(ScrollView, { horizontal: horizontal, style: style, contentContainerStyle: contentStyle, showsVerticalScrollIndicator: showsVerticalScrollIndicator, showsHorizontalScrollIndicator: showsHorizontalScrollIndicator, ...rest, children: children }));
19
+ export function ScrollArea({ children, horizontal = false, style, contentStyle, showsVerticalScrollIndicator = false, showsHorizontalScrollIndicator = false, scrollEnabled, ...rest }) {
20
+ const [locks, setLocks] = useState(0);
21
+ // Counted rather than a boolean. Two controls can overlap in principle — a
22
+ // drag interrupted by a second one starting — and the first to finish would
23
+ // otherwise unlock the list while the second is still going.
24
+ const lock = useCallback(() => setLocks((n) => n + 1), []);
25
+ const unlock = useCallback(() => setLocks((n) => Math.max(0, n - 1)), []);
26
+ const dragLock = useMemo(() => ({ locked: locks > 0, lock, unlock }), [locks, lock, unlock]);
27
+ return (_jsx(DragLockContext.Provider, { value: dragLock, children: _jsx(ScrollView, { horizontal: horizontal, style: style, contentContainerStyle: contentStyle, showsVerticalScrollIndicator: showsVerticalScrollIndicator, showsHorizontalScrollIndicator: showsHorizontalScrollIndicator,
28
+ // Only overridden while something is actually dragging, so a caller
29
+ // that passes `scrollEnabled={false}` still gets a list that never
30
+ // scrolls.
31
+ scrollEnabled: scrollEnabled === false ? false : locks === 0, ...rest, children: children }) }));
17
32
  }
@@ -0,0 +1,99 @@
1
+ import { type ReactNode } from "react";
2
+ import { type StyleProp, type ViewStyle } from "react-native";
3
+ import { type OpenStateProps } from "../../overlay/useOpenState.js";
4
+ /**
5
+ * A bottom sheet, which is the phone's modal.
6
+ *
7
+ * `@gryt/ui` has no counterpart, and that is deliberate rather than an
8
+ * oversight to be corrected later. A sheet is what a phone does where the web
9
+ * opens a dialog or slides a drawer, and the two are not the same interaction:
10
+ * a sheet is dragged, it settles at heights the user chooses, and dismissing it
11
+ * is a flick rather than a click on an X. Shipping the web's Dialog on a phone
12
+ * would be 1:1 and wrong; inventing a sheet on the web would be worse. So this
13
+ * is an addition, and the README lists it as one.
14
+ *
15
+ * Built on `@gorhom/bottom-sheet` rather than by hand, which is the opposite of
16
+ * the call made for Slider and Tabs, and the reason is the shape of the
17
+ * problem. Those were shallow and Gryt-specific — a press scale is four lines
18
+ * and no library will give you the right one. A sheet is deep and generic:
19
+ * velocity-based dismiss, snap points, keyboard avoidance, a backdrop whose
20
+ * opacity tracks the drag, and the handoff between dragging the sheet and
21
+ * scrolling the content inside it. None of that is about Gryt and all of it is
22
+ * months of edge cases.
23
+ *
24
+ * The library supplies behaviour only. Every colour, radius and dimension below
25
+ * comes from the theme.
26
+ *
27
+ * **`SheetProvider` has to be mounted above anything that renders a Sheet**,
28
+ * next to `ToastProvider` and `TooltipProvider`. That is not ceremony: this is
29
+ * `BottomSheetModal`, not `BottomSheet`, and the difference is where the sheet
30
+ * is anchored.
31
+ *
32
+ * `BottomSheet` positions itself inside whatever container it is rendered in.
33
+ * Written inline in a screen, as this was first, it anchors to its slot in the
34
+ * layout — so it appeared partway down the page with the content behind it
35
+ * showing through, and read as entering from the top. `BottomSheetModal`
36
+ * portals to the provider instead, which sits at the app root, so it is always
37
+ * anchored to the bottom of the screen no matter where the call site is.
38
+ */
39
+ export interface SheetProviderProps {
40
+ children?: ReactNode;
41
+ }
42
+ /** Mount once, at the root. See the note above for why it is required. */
43
+ export declare function SheetProvider({ children }: SheetProviderProps): import("react").JSX.Element;
44
+ export interface SheetProps extends OpenStateProps {
45
+ /**
46
+ * Heights the sheet settles at, as percentages or points.
47
+ *
48
+ * The web has no equivalent concept — a dialog is one size — which is part of
49
+ * why this is its own component rather than a Drawer with a prop.
50
+ */
51
+ snapPoints?: (string | number)[];
52
+ children?: ReactNode;
53
+ }
54
+ /**
55
+ * `open` with `onOpenChange` drives it from outside; `defaultOpen` alone lets
56
+ * it manage itself. That is `useOpenState`, which every other overlay in this
57
+ * package already uses — Sheet was the one that did not, and took `defaultOpen`
58
+ * and a `Trigger` and nothing else.
59
+ *
60
+ * What that cost a caller: a sheet opened by something that is not a Pressable
61
+ * — a tab bar item, a notification, a deep link — had to be unmounted and
62
+ * remounted with `defaultOpen` to open it at all, which throws the body away on
63
+ * every open. The mobile app shell did exactly that, twice.
64
+ */
65
+ declare function Root({ snapPoints, children, ...openProps }: SheetProps): import("react").JSX.Element;
66
+ export interface SheetTriggerProps {
67
+ children?: ReactNode;
68
+ style?: StyleProp<ViewStyle>;
69
+ }
70
+ /**
71
+ * A Pressable, like every other trigger in this package — which means its
72
+ * children have to be plain visual content. Nesting a Button inside one gives
73
+ * the inner pressable the touch and the sheet never opens, silently. Same
74
+ * footgun the overlay triggers already carry.
75
+ */
76
+ declare function Trigger({ children, style }: SheetTriggerProps): import("react").JSX.Element;
77
+ export interface SheetContentProps {
78
+ children?: ReactNode;
79
+ style?: StyleProp<ViewStyle>;
80
+ }
81
+ declare function Content({ children, style }: SheetContentProps): import("react").JSX.Element;
82
+ export interface SheetCloseProps {
83
+ children?: ReactNode;
84
+ style?: StyleProp<ViewStyle>;
85
+ }
86
+ declare function Close({ children, style }: SheetCloseProps): import("react").JSX.Element;
87
+ export interface SheetTitleProps {
88
+ children?: ReactNode;
89
+ style?: StyleProp<ViewStyle>;
90
+ }
91
+ declare function Title({ children, style }: SheetTitleProps): import("react").JSX.Element;
92
+ export declare const Sheet: typeof Root & {
93
+ Trigger: typeof Trigger;
94
+ Content: typeof Content;
95
+ Close: typeof Close;
96
+ Title: typeof Title;
97
+ };
98
+ export {};
99
+ //# sourceMappingURL=Sheet.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Sheet.d.ts","sourceRoot":"","sources":["../../../src/components/Sheet/Sheet.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,EAAyB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAerF,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAG/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,EAAE,QAAQ,EAAE,EAAE,kBAAkB,+BAE7D;AAeD,MAAM,WAAW,UAAW,SAAQ,cAAc;IAChD;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IACjC,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED;;;;;;;;;;GAUG;AACH,iBAAS,IAAI,CAAC,EACZ,UAAoB,EACpB,QAAQ,EACR,GAAG,SAAS,EACb,EAAE,UAAU,+BAqBZ;AAWD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED;;;;;GAKG;AACH,iBAAS,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,iBAAiB,+BAOtD;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,iBAAS,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,iBAAiB,+BA2LtD;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,iBAAS,KAAK,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,eAAe,+BAOlD;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,iBAAS,KAAK,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,eAAe,+BAiBlD;AAED,eAAO,MAAM,KAAK;;;;;CAA0D,CAAC"}
@@ -0,0 +1,201 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, } from "react";
3
+ import { Pressable, Text, View } from "react-native";
4
+ import { BottomSheetBackdrop, BottomSheetModal, BottomSheetModalProvider, BottomSheetView, useBottomSheetTimingConfigs, } from "@gorhom/bottom-sheet";
5
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
6
+ import { grytDrawerBleed } from "@gryt/theme";
7
+ import { durations, easeSpring } from "../../motion/index.js";
8
+ import { useOpenState } from "../../overlay/useOpenState.js";
9
+ import { useTheme } from "../../theme/index.js";
10
+ /** Mount once, at the root. See the note above for why it is required. */
11
+ export function SheetProvider({ children }) {
12
+ return _jsx(BottomSheetModalProvider, { children: children });
13
+ }
14
+ const SheetContext = createContext(null);
15
+ function useSheet(part) {
16
+ const value = useContext(SheetContext);
17
+ if (!value)
18
+ throw new Error(`Sheet.${part} must be rendered inside Sheet.`);
19
+ return value;
20
+ }
21
+ /**
22
+ * `open` with `onOpenChange` drives it from outside; `defaultOpen` alone lets
23
+ * it manage itself. That is `useOpenState`, which every other overlay in this
24
+ * package already uses — Sheet was the one that did not, and took `defaultOpen`
25
+ * and a `Trigger` and nothing else.
26
+ *
27
+ * What that cost a caller: a sheet opened by something that is not a Pressable
28
+ * — a tab bar item, a notification, a deep link — had to be unmounted and
29
+ * remounted with `defaultOpen` to open it at all, which throws the body away on
30
+ * every open. The mobile app shell did exactly that, twice.
31
+ */
32
+ function Root({ snapPoints = ["50%"], children, ...openProps }) {
33
+ const ref = useRef(null);
34
+ const state = useOpenState(openProps);
35
+ const { open: isOpen, setOpen } = state;
36
+ // Asking for the state rather than reaching for the ref, so a controlled
37
+ // Sheet's parent gets the final say — `setOpen` on a controlled overlay
38
+ // reports and does not decide. The effect in `Content` is what actually
39
+ // presents, once the modal it presents exists.
40
+ const open = useCallback(() => setOpen(true), [setOpen]);
41
+ const close = useCallback(() => setOpen(false), [setOpen]);
42
+ const context = useMemo(() => ({ open, close }), [open, close]);
43
+ return (_jsx(SheetContext.Provider, { value: context, children: _jsx(SheetRefContext.Provider, { value: { ref, isOpen, setOpen, snapPoints }, children: children }) }));
44
+ }
45
+ const SheetRefContext = createContext(null);
46
+ /**
47
+ * A Pressable, like every other trigger in this package — which means its
48
+ * children have to be plain visual content. Nesting a Button inside one gives
49
+ * the inner pressable the touch and the sheet never opens, silently. Same
50
+ * footgun the overlay triggers already carry.
51
+ */
52
+ function Trigger({ children, style }) {
53
+ const sheet = useSheet("Trigger");
54
+ return (_jsx(Pressable, { onPress: sheet.open, style: style, children: children }));
55
+ }
56
+ function Content({ children, style }) {
57
+ const theme = useTheme();
58
+ /**
59
+ * The phone's own furniture, which the sheet has to stay clear of at both
60
+ * ends.
61
+ *
62
+ * At the tall snap point the sheet reaches the top of the screen, and without
63
+ * `topInset` its content runs under the Dynamic Island. At the other end the
64
+ * home indicator sits over the last few points, and a control row ending
65
+ * flush with the sheet is clipped by it.
66
+ */
67
+ const insets = useSafeAreaInsets();
68
+ const state = useContext(SheetRefContext);
69
+ // Read here, in the normal tree, and handed back down below — see the note
70
+ // on the provider inside the modal.
71
+ const sheet = useContext(SheetContext);
72
+ if (!state || !sheet) {
73
+ throw new Error("Sheet.Content must be rendered inside Sheet.");
74
+ }
75
+ const { ref, isOpen, setOpen, snapPoints } = state;
76
+ /**
77
+ * `present` and `dismiss` rather than mounting and unmounting by hand: the
78
+ * modal is always rendered and the provider decides whether it is on screen,
79
+ * which is what removes the mount-then-snap-on-the-next-frame dance the
80
+ * inline version needed.
81
+ *
82
+ * Here rather than in `Root` because `ref` is attached to the modal below,
83
+ * and `Root` runs before there is one.
84
+ *
85
+ * `presented` is not bookkeeping that could be dropped. A closed Sheet runs
86
+ * this effect once on mount with `isOpen` false, and dismissing a modal that
87
+ * has never been presented takes it *out* of the provider's registry — after
88
+ * which `present()` is a no-op and the sheet never opens again. It looks like
89
+ * the open prop being ignored, and it is not: it is the close that ran first.
90
+ */
91
+ const presented = useRef(false);
92
+ useEffect(() => {
93
+ if (isOpen) {
94
+ presented.current = true;
95
+ ref.current?.present();
96
+ }
97
+ else if (presented.current) {
98
+ presented.current = false;
99
+ ref.current?.dismiss();
100
+ }
101
+ }, [isOpen, ref]);
102
+ // The modal renders nothing until it is presented, so there is no invisible
103
+ // backdrop sitting over the screen eating taps while it is closed. That was
104
+ // a real hazard with the inline version and had to be handled by unmounting.
105
+ /**
106
+ * The sheet's own background, drawn here rather than left to `backgroundStyle`.
107
+ *
108
+ * Two things needed fixing and one prop could not do either.
109
+ *
110
+ * `backgroundStyle` paints a view sized exactly to the sheet, and the
111
+ * container `style` sits outside the rounded corners — so a border on the
112
+ * container drew a straight line across the full width above the rounded top,
113
+ * which is the stray line that was reported.
114
+ *
115
+ * And the spring overshoots. A sheet sized exactly to its snap point travels
116
+ * past it and leaves a band of backdrop along the bottom of the screen for a
117
+ * frame or two. So this hangs `grytDrawerBleed` below the bottom edge, which
118
+ * is the same trick and the same distance the web Drawer uses — its comment
119
+ * calls the artefact "a seam of backdrop down the edge".
120
+ */
121
+ const renderBackground = useCallback(({ style: bgStyle }) => (_jsx(View, { pointerEvents: "none", style: [
122
+ bgStyle,
123
+ {
124
+ backgroundColor: theme.color.surface,
125
+ borderTopLeftRadius: theme.radius.xl,
126
+ borderTopRightRadius: theme.radius.xl,
127
+ // Follows the rounded corners, unlike a border on the container.
128
+ borderTopWidth: 1,
129
+ borderColor: theme.color.border,
130
+ // The overhang. Negative bottom rather than extra height, so the
131
+ // rounded top stays where the sheet actually is.
132
+ bottom: -grytDrawerBleed,
133
+ },
134
+ ] })), [theme]);
135
+ /**
136
+ * Gryt's spring, rather than gorhom's default.
137
+ *
138
+ * `withTiming` over the sampled curve, for the same reason every other
139
+ * animation in this package does it that way: the curve in @gryt/theme is a
140
+ * damped spring solved analytically, and approximating it with a physics
141
+ * engine would be approximating an exact curve with the thing it was chosen
142
+ * over.
143
+ *
144
+ * `springSoft` — 700ms — because a sheet travels its own height, which is the
145
+ * case that duration was shaped for. It is what Drawer uses and for the same
146
+ * reason.
147
+ */
148
+ const animationConfigs = useBottomSheetTimingConfigs({
149
+ duration: durations.springSoft,
150
+ easing: easeSpring,
151
+ });
152
+ const renderBackdrop = useCallback((props) => (_jsx(BottomSheetBackdrop, { ...props, appearsOnIndex: 0, disappearsOnIndex: -1,
153
+ // Tracks the drag, so dragging the sheet down lightens the scrim
154
+ // rather than holding full opacity until it snaps shut.
155
+ opacity: 0.6, pressBehavior: "close" })), []);
156
+ return (_jsx(BottomSheetModal, { ref: ref, snapPoints: snapPoints,
157
+ // So a sheet at 100% stops below the Dynamic Island rather than running
158
+ // its content under it.
159
+ topInset: insets.top,
160
+ // Off, because `snapPoints` is the whole point of this component.
161
+ //
162
+ // gorhom v5 defaults dynamic sizing on, which measures the content and
163
+ // sizes the sheet to it — overriding the snap points entirely. A sheet
164
+ // asked for 70% whose content had no intrinsic height collapsed to the
165
+ // height of its own footer, which looks like the snap points being
166
+ // ignored because they were.
167
+ enableDynamicSizing: false, enablePanDownToClose: true,
168
+ // A flick down or a tap on the backdrop closes it without anything in
169
+ // React asking, so the state has to be told. Uncontrolled, this is what
170
+ // makes the next `present` work; controlled, it is how the parent finds
171
+ // out its sheet is gone.
172
+ onDismiss: () => setOpen(false), animationConfigs: animationConfigs, backdropComponent: renderBackdrop, backgroundComponent: renderBackground, handleIndicatorStyle: {
173
+ backgroundColor: theme.color.border,
174
+ width: 36,
175
+ height: 4,
176
+ }, children: _jsx(BottomSheetView, { style: [
177
+ {
178
+ flex: 1,
179
+ padding: theme.space(4),
180
+ // The home indicator's strip, on top of whatever padding the caller
181
+ // asked for. Without it the last row of content is clipped by it —
182
+ // reported as the voice controls being cut off at the bottom.
183
+ paddingBottom: theme.space(4) + insets.bottom,
184
+ },
185
+ style,
186
+ ], children: _jsx(SheetContext.Provider, { value: sheet, children: children }) }) }));
187
+ }
188
+ function Close({ children, style }) {
189
+ const sheet = useSheet("Close");
190
+ return (_jsx(Pressable, { onPress: sheet.close, style: style, children: children }));
191
+ }
192
+ function Title({ children, style }) {
193
+ const theme = useTheme();
194
+ return (_jsx(View, { style: style, children: _jsx(Text, { accessibilityRole: "header", style: {
195
+ color: theme.color.text,
196
+ fontSize: 18,
197
+ fontWeight: "600",
198
+ marginBottom: theme.space(2),
199
+ }, children: children }) }));
200
+ }
201
+ export const Sheet = Object.assign(Root, { Trigger, Content, Close, Title });
@@ -1 +1 @@
1
- {"version":3,"file":"Slider.d.ts","sourceRoot":"","sources":["../../../src/components/Slider/Slider.tsx"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAMtB,OAAO,EAAsB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAGrE,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,aAAa,EAAE,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC,CAAC;AAEhG,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAKD;;;;;;;;;GASG;AACH,wBAAgB,MAAM,CAAC,EACrB,KAAK,EAAE,UAAU,EACjB,YAAgB,EAChB,aAAa,EACb,aAAa,EACb,GAAO,EACP,GAAS,EACT,IAAQ,EACR,QAAQ,EACR,IAAgB,EAChB,KAAK,EACL,kBAAkB,GACnB,EAAE,WAAW,+BAsIb"}
1
+ {"version":3,"file":"Slider.d.ts","sourceRoot":"","sources":["../../../src/components/Slider/Slider.tsx"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAMtB,OAAO,EAAsB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAIrE,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,aAAa,EAAE,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC,CAAC;AAEhG,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAKD;;;;;;;;;GASG;AACH,wBAAgB,MAAM,CAAC,EACrB,KAAK,EAAE,UAAU,EACjB,YAAgB,EAChB,aAAa,EACb,aAAa,EACb,GAAO,EACP,GAAS,EACT,IAAQ,EACR,QAAQ,EACR,IAAgB,EAChB,KAAK,EACL,kBAAkB,GACnB,EAAE,WAAW,+BAuKb"}