@innovui/react-native 0.1.0 → 0.2.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 (28) hide show
  1. package/dist/components/_shared/anchored-overlay.js +37 -12
  2. package/dist/components/_shared/anchored-overlay.js.map +1 -1
  3. package/dist/components/_shared/sliding-indicator.js +98 -0
  4. package/dist/components/_shared/sliding-indicator.js.map +1 -0
  5. package/dist/components/_shared/use-transition.js +53 -0
  6. package/dist/components/_shared/use-transition.js.map +1 -0
  7. package/dist/components/drawer/drawer.d.ts +11 -1
  8. package/dist/components/drawer/drawer.js +37 -22
  9. package/dist/components/drawer/drawer.js.map +1 -1
  10. package/dist/components/drawer/use-swipe-to-close.js +143 -0
  11. package/dist/components/drawer/use-swipe-to-close.js.map +1 -0
  12. package/dist/components/input/input.d.ts +8 -2
  13. package/dist/components/input/input.js +10 -2
  14. package/dist/components/input/input.js.map +1 -1
  15. package/dist/components/modal/modal.js +59 -39
  16. package/dist/components/modal/modal.js.map +1 -1
  17. package/dist/components/segmented-control/segmented-control.d.ts +2 -2
  18. package/dist/components/segmented-control/segmented-control.js +20 -7
  19. package/dist/components/segmented-control/segmented-control.js.map +1 -1
  20. package/dist/components/tabs/tabs.d.ts +2 -2
  21. package/dist/components/tabs/tabs.js +32 -9
  22. package/dist/components/tabs/tabs.js.map +1 -1
  23. package/dist/components/toast/toast.js +66 -5
  24. package/dist/components/toast/toast.js.map +1 -1
  25. package/dist/components/tooltip/tooltip.d.ts +7 -3
  26. package/dist/components/tooltip/tooltip.js +10 -4
  27. package/dist/components/tooltip/tooltip.js.map +1 -1
  28. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
+ import { useTransition } from "./use-transition.js";
1
2
  import { useCallback, useRef, useState } from "react";
2
- import { Modal, Pressable, View, useWindowDimensions } from "react-native";
3
- import { jsx } from "react/jsx-runtime";
3
+ import { Animated, Modal, Pressable, View, useWindowDimensions } from "react-native";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
4
5
  //#region src/components/_shared/anchored-overlay.tsx
5
6
  /**
6
7
  * Medição de um gatilho para ancorar um overlay (Popover, Menu, Select, Combobox, DatePicker,
@@ -35,17 +36,25 @@ function useAnchor() {
35
36
  };
36
37
  }
37
38
  /** Overlay transparente, posicionado junto ao gatilho, dentro de um Modal nativo (fica acima de tudo). */
38
- function AnchoredOverlay({ visible, rect, onRequestClose, children, placement = "bottom", offset = 4, matchAnchorWidth = false, estimatedHeight = 320, dismissOnPressOutside = true, className, testID }) {
39
+ function AnchoredOverlay({ visible, rect, onRequestClose, children, placement = "bottom", offset = 4, matchAnchorWidth = false, estimatedHeight = 320, align = "start", arrowClassName, dismissOnPressOutside = true, className, testID }) {
39
40
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
41
+ const { mounted, progress } = useTransition(visible, {
42
+ duration: 150,
43
+ exitDuration: 100
44
+ });
45
+ const [contentWidth, setContentWidth] = useState(0);
40
46
  if (!rect) return null;
41
47
  const spaceBelow = windowHeight - (rect.y + rect.height);
42
48
  const spaceAbove = rect.y;
43
49
  const goTop = placement === "top" ? spaceAbove >= estimatedHeight || spaceAbove >= spaceBelow : spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
44
- const left = Math.max(8, Math.min(rect.x, windowWidth - 8 - (matchAnchorWidth ? rect.width : 0)));
50
+ const anchorCenter = rect.x + rect.width / 2;
51
+ const left = align === "center" ? Math.max(8, Math.min(anchorCenter - contentWidth / 2, windowWidth - 8 - contentWidth)) : Math.max(8, Math.min(rect.x, windowWidth - 8 - (matchAnchorWidth ? rect.width : 0)));
52
+ const measured = align !== "center" || contentWidth > 0;
53
+ const arrowLeft = Math.max(8, Math.min(anchorCenter - left, Math.max(contentWidth, 16) - 8)) - 3;
45
54
  return /* @__PURE__ */ jsx(Modal, {
46
55
  transparent: true,
47
- visible,
48
- animationType: "fade",
56
+ visible: mounted,
57
+ animationType: "none",
49
58
  onRequestClose,
50
59
  statusBarTranslucent: true,
51
60
  children: /* @__PURE__ */ jsx(Pressable, {
@@ -53,17 +62,33 @@ function AnchoredOverlay({ visible, rect, onRequestClose, children, placement =
53
62
  className: "flex-1",
54
63
  onPress: dismissOnPressOutside ? onRequestClose : void 0,
55
64
  testID: testID ? `${testID}-backdrop` : void 0,
56
- children: /* @__PURE__ */ jsx(View, {
57
- testID,
58
- className,
65
+ children: /* @__PURE__ */ jsxs(Animated.View, {
59
66
  style: {
60
67
  position: "absolute",
61
68
  left,
62
69
  ...goTop ? { bottom: windowHeight - rect.y + offset } : { top: rect.y + rect.height + offset },
63
- ...matchAnchorWidth ? { width: rect.width } : null
70
+ ...matchAnchorWidth ? { width: rect.width } : null,
71
+ opacity: measured ? progress : 0,
72
+ transform: [{ scale: progress.interpolate({
73
+ inputRange: [0, 1],
74
+ outputRange: [.96, 1]
75
+ }) }],
76
+ transformOrigin: goTop ? "bottom left" : "top left"
64
77
  },
65
- onStartShouldSetResponder: () => true,
66
- children
78
+ onLayout: (e) => align === "center" && setContentWidth(e.nativeEvent.layout.width),
79
+ children: [/* @__PURE__ */ jsx(View, {
80
+ testID,
81
+ className,
82
+ onStartShouldSetResponder: () => true,
83
+ children
84
+ }), arrowClassName && /* @__PURE__ */ jsx(View, {
85
+ pointerEvents: "none",
86
+ className: `absolute size-1.5 rotate-45 ${arrowClassName}`,
87
+ style: {
88
+ left: arrowLeft,
89
+ ...goTop ? { bottom: -3 } : { top: -3 }
90
+ }
91
+ })]
67
92
  })
68
93
  })
69
94
  });
@@ -1 +1 @@
1
- {"version":3,"file":"anchored-overlay.js","names":[],"sources":["../../../src/components/_shared/anchored-overlay.tsx"],"sourcesContent":["/// <reference types=\"uniwind/types\" />\n\nimport { type ReactNode, type RefObject, useCallback, useRef, useState } from 'react'\nimport { Modal, Pressable, useWindowDimensions, View } from 'react-native'\n\nexport type Placement = 'bottom' | 'top'\ntype Rect = { x: number; y: number; width: number; height: number }\n\n/**\n * Medição de um gatilho para ancorar um overlay (Popover, Menu, Select, Combobox, DatePicker,\n * Tooltip). `open()` mede o gatilho na janela e só então abre.\n *\n * const { ref, rect, visible, open, close } = useAnchor()\n * <View ref={ref} collapsable={false}> ...gatilho... </View>\n * <AnchoredOverlay visible={visible} rect={rect} onRequestClose={close}> ... </AnchoredOverlay>\n */\nexport function useAnchor() {\n const ref = useRef<View>(null)\n const [rect, setRect] = useState<Rect | null>(null)\n const [visible, setVisible] = useState(false)\n\n const open = useCallback(() => {\n const node = ref.current\n if (!node) return\n node.measureInWindow((x, y, width, height) => {\n setRect({ x, y, width, height })\n setVisible(true)\n })\n }, [])\n const close = useCallback(() => setVisible(false), [])\n\n return { ref: ref as RefObject<View>, rect, visible, open, close }\n}\n\nexport type AnchoredOverlayProps = {\n visible: boolean\n rect: Rect | null\n onRequestClose: () => void\n children: ReactNode\n /** Lado preferido; inverte se não couber. Padrão: `bottom`. */\n placement?: Placement\n /** Distância do gatilho. Padrão: 4. */\n offset?: number\n /** Largura do conteúdo: `anchor` iguala à do gatilho (Select, Combobox). Padrão: automática. */\n matchAnchorWidth?: boolean\n /** Altura máxima estimada do conteúdo, para decidir a inversão. Padrão: 320. */\n estimatedHeight?: number\n /** Toque fora fecha. Padrão: true. */\n dismissOnPressOutside?: boolean\n className?: string\n testID?: string\n}\n\n/** Overlay transparente, posicionado junto ao gatilho, dentro de um Modal nativo (fica acima de tudo). */\nexport function AnchoredOverlay({\n visible,\n rect,\n onRequestClose,\n children,\n placement = 'bottom',\n offset = 4,\n matchAnchorWidth = false,\n estimatedHeight = 320,\n dismissOnPressOutside = true,\n className,\n testID,\n}: AnchoredOverlayProps) {\n const { width: windowWidth, height: windowHeight } = useWindowDimensions()\n if (!rect) return null\n\n const spaceBelow = windowHeight - (rect.y + rect.height)\n const spaceAbove = rect.y\n const wantsTop = placement === 'top'\n const goTop = wantsTop\n ? spaceAbove >= estimatedHeight || spaceAbove >= spaceBelow\n : spaceBelow < estimatedHeight && spaceAbove > spaceBelow\n\n const left = Math.max(8, Math.min(rect.x, windowWidth - 8 - (matchAnchorWidth ? rect.width : 0)))\n\n return (\n <Modal\n transparent\n visible={visible}\n animationType=\"fade\"\n onRequestClose={onRequestClose}\n statusBarTranslucent\n >\n <Pressable\n accessible={false}\n className=\"flex-1\"\n onPress={dismissOnPressOutside ? onRequestClose : undefined}\n testID={testID ? `${testID}-backdrop` : undefined}\n >\n <View\n testID={testID}\n className={className}\n style={{\n position: 'absolute',\n left,\n ...(goTop\n ? { bottom: windowHeight - rect.y + offset }\n : { top: rect.y + rect.height + offset }),\n ...(matchAnchorWidth ? { width: rect.width } : null),\n }}\n // Toques dentro do conteúdo não chegam ao backdrop.\n onStartShouldSetResponder={() => true}\n >\n {children}\n </View>\n </Pressable>\n </Modal>\n )\n}\n"],"mappings":";;;;;;;;;;;;AAgBA,SAAgB,YAAY;CAC1B,MAAM,MAAM,OAAa,IAAI;CAC7B,MAAM,CAAC,MAAM,WAAW,SAAsB,IAAI;CAClD,MAAM,CAAC,SAAS,cAAc,SAAS,KAAK;CAY5C,OAAO;EAAO;EAAwB;EAAM;EAAS,MAVxC,kBAAkB;GAC7B,MAAM,OAAO,IAAI;GACjB,IAAI,CAAC,MAAM;GACX,KAAK,iBAAiB,GAAG,GAAG,OAAO,WAAW;IAC5C,QAAQ;KAAE;KAAG;KAAG;KAAO;IAAO,CAAC;IAC/B,WAAW,IAAI;GACjB,CAAC;EACH,GAAG,CAAC,CAGoD;EAAG,OAF7C,kBAAkB,WAAW,KAAK,GAAG,CAAC,CAEW;CAAE;AACnE;;AAsBA,SAAgB,gBAAgB,EAC9B,SACA,MACA,gBACA,UACA,YAAY,UACZ,SAAS,GACT,mBAAmB,OACnB,kBAAkB,KAClB,wBAAwB,MACxB,WACA,UACuB;CACvB,MAAM,EAAE,OAAO,aAAa,QAAQ,iBAAiB,oBAAoB;CACzE,IAAI,CAAC,MAAM,OAAO;CAElB,MAAM,aAAa,gBAAgB,KAAK,IAAI,KAAK;CACjD,MAAM,aAAa,KAAK;CAExB,MAAM,QADW,cAAc,QAE3B,cAAc,mBAAmB,cAAc,aAC/C,aAAa,mBAAmB,aAAa;CAEjD,MAAM,OAAO,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,GAAG,cAAc,KAAK,mBAAmB,KAAK,QAAQ,EAAE,CAAC;CAEhG,OACE,oBAAC,OAAD;EACE,aAAA;EACS;EACT,eAAc;EACE;EAChB,sBAAA;EAEA,UAAA,oBAAC,WAAD;GACE,YAAY;GACZ,WAAU;GACV,SAAS,wBAAwB,iBAAiB,KAAA;GAClD,QAAQ,SAAS,GAAG,OAAO,aAAa,KAAA;GAExC,UAAA,oBAAC,MAAD;IACU;IACG;IACX,OAAO;KACL,UAAU;KACV;KACA,GAAI,QACA,EAAE,QAAQ,eAAe,KAAK,IAAI,OAAO,IACzC,EAAE,KAAK,KAAK,IAAI,KAAK,SAAS,OAAO;KACzC,GAAI,mBAAmB,EAAE,OAAO,KAAK,MAAM,IAAI;IACjD;IAEA,iCAAiC;IAEhC;GACG,CAAA;EACG,CAAA;CACN,CAAA;AAEX"}
1
+ {"version":3,"file":"anchored-overlay.js","names":[],"sources":["../../../src/components/_shared/anchored-overlay.tsx"],"sourcesContent":["/// <reference types=\"uniwind/types\" />\n\nimport { type ReactNode, type RefObject, useCallback, useRef, useState } from 'react'\nimport { Animated, Modal, Pressable, useWindowDimensions, View } from 'react-native'\nimport { useTransition } from './use-transition'\n\nexport type Placement = 'bottom' | 'top'\ntype Rect = { x: number; y: number; width: number; height: number }\n\n/**\n * Medição de um gatilho para ancorar um overlay (Popover, Menu, Select, Combobox, DatePicker,\n * Tooltip). `open()` mede o gatilho na janela e só então abre.\n *\n * const { ref, rect, visible, open, close } = useAnchor()\n * <View ref={ref} collapsable={false}> ...gatilho... </View>\n * <AnchoredOverlay visible={visible} rect={rect} onRequestClose={close}> ... </AnchoredOverlay>\n */\nexport function useAnchor() {\n const ref = useRef<View>(null)\n const [rect, setRect] = useState<Rect | null>(null)\n const [visible, setVisible] = useState(false)\n\n const open = useCallback(() => {\n const node = ref.current\n if (!node) return\n node.measureInWindow((x, y, width, height) => {\n setRect({ x, y, width, height })\n setVisible(true)\n })\n }, [])\n const close = useCallback(() => setVisible(false), [])\n\n return { ref: ref as RefObject<View>, rect, visible, open, close }\n}\n\nexport type AnchoredOverlayProps = {\n visible: boolean\n rect: Rect | null\n onRequestClose: () => void\n children: ReactNode\n /** Lado preferido; inverte se não couber. Padrão: `bottom`. */\n placement?: Placement\n /** Distância do gatilho. Padrão: 4. */\n offset?: number\n /** Largura do conteúdo: `anchor` iguala à do gatilho (Select, Combobox). Padrão: automática. */\n matchAnchorWidth?: boolean\n /** Altura máxima estimada do conteúdo, para decidir a inversão. Padrão: 320. */\n estimatedHeight?: number\n /** Alinhamento com o gatilho: `start` (borda esquerda) ou `center` (centrado, como o Tooltip). */\n align?: 'start' | 'center'\n /** Seta apontando para o gatilho (classes do desenho, ex.: a cor do fundo do conteúdo). */\n arrowClassName?: string\n /** Toque fora fecha. Padrão: true. */\n dismissOnPressOutside?: boolean\n className?: string\n testID?: string\n}\n\n/** Overlay transparente, posicionado junto ao gatilho, dentro de um Modal nativo (fica acima de tudo). */\nexport function AnchoredOverlay({\n visible,\n rect,\n onRequestClose,\n children,\n placement = 'bottom',\n offset = 4,\n matchAnchorWidth = false,\n estimatedHeight = 320,\n align = 'start',\n arrowClassName,\n dismissOnPressOutside = true,\n className,\n testID,\n}: AnchoredOverlayProps) {\n const { width: windowWidth, height: windowHeight } = useWindowDimensions()\n // Como no web: fade com zoom de 96% a partir do gatilho (o `Modal` nativo só fecha depois da saída)\n const { mounted, progress } = useTransition(visible, { duration: 150, exitDuration: 100 })\n // centrado precisa da largura do conteúdo: mede e só então mostra\n const [contentWidth, setContentWidth] = useState(0)\n if (!rect) return null\n\n const spaceBelow = windowHeight - (rect.y + rect.height)\n const spaceAbove = rect.y\n const wantsTop = placement === 'top'\n const goTop = wantsTop\n ? spaceAbove >= estimatedHeight || spaceAbove >= spaceBelow\n : spaceBelow < estimatedHeight && spaceAbove > spaceBelow\n\n const anchorCenter = rect.x + rect.width / 2\n const left =\n align === 'center'\n ? Math.max(8, Math.min(anchorCenter - contentWidth / 2, windowWidth - 8 - contentWidth))\n : Math.max(8, Math.min(rect.x, windowWidth - 8 - (matchAnchorWidth ? rect.width : 0)))\n const measured = align !== 'center' || contentWidth > 0\n // seta de 6px girada 45° (um losango de ~8px de largura e ~4px para fora), sobre a borda do conteúdo\n const arrowLeft = Math.max(8, Math.min(anchorCenter - left, Math.max(contentWidth, 16) - 8)) - 3\n\n return (\n <Modal\n transparent\n visible={mounted}\n animationType=\"none\"\n onRequestClose={onRequestClose}\n statusBarTranslucent\n >\n <Pressable\n accessible={false}\n className=\"flex-1\"\n onPress={dismissOnPressOutside ? onRequestClose : undefined}\n testID={testID ? `${testID}-backdrop` : undefined}\n >\n <Animated.View\n style={{\n position: 'absolute',\n left,\n ...(goTop\n ? { bottom: windowHeight - rect.y + offset }\n : { top: rect.y + rect.height + offset }),\n ...(matchAnchorWidth ? { width: rect.width } : null),\n opacity: measured ? progress : 0,\n transform: [\n { scale: progress.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }) },\n ],\n transformOrigin: goTop ? 'bottom left' : 'top left',\n }}\n onLayout={(e) => align === 'center' && setContentWidth(e.nativeEvent.layout.width)}\n >\n <View\n testID={testID}\n className={className}\n // Toques dentro do conteúdo não chegam ao backdrop.\n onStartShouldSetResponder={() => true}\n >\n {children}\n </View>\n {arrowClassName && (\n <View\n pointerEvents=\"none\"\n className={`absolute size-1.5 rotate-45 ${arrowClassName}`}\n style={{ left: arrowLeft, ...(goTop ? { bottom: -3 } : { top: -3 }) }}\n />\n )}\n </Animated.View>\n </Pressable>\n </Modal>\n )\n}\n"],"mappings":";;;;;;;;;;;;;AAiBA,SAAgB,YAAY;CAC1B,MAAM,MAAM,OAAa,IAAI;CAC7B,MAAM,CAAC,MAAM,WAAW,SAAsB,IAAI;CAClD,MAAM,CAAC,SAAS,cAAc,SAAS,KAAK;CAY5C,OAAO;EAAO;EAAwB;EAAM;EAAS,MAVxC,kBAAkB;GAC7B,MAAM,OAAO,IAAI;GACjB,IAAI,CAAC,MAAM;GACX,KAAK,iBAAiB,GAAG,GAAG,OAAO,WAAW;IAC5C,QAAQ;KAAE;KAAG;KAAG;KAAO;IAAO,CAAC;IAC/B,WAAW,IAAI;GACjB,CAAC;EACH,GAAG,CAAC,CAGoD;EAAG,OAF7C,kBAAkB,WAAW,KAAK,GAAG,CAAC,CAEW;CAAE;AACnE;;AA0BA,SAAgB,gBAAgB,EAC9B,SACA,MACA,gBACA,UACA,YAAY,UACZ,SAAS,GACT,mBAAmB,OACnB,kBAAkB,KAClB,QAAQ,SACR,gBACA,wBAAwB,MACxB,WACA,UACuB;CACvB,MAAM,EAAE,OAAO,aAAa,QAAQ,iBAAiB,oBAAoB;CAEzE,MAAM,EAAE,SAAS,aAAa,cAAc,SAAS;EAAE,UAAU;EAAK,cAAc;CAAI,CAAC;CAEzF,MAAM,CAAC,cAAc,mBAAmB,SAAS,CAAC;CAClD,IAAI,CAAC,MAAM,OAAO;CAElB,MAAM,aAAa,gBAAgB,KAAK,IAAI,KAAK;CACjD,MAAM,aAAa,KAAK;CAExB,MAAM,QADW,cAAc,QAE3B,cAAc,mBAAmB,cAAc,aAC/C,aAAa,mBAAmB,aAAa;CAEjD,MAAM,eAAe,KAAK,IAAI,KAAK,QAAQ;CAC3C,MAAM,OACJ,UAAU,WACN,KAAK,IAAI,GAAG,KAAK,IAAI,eAAe,eAAe,GAAG,cAAc,IAAI,YAAY,CAAC,IACrF,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,GAAG,cAAc,KAAK,mBAAmB,KAAK,QAAQ,EAAE,CAAC;CACzF,MAAM,WAAW,UAAU,YAAY,eAAe;CAEtD,MAAM,YAAY,KAAK,IAAI,GAAG,KAAK,IAAI,eAAe,MAAM,KAAK,IAAI,cAAc,EAAE,IAAI,CAAC,CAAC,IAAI;CAE/F,OACE,oBAAC,OAAD;EACE,aAAA;EACA,SAAS;EACT,eAAc;EACE;EAChB,sBAAA;EAEA,UAAA,oBAAC,WAAD;GACE,YAAY;GACZ,WAAU;GACV,SAAS,wBAAwB,iBAAiB,KAAA;GAClD,QAAQ,SAAS,GAAG,OAAO,aAAa,KAAA;GAExC,UAAA,qBAAC,SAAS,MAAV;IACE,OAAO;KACL,UAAU;KACV;KACA,GAAI,QACA,EAAE,QAAQ,eAAe,KAAK,IAAI,OAAO,IACzC,EAAE,KAAK,KAAK,IAAI,KAAK,SAAS,OAAO;KACzC,GAAI,mBAAmB,EAAE,OAAO,KAAK,MAAM,IAAI;KAC/C,SAAS,WAAW,WAAW;KAC/B,WAAW,CACT,EAAE,OAAO,SAAS,YAAY;MAAE,YAAY,CAAC,GAAG,CAAC;MAAG,aAAa,CAAC,KAAM,CAAC;KAAE,CAAC,EAAE,CAChF;KACA,iBAAiB,QAAQ,gBAAgB;IAC3C;IACA,WAAW,MAAM,UAAU,YAAY,gBAAgB,EAAE,YAAY,OAAO,KAAK;IAdnF,UAAA,CAgBE,oBAAC,MAAD;KACU;KACG;KAEX,iCAAiC;KAEhC;IACG,CAAA,GACL,kBACC,oBAAC,MAAD;KACE,eAAc;KACd,WAAW,+BAA+B;KAC1C,OAAO;MAAE,MAAM;MAAW,GAAI,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,KAAK,GAAG;KAAG;IACrE,CAAA,CAEU;;EACN,CAAA;CACN,CAAA;AAEX"}
@@ -0,0 +1,98 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { AccessibilityInfo, Animated, Easing, View } from "react-native";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/components/_shared/sliding-indicator.tsx
5
+ /** Posições medidas (`onLayout`) dos itens de um grupo, por chave. */
6
+ function useItemLayouts() {
7
+ const [layouts, setLayouts] = useState({});
8
+ return {
9
+ layouts,
10
+ setLayout: useCallback((key, rect) => {
11
+ setLayouts((current) => {
12
+ const before = current[key];
13
+ return before && before.x === rect.x && before.y === rect.y && before.width === rect.width && before.height === rect.height ? current : {
14
+ ...current,
15
+ [key]: rect
16
+ };
17
+ });
18
+ }, [])
19
+ };
20
+ }
21
+ /** "Reduzir movimento" do sistema: o indicador pula direto em vez de deslizar. */
22
+ function useReducedMotion() {
23
+ const [reduced, setReduced] = useState(false);
24
+ useEffect(() => {
25
+ let alive = true;
26
+ AccessibilityInfo.isReduceMotionEnabled?.()?.then((value) => alive && value && setReduced(true));
27
+ const subscription = AccessibilityInfo.addEventListener?.("reduceMotionChanged", setReduced);
28
+ return () => {
29
+ alive = false;
30
+ subscription?.remove?.();
31
+ };
32
+ }, []);
33
+ return reduced;
34
+ }
35
+ /**
36
+ * Indicador único que desliza e se redimensiona até `target` (o sublinhado das Tabs, o pill do
37
+ * SegmentedControl). Fica no contêiner dos itens, com posição absoluta, e usa as medidas
38
+ * `onLayout` dos itens. Na primeira medição aparece direto no lugar, sem deslizar do canto.
39
+ * A animação é do lado do JS (posição e tamanho são `layout`, que o driver nativo não anima).
40
+ */
41
+ function SlidingIndicator({ target, className }) {
42
+ const reduced = useReducedMotion();
43
+ const values = useRef(null);
44
+ if (target && !values.current) values.current = {
45
+ left: new Animated.Value(target.x),
46
+ top: new Animated.Value(target.y),
47
+ width: new Animated.Value(target.width),
48
+ height: new Animated.Value(target.height)
49
+ };
50
+ const x = target?.x;
51
+ const y = target?.y;
52
+ const width = target?.width;
53
+ const height = target?.height;
54
+ useEffect(() => {
55
+ const current = values.current;
56
+ if (!current || x === void 0 || y === void 0 || !width || !height) return;
57
+ const to = {
58
+ left: x,
59
+ top: y,
60
+ width,
61
+ height
62
+ };
63
+ if (reduced) {
64
+ for (const key of Object.keys(to)) current[key].setValue(to[key]);
65
+ return;
66
+ }
67
+ const animation = Animated.parallel(Object.keys(to).map((key) => Animated.timing(current[key], {
68
+ toValue: to[key],
69
+ duration: 200,
70
+ easing: Easing.out(Easing.cubic),
71
+ useNativeDriver: false
72
+ })));
73
+ animation.start();
74
+ return () => animation.stop();
75
+ }, [
76
+ x,
77
+ y,
78
+ width,
79
+ height,
80
+ reduced
81
+ ]);
82
+ if (!target || !values.current) return null;
83
+ return /* @__PURE__ */ jsx(Animated.View, {
84
+ pointerEvents: "none",
85
+ style: {
86
+ position: "absolute",
87
+ ...values.current
88
+ },
89
+ children: /* @__PURE__ */ jsx(View, {
90
+ className,
91
+ style: { flex: 1 }
92
+ })
93
+ });
94
+ }
95
+ //#endregion
96
+ export { SlidingIndicator, useItemLayouts, useReducedMotion };
97
+
98
+ //# sourceMappingURL=sliding-indicator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sliding-indicator.js","names":[],"sources":["../../../src/components/_shared/sliding-indicator.tsx"],"sourcesContent":["/// <reference types=\"uniwind/types\" />\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { AccessibilityInfo, Animated, Easing, View } from 'react-native'\n\nexport type Rect = { x: number; y: number; width: number; height: number }\n\n/** Posições medidas (`onLayout`) dos itens de um grupo, por chave. */\nexport function useItemLayouts() {\n const [layouts, setLayouts] = useState<Record<string, Rect>>({})\n const setLayout = useCallback((key: string, rect: Rect) => {\n setLayouts((current) => {\n const before = current[key]\n const same =\n before &&\n before.x === rect.x &&\n before.y === rect.y &&\n before.width === rect.width &&\n before.height === rect.height\n return same ? current : { ...current, [key]: rect }\n })\n }, [])\n return { layouts, setLayout }\n}\n\n/** \"Reduzir movimento\" do sistema: o indicador pula direto em vez de deslizar. */\nexport function useReducedMotion() {\n const [reduced, setReduced] = useState(false)\n useEffect(() => {\n let alive = true\n // só ligar quando o sistema pede (o padrão é `false`): evita um render à toa em todo overlay\n AccessibilityInfo.isReduceMotionEnabled?.()?.then((value) => alive && value && setReduced(true))\n const subscription = AccessibilityInfo.addEventListener?.('reduceMotionChanged', setReduced)\n return () => {\n alive = false\n subscription?.remove?.()\n }\n }, [])\n return reduced\n}\n\n/**\n * Indicador único que desliza e se redimensiona até `target` (o sublinhado das Tabs, o pill do\n * SegmentedControl). Fica no contêiner dos itens, com posição absoluta, e usa as medidas\n * `onLayout` dos itens. Na primeira medição aparece direto no lugar, sem deslizar do canto.\n * A animação é do lado do JS (posição e tamanho são `layout`, que o driver nativo não anima).\n */\nexport function SlidingIndicator({\n target,\n className,\n}: {\n target: Rect | undefined\n /** Classes do desenho (cor, raio, sombra) do indicador, que preenche o retângulo. */\n className?: string\n}) {\n const reduced = useReducedMotion()\n const values = useRef<Record<'left' | 'top' | 'width' | 'height', Animated.Value> | null>(null)\n if (target && !values.current) {\n values.current = {\n left: new Animated.Value(target.x),\n top: new Animated.Value(target.y),\n width: new Animated.Value(target.width),\n height: new Animated.Value(target.height),\n }\n }\n\n const x = target?.x\n const y = target?.y\n const width = target?.width\n const height = target?.height\n useEffect(() => {\n const current = values.current\n if (!current || x === undefined || y === undefined || !width || !height) return\n const to = { left: x, top: y, width, height }\n if (reduced) {\n for (const key of Object.keys(to) as (keyof typeof to)[]) current[key].setValue(to[key])\n return\n }\n const animation = Animated.parallel(\n (Object.keys(to) as (keyof typeof to)[]).map((key) =>\n Animated.timing(current[key], {\n toValue: to[key],\n duration: 200,\n easing: Easing.out(Easing.cubic),\n useNativeDriver: false,\n }),\n ),\n )\n animation.start()\n return () => animation.stop()\n }, [x, y, width, height, reduced])\n\n if (!target || !values.current) return null\n return (\n <Animated.View pointerEvents=\"none\" style={{ position: 'absolute', ...values.current }}>\n <View className={className} style={{ flex: 1 }} />\n </Animated.View>\n )\n}\n"],"mappings":";;;;;AAQA,SAAgB,iBAAiB;CAC/B,MAAM,CAAC,SAAS,cAAc,SAA+B,CAAC,CAAC;CAa/D,OAAO;EAAE;EAAS,WAZA,aAAa,KAAa,SAAe;GACzD,YAAY,YAAY;IACtB,MAAM,SAAS,QAAQ;IAOvB,OALE,UACA,OAAO,MAAM,KAAK,KAClB,OAAO,MAAM,KAAK,KAClB,OAAO,UAAU,KAAK,SACtB,OAAO,WAAW,KAAK,SACX,UAAU;KAAE,GAAG;MAAU,MAAM;IAAK;GACpD,CAAC;EACH,GAAG,CAAC,CACsB;CAAE;AAC9B;;AAGA,SAAgB,mBAAmB;CACjC,MAAM,CAAC,SAAS,cAAc,SAAS,KAAK;CAC5C,gBAAgB;EACd,IAAI,QAAQ;EAEZ,kBAAkB,wBAAwB,CAAC,EAAE,MAAM,UAAU,SAAS,SAAS,WAAW,IAAI,CAAC;EAC/F,MAAM,eAAe,kBAAkB,mBAAmB,uBAAuB,UAAU;EAC3F,aAAa;GACX,QAAQ;GACR,cAAc,SAAS;EACzB;CACF,GAAG,CAAC,CAAC;CACL,OAAO;AACT;;;;;;;AAQA,SAAgB,iBAAiB,EAC/B,QACA,aAKC;CACD,MAAM,UAAU,iBAAiB;CACjC,MAAM,SAAS,OAA2E,IAAI;CAC9F,IAAI,UAAU,CAAC,OAAO,SACpB,OAAO,UAAU;EACf,MAAM,IAAI,SAAS,MAAM,OAAO,CAAC;EACjC,KAAK,IAAI,SAAS,MAAM,OAAO,CAAC;EAChC,OAAO,IAAI,SAAS,MAAM,OAAO,KAAK;EACtC,QAAQ,IAAI,SAAS,MAAM,OAAO,MAAM;CAC1C;CAGF,MAAM,IAAI,QAAQ;CAClB,MAAM,IAAI,QAAQ;CAClB,MAAM,QAAQ,QAAQ;CACtB,MAAM,SAAS,QAAQ;CACvB,gBAAgB;EACd,MAAM,UAAU,OAAO;EACvB,IAAI,CAAC,WAAW,MAAM,KAAA,KAAa,MAAM,KAAA,KAAa,CAAC,SAAS,CAAC,QAAQ;EACzE,MAAM,KAAK;GAAE,MAAM;GAAG,KAAK;GAAG;GAAO;EAAO;EAC5C,IAAI,SAAS;GACX,KAAK,MAAM,OAAO,OAAO,KAAK,EAAE,GAA0B,QAAQ,IAAI,CAAC,SAAS,GAAG,IAAI;GACvF;EACF;EACA,MAAM,YAAY,SAAS,SACxB,OAAO,KAAK,EAAE,CAAC,CAAyB,KAAK,QAC5C,SAAS,OAAO,QAAQ,MAAM;GAC5B,SAAS,GAAG;GACZ,UAAU;GACV,QAAQ,OAAO,IAAI,OAAO,KAAK;GAC/B,iBAAiB;EACnB,CAAC,CACH,CACF;EACA,UAAU,MAAM;EAChB,aAAa,UAAU,KAAK;CAC9B,GAAG;EAAC;EAAG;EAAG;EAAO;EAAQ;CAAO,CAAC;CAEjC,IAAI,CAAC,UAAU,CAAC,OAAO,SAAS,OAAO;CACvC,OACE,oBAAC,SAAS,MAAV;EAAe,eAAc;EAAO,OAAO;GAAE,UAAU;GAAY,GAAG,OAAO;EAAQ;EACnF,UAAA,oBAAC,MAAD;GAAiB;GAAW,OAAO,EAAE,MAAM,EAAE;EAAI,CAAA;CACpC,CAAA;AAEnB"}
@@ -0,0 +1,53 @@
1
+ import { useReducedMotion } from "./sliding-indicator.js";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Animated } from "react-native";
4
+ //#region src/components/_shared/use-transition.ts
5
+ /**
6
+ * Entrada e saída de um overlay: `progress` vai de 0 a 1 (nativo, sem passar pelo JS) e `mounted`
7
+ * mantém o conteúdo na tela até a saída terminar (o `Modal` nativo só é fechado depois). Com
8
+ * "Reduzir movimento" ligado no sistema, abre e fecha direto.
9
+ */
10
+ function useTransition(isOpen, { duration = 200, exitDuration = 150, onClosed } = {}) {
11
+ const reduced = useReducedMotion();
12
+ const [mounted, setMounted] = useState(isOpen);
13
+ const progress = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
14
+ const closed = useRef(onClosed);
15
+ closed.current = onClosed;
16
+ useEffect(() => {
17
+ if (isOpen) setMounted(true);
18
+ if (reduced) {
19
+ progress.setValue(isOpen ? 1 : 0);
20
+ if (!isOpen) {
21
+ setMounted(false);
22
+ closed.current?.();
23
+ }
24
+ return;
25
+ }
26
+ const animation = Animated.timing(progress, {
27
+ toValue: isOpen ? 1 : 0,
28
+ duration: isOpen ? duration : exitDuration,
29
+ useNativeDriver: true
30
+ });
31
+ animation.start(({ finished }) => {
32
+ if (finished && !isOpen) {
33
+ setMounted(false);
34
+ closed.current?.();
35
+ }
36
+ });
37
+ return () => animation.stop();
38
+ }, [
39
+ isOpen,
40
+ reduced,
41
+ duration,
42
+ exitDuration,
43
+ progress
44
+ ]);
45
+ return {
46
+ mounted,
47
+ progress
48
+ };
49
+ }
50
+ //#endregion
51
+ export { useTransition };
52
+
53
+ //# sourceMappingURL=use-transition.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-transition.js","names":[],"sources":["../../../src/components/_shared/use-transition.ts"],"sourcesContent":["import { useEffect, useRef, useState } from 'react'\nimport { Animated } from 'react-native'\nimport { useReducedMotion } from './sliding-indicator'\n\ntype Options = {\n /** Duração da entrada (ms). */\n duration?: number\n /** Duração da saída (ms). */\n exitDuration?: number\n /** Chamado quando a saída termina e o conteúdo é desmontado. */\n onClosed?: () => void\n}\n\n/**\n * Entrada e saída de um overlay: `progress` vai de 0 a 1 (nativo, sem passar pelo JS) e `mounted`\n * mantém o conteúdo na tela até a saída terminar (o `Modal` nativo só é fechado depois). Com\n * \"Reduzir movimento\" ligado no sistema, abre e fecha direto.\n */\nexport function useTransition(\n isOpen: boolean,\n { duration = 200, exitDuration = 150, onClosed }: Options = {},\n) {\n const reduced = useReducedMotion()\n const [mounted, setMounted] = useState(isOpen)\n const progress = useRef(new Animated.Value(isOpen ? 1 : 0)).current\n const closed = useRef(onClosed)\n closed.current = onClosed\n\n useEffect(() => {\n if (isOpen) setMounted(true)\n if (reduced) {\n progress.setValue(isOpen ? 1 : 0)\n if (!isOpen) {\n setMounted(false)\n closed.current?.()\n }\n return\n }\n const animation = Animated.timing(progress, {\n toValue: isOpen ? 1 : 0,\n duration: isOpen ? duration : exitDuration,\n useNativeDriver: true,\n })\n animation.start(({ finished }) => {\n if (finished && !isOpen) {\n setMounted(false)\n closed.current?.()\n }\n })\n return () => animation.stop()\n }, [isOpen, reduced, duration, exitDuration, progress])\n\n return { mounted, progress }\n}\n"],"mappings":";;;;;;;;;AAkBA,SAAgB,cACd,QACA,EAAE,WAAW,KAAK,eAAe,KAAK,aAAsB,CAAC,GAC7D;CACA,MAAM,UAAU,iBAAiB;CACjC,MAAM,CAAC,SAAS,cAAc,SAAS,MAAM;CAC7C,MAAM,WAAW,OAAO,IAAI,SAAS,MAAM,SAAS,IAAI,CAAC,CAAC,CAAC,CAAC;CAC5D,MAAM,SAAS,OAAO,QAAQ;CAC9B,OAAO,UAAU;CAEjB,gBAAgB;EACd,IAAI,QAAQ,WAAW,IAAI;EAC3B,IAAI,SAAS;GACX,SAAS,SAAS,SAAS,IAAI,CAAC;GAChC,IAAI,CAAC,QAAQ;IACX,WAAW,KAAK;IAChB,OAAO,UAAU;GACnB;GACA;EACF;EACA,MAAM,YAAY,SAAS,OAAO,UAAU;GAC1C,SAAS,SAAS,IAAI;GACtB,UAAU,SAAS,WAAW;GAC9B,iBAAiB;EACnB,CAAC;EACD,UAAU,OAAO,EAAE,eAAe;GAChC,IAAI,YAAY,CAAC,QAAQ;IACvB,WAAW,KAAK;IAChB,OAAO,UAAU;GACnB;EACF,CAAC;EACD,aAAa,UAAU,KAAK;CAC9B,GAAG;EAAC;EAAQ;EAAS;EAAU;EAAc;CAAQ,CAAC;CAEtD,OAAO;EAAE;EAAS;CAAS;AAC7B"}
@@ -16,6 +16,16 @@ export type DrawerProps = VariantProps<typeof drawerVariants> & {
16
16
  footer?: ReactNode;
17
17
  /** Elemento que abre o painel (ex.: um `<Button>`); recebe `onPress`. Dispensável no modo controlado. */
18
18
  trigger?: ReactNode;
19
+ /**
20
+ * Arrastar o painel para a borda de onde ele veio fecha. Não toma o gesto da rolagem do corpo
21
+ * (só arrasta com o corpo no começo, no `top`/`bottom`) e o backdrop clareia junto. Padrão: `true`.
22
+ */
23
+ swipeToClose?: boolean;
24
+ /**
25
+ * Barrinha de arraste na borda do painel. Só aparece em `top` e `bottom`, e só com
26
+ * `swipeToClose`. Padrão: `true`.
27
+ */
28
+ showHandle?: boolean;
19
29
  /** Nome acessível do botão de fechar (×). */
20
30
  closeLabel?: string;
21
31
  className?: string;
@@ -25,7 +35,7 @@ export type DrawerProps = VariantProps<typeof drawerVariants> & {
25
35
  * Painel colado a uma borda da tela, que desliza ao abrir e fechar. Usa o `Modal` nativo (botão
26
36
  * voltar do Android e toque no fundo fecham). Não há foco preso nem `Esc`.
27
37
  */
28
- export declare function Drawer({ open, defaultOpen, onOpenChange, title, children, footer, trigger, closeLabel, side, className, testID }: DrawerProps): import("react").JSX.Element;
38
+ export declare function Drawer({ open, defaultOpen, onOpenChange, title, children, footer, trigger, closeLabel, swipeToClose, showHandle, side, className, testID }: DrawerProps): import("react").JSX.Element;
29
39
  /** Fecha o painel ao pressionar. Envolva um `<Button>` do rodapé (injeta `onPress`, como o `asChild`). */
30
40
  export declare function DrawerClose({ children }: {
31
41
  children: ReactNode;
@@ -1,9 +1,11 @@
1
1
  import { cn } from "../../utils/cn.js";
2
2
  import { CloseIcon } from "../_shared/status.js";
3
3
  import { useToken } from "../_shared/use-token.js";
4
+ import { useTransition } from "../_shared/use-transition.js";
4
5
  import { Backdrop } from "../_shared/backdrop.js";
5
6
  import { injectHandler, useOpenState } from "../_shared/overlay-state.js";
6
- import { createContext, useContext, useEffect, useRef, useState } from "react";
7
+ import { useSwipeToClose } from "./use-swipe-to-close.js";
8
+ import { createContext, useContext, useRef } from "react";
7
9
  import { Animated, Modal, Pressable, ScrollView, Text, View, useWindowDimensions } from "react-native";
8
10
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
9
11
  import { cva } from "class-variance-authority";
@@ -31,7 +33,7 @@ const CloseContext = createContext(null);
31
33
  * Painel colado a uma borda da tela, que desliza ao abrir e fechar. Usa o `Modal` nativo (botão
32
34
  * voltar do Android e toque no fundo fecham). Não há foco preso nem `Esc`.
33
35
  */
34
- function Drawer({ open, defaultOpen, onOpenChange, title, children, footer, trigger, closeLabel = "Fechar", side, className, testID }) {
36
+ function Drawer({ open, defaultOpen, onOpenChange, title, children, footer, trigger, closeLabel = "Fechar", swipeToClose = true, showHandle = true, side, className, testID }) {
35
37
  const [isOpen, setOpen] = useOpenState({
36
38
  open,
37
39
  defaultOpen,
@@ -42,25 +44,32 @@ function Drawer({ open, defaultOpen, onOpenChange, title, children, footer, trig
42
44
  const { width, height } = useWindowDimensions();
43
45
  const placement = side ?? "right";
44
46
  const horizontal = placement === "left" || placement === "right";
45
- const [mounted, setMounted] = useState(isOpen);
46
- const progress = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
47
- useEffect(() => {
48
- if (isOpen) setMounted(true);
49
- const animation = Animated.timing(progress, {
50
- toValue: isOpen ? 1 : 0,
51
- duration: 250,
52
- useNativeDriver: true
53
- });
54
- animation.start(({ finished }) => {
55
- if (finished && !isOpen) setMounted(false);
56
- });
57
- return () => animation.stop();
58
- }, [isOpen, progress]);
47
+ const isOpenRef = useRef(isOpen);
48
+ isOpenRef.current = isOpen;
49
+ const swipe = useSwipeToClose({
50
+ side: placement,
51
+ enabled: swipeToClose,
52
+ fallbackSize: horizontal ? width : height,
53
+ onClose: close,
54
+ isOpen: () => isOpenRef.current
55
+ });
56
+ const hasHandle = showHandle && swipeToClose && !horizontal;
57
+ const { mounted, progress } = useTransition(isOpen, {
58
+ duration: 250,
59
+ exitDuration: 250,
60
+ onClosed: swipe.resetDrag
61
+ });
59
62
  const offset = horizontal ? width : height;
60
63
  const sign = placement === "right" || placement === "bottom" ? 1 : -1;
61
- const translate = progress.interpolate({
64
+ const translate = Animated.add(progress.interpolate({
62
65
  inputRange: [0, 1],
63
66
  outputRange: [sign * offset, 0]
67
+ }), Animated.multiply(swipe.drag, sign));
68
+ const handle = hasHandle && /* @__PURE__ */ jsx(View, {
69
+ accessibilityElementsHidden: true,
70
+ importantForAccessibility: "no-hide-descendants",
71
+ className: "h-6 shrink-0 items-center justify-center",
72
+ children: /* @__PURE__ */ jsx(View, { className: "h-1 w-10 rounded-lg bg-border-strong" })
64
73
  });
65
74
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [injectHandler(trigger, "onPress", () => setOpen(true)), /* @__PURE__ */ jsx(Modal, {
66
75
  transparent: true,
@@ -81,13 +90,16 @@ function Drawer({ open, defaultOpen, onOpenChange, title, children, footer, trig
81
90
  left: 0,
82
91
  right: 0,
83
92
  bottom: 0,
84
- opacity: progress
93
+ opacity: Animated.multiply(progress, swipe.dragFade)
85
94
  },
86
95
  children: /* @__PURE__ */ jsx(Backdrop, {
87
96
  onPress: close,
88
97
  testID: testID ? `${testID}-backdrop` : void 0
89
98
  })
90
99
  }), /* @__PURE__ */ jsx(Animated.View, {
100
+ ...swipe.panHandlers,
101
+ onLayout: swipe.onPanelLayout,
102
+ testID: testID ? `${testID}-sheet` : void 0,
91
103
  style: [horizontal ? {
92
104
  width: "85%",
93
105
  maxWidth: 400
@@ -99,8 +111,9 @@ function Drawer({ open, defaultOpen, onOpenChange, title, children, footer, trig
99
111
  testID,
100
112
  className: cn(drawerVariants({ side }), horizontal && "flex-1", className),
101
113
  children: [
114
+ placement === "bottom" && handle,
102
115
  /* @__PURE__ */ jsxs(View, {
103
- className: "flex-row items-center gap-4 border-b border-border-default px-6 py-4",
116
+ className: cn("flex-row items-center gap-4 border-b border-border-default px-6 py-4", hasHandle && placement === "bottom" && "pt-0"),
104
117
  children: [/* @__PURE__ */ jsx(Text, {
105
118
  accessibilityRole: "header",
106
119
  className: "flex-1 text-lg font-semibold leading-6 text-text-primary",
@@ -116,16 +129,18 @@ function Drawer({ open, defaultOpen, onOpenChange, title, children, footer, trig
116
129
  }),
117
130
  /* @__PURE__ */ jsx(ScrollView, {
118
131
  className: "shrink",
119
- contentContainerClassName: "gap-4 p-6",
132
+ contentContainerClassName: cn("gap-4 p-6", hasHandle && placement === "top" && !footer && "pb-2"),
133
+ ...swipe.scrollProps,
120
134
  children: typeof children === "string" ? /* @__PURE__ */ jsx(Text, {
121
135
  className: "text-sm leading-5 text-text-secondary",
122
136
  children
123
137
  }) : children
124
138
  }),
125
139
  footer && /* @__PURE__ */ jsx(View, {
126
- className: "flex-row items-center justify-end gap-2 border-t border-border-default px-6 py-4",
140
+ className: cn("flex-row items-center justify-end gap-2 border-t border-border-default px-6 py-4", hasHandle && placement === "top" && "pb-0"),
127
141
  children: footer
128
- })
142
+ }),
143
+ placement === "top" && handle
129
144
  ]
130
145
  })
131
146
  })]
@@ -1 +1 @@
1
- {"version":3,"file":"drawer.js","names":["RNModal"],"sources":["../../../src/components/drawer/drawer.tsx"],"sourcesContent":["/// <reference types=\"uniwind/types\" />\n\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react'\nimport {\n Animated,\n Pressable,\n Modal as RNModal,\n ScrollView,\n Text,\n useWindowDimensions,\n View,\n} from 'react-native'\nimport { cn } from '../../utils/cn'\nimport { Backdrop } from '../_shared/backdrop'\nimport { injectHandler, useOpenState } from '../_shared/overlay-state'\nimport { CloseIcon } from '../_shared/status'\nimport { useToken } from '../_shared/use-token'\n\nconst drawerVariants = cva('overflow-hidden border-border-default bg-surface-elevated shadow-2xl', {\n variants: {\n side: {\n right: 'border-l',\n left: 'border-r',\n top: 'border-b',\n bottom: 'border-t',\n },\n },\n defaultVariants: { side: 'right' },\n})\n\nconst containerVariants = cva('flex-1', {\n variants: {\n side: {\n right: 'flex-row justify-end',\n left: 'flex-row justify-start',\n top: 'justify-start',\n bottom: 'justify-end',\n },\n },\n defaultVariants: { side: 'right' },\n})\n\nconst CloseContext = createContext<(() => void) | null>(null)\n\ntype Side = 'right' | 'left' | 'top' | 'bottom'\n\nexport type DrawerProps = VariantProps<typeof drawerVariants> & {\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n /** Título do painel; também é o nome acessível dele. */\n title: ReactNode\n /** O corpo do painel. */\n children: ReactNode\n /** Ações do rodapé (primária à direita). Use `DrawerClose` para fechar. Sem ele, não há rodapé. */\n footer?: ReactNode\n /** Elemento que abre o painel (ex.: um `<Button>`); recebe `onPress`. Dispensável no modo controlado. */\n trigger?: ReactNode\n /** Nome acessível do botão de fechar (×). */\n closeLabel?: string\n className?: string\n testID?: string\n}\n\n/**\n * Painel colado a uma borda da tela, que desliza ao abrir e fechar. Usa o `Modal` nativo (botão\n * voltar do Android e toque no fundo fecham). Não há foco preso nem `Esc`.\n */\nexport function Drawer({\n open,\n defaultOpen,\n onOpenChange,\n title,\n children,\n footer,\n trigger,\n closeLabel = 'Fechar',\n side,\n className,\n testID,\n}: DrawerProps) {\n const [isOpen, setOpen] = useOpenState({ open, defaultOpen, onOpenChange })\n const close = () => setOpen(false)\n const iconColor = useToken('--color-text-primary')\n const { width, height } = useWindowDimensions()\n const placement: Side = side ?? 'right'\n const horizontal = placement === 'left' || placement === 'right'\n\n // O Modal fica montado durante a animação de saída.\n const [mounted, setMounted] = useState(isOpen)\n const progress = useRef(new Animated.Value(isOpen ? 1 : 0)).current\n\n useEffect(() => {\n if (isOpen) setMounted(true)\n const animation = Animated.timing(progress, {\n toValue: isOpen ? 1 : 0,\n duration: 250,\n useNativeDriver: true,\n })\n animation.start(({ finished }) => {\n if (finished && !isOpen) setMounted(false)\n })\n return () => animation.stop()\n }, [isOpen, progress])\n\n const offset = horizontal ? width : height\n const sign = placement === 'right' || placement === 'bottom' ? 1 : -1\n const translate = progress.interpolate({ inputRange: [0, 1], outputRange: [sign * offset, 0] })\n\n return (\n <>\n {injectHandler(trigger, 'onPress', () => setOpen(true))}\n <RNModal\n transparent\n statusBarTranslucent\n animationType=\"none\"\n visible={mounted}\n onRequestClose={close}\n >\n <CloseContext.Provider value={close}>\n <View className={containerVariants({ side })} accessibilityViewIsModal>\n <Animated.View\n pointerEvents=\"box-none\"\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n opacity: progress,\n }}\n >\n <Backdrop onPress={close} testID={testID ? `${testID}-backdrop` : undefined} />\n </Animated.View>\n <Animated.View\n style={[\n horizontal ? { width: '85%', maxWidth: 400 } : { width: '100%', maxHeight: '80%' },\n { transform: [horizontal ? { translateX: translate } : { translateY: translate }] },\n ]}\n >\n <View\n testID={testID}\n className={cn(drawerVariants({ side }), horizontal && 'flex-1', className)}\n >\n <View className=\"flex-row items-center gap-4 border-b border-border-default px-6 py-4\">\n <Text\n accessibilityRole=\"header\"\n className=\"flex-1 text-lg font-semibold leading-6 text-text-primary\"\n >\n {title}\n </Text>\n <Pressable\n accessibilityRole=\"button\"\n accessibilityLabel={closeLabel}\n hitSlop={8}\n onPress={close}\n className=\"size-8 items-center justify-center rounded-full active:bg-button-secondary-bg-hover\"\n >\n <CloseIcon color={iconColor} />\n </Pressable>\n </View>\n <ScrollView className=\"shrink\" contentContainerClassName=\"gap-4 p-6\">\n {typeof children === 'string' ? (\n <Text className=\"text-sm leading-5 text-text-secondary\">{children}</Text>\n ) : (\n children\n )}\n </ScrollView>\n {footer && (\n <View className=\"flex-row items-center justify-end gap-2 border-t border-border-default px-6 py-4\">\n {footer}\n </View>\n )}\n </View>\n </Animated.View>\n </View>\n </CloseContext.Provider>\n </RNModal>\n </>\n )\n}\n\n/** Fecha o painel ao pressionar. Envolva um `<Button>` do rodapé (injeta `onPress`, como o `asChild`). */\nexport function DrawerClose({ children }: { children: ReactNode }) {\n const close = useContext(CloseContext)\n return <>{injectHandler(children, 'onPress', () => close?.())}</>\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"drawer.js","names":["RNModal"],"sources":["../../../src/components/drawer/drawer.tsx"],"sourcesContent":["/// <reference types=\"uniwind/types\" />\n\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { createContext, type ReactNode, useContext, useRef } from 'react'\nimport {\n Animated,\n Pressable,\n Modal as RNModal,\n ScrollView,\n Text,\n useWindowDimensions,\n View,\n} from 'react-native'\nimport { cn } from '../../utils/cn'\nimport { Backdrop } from '../_shared/backdrop'\nimport { injectHandler, useOpenState } from '../_shared/overlay-state'\nimport { CloseIcon } from '../_shared/status'\nimport { useToken } from '../_shared/use-token'\nimport { useTransition } from '../_shared/use-transition'\nimport { useSwipeToClose } from './use-swipe-to-close'\n\nconst drawerVariants = cva('overflow-hidden border-border-default bg-surface-elevated shadow-2xl', {\n variants: {\n side: {\n right: 'border-l',\n left: 'border-r',\n top: 'border-b',\n bottom: 'border-t',\n },\n },\n defaultVariants: { side: 'right' },\n})\n\nconst containerVariants = cva('flex-1', {\n variants: {\n side: {\n right: 'flex-row justify-end',\n left: 'flex-row justify-start',\n top: 'justify-start',\n bottom: 'justify-end',\n },\n },\n defaultVariants: { side: 'right' },\n})\n\nconst CloseContext = createContext<(() => void) | null>(null)\n\ntype Side = 'right' | 'left' | 'top' | 'bottom'\n\nexport type DrawerProps = VariantProps<typeof drawerVariants> & {\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n /** Título do painel; também é o nome acessível dele. */\n title: ReactNode\n /** O corpo do painel. */\n children: ReactNode\n /** Ações do rodapé (primária à direita). Use `DrawerClose` para fechar. Sem ele, não há rodapé. */\n footer?: ReactNode\n /** Elemento que abre o painel (ex.: um `<Button>`); recebe `onPress`. Dispensável no modo controlado. */\n trigger?: ReactNode\n /**\n * Arrastar o painel para a borda de onde ele veio fecha. Não toma o gesto da rolagem do corpo\n * (só arrasta com o corpo no começo, no `top`/`bottom`) e o backdrop clareia junto. Padrão: `true`.\n */\n swipeToClose?: boolean\n /**\n * Barrinha de arraste na borda do painel. Só aparece em `top` e `bottom`, e só com\n * `swipeToClose`. Padrão: `true`.\n */\n showHandle?: boolean\n /** Nome acessível do botão de fechar (×). */\n closeLabel?: string\n className?: string\n testID?: string\n}\n\n/**\n * Painel colado a uma borda da tela, que desliza ao abrir e fechar. Usa o `Modal` nativo (botão\n * voltar do Android e toque no fundo fecham). Não há foco preso nem `Esc`.\n */\nexport function Drawer({\n open,\n defaultOpen,\n onOpenChange,\n title,\n children,\n footer,\n trigger,\n closeLabel = 'Fechar',\n swipeToClose = true,\n showHandle = true,\n side,\n className,\n testID,\n}: DrawerProps) {\n const [isOpen, setOpen] = useOpenState({ open, defaultOpen, onOpenChange })\n const close = () => setOpen(false)\n const iconColor = useToken('--color-text-primary')\n const { width, height } = useWindowDimensions()\n const placement: Side = side ?? 'right'\n const horizontal = placement === 'left' || placement === 'right'\n const isOpenRef = useRef(isOpen)\n isOpenRef.current = isOpen\n const swipe = useSwipeToClose({\n side: placement,\n enabled: swipeToClose,\n fallbackSize: horizontal ? width : height,\n onClose: close,\n isOpen: () => isOpenRef.current,\n })\n const hasHandle = showHandle && swipeToClose && !horizontal\n\n // O Modal fica montado durante a animação de saída; o arraste zera quando ele desmonta.\n const { mounted, progress } = useTransition(isOpen, {\n duration: 250,\n exitDuration: 250,\n onClosed: swipe.resetDrag,\n })\n\n const offset = horizontal ? width : height\n const sign = placement === 'right' || placement === 'bottom' ? 1 : -1\n // abrir/fechar + o que o dedo arrastou (`drag` vai no sentido de fechar)\n const translate = Animated.add(\n progress.interpolate({ inputRange: [0, 1], outputRange: [sign * offset, 0] }),\n Animated.multiply(swipe.drag, sign),\n )\n const handle = hasHandle && (\n // Figma \"Drawer/navigation\": traço de 4px em `border/strong` numa faixa de 24px (variante xl, 40px)\n <View\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n className=\"h-6 shrink-0 items-center justify-center\"\n >\n <View className=\"h-1 w-10 rounded-lg bg-border-strong\" />\n </View>\n )\n\n return (\n <>\n {injectHandler(trigger, 'onPress', () => setOpen(true))}\n <RNModal\n transparent\n statusBarTranslucent\n animationType=\"none\"\n visible={mounted}\n onRequestClose={close}\n >\n <CloseContext.Provider value={close}>\n <View className={containerVariants({ side })} accessibilityViewIsModal>\n <Animated.View\n pointerEvents=\"box-none\"\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n opacity: Animated.multiply(progress, swipe.dragFade),\n }}\n >\n <Backdrop onPress={close} testID={testID ? `${testID}-backdrop` : undefined} />\n </Animated.View>\n <Animated.View\n {...swipe.panHandlers}\n onLayout={swipe.onPanelLayout}\n testID={testID ? `${testID}-sheet` : undefined}\n style={[\n horizontal ? { width: '85%', maxWidth: 400 } : { width: '100%', maxHeight: '80%' },\n { transform: [horizontal ? { translateX: translate } : { translateY: translate }] },\n ]}\n >\n <View\n testID={testID}\n className={cn(drawerVariants({ side }), horizontal && 'flex-1', className)}\n >\n {placement === 'bottom' && handle}\n {/* ao lado da barrinha, o conteúdo vizinho encosta na faixa de 24px (sem somar o padding) */}\n <View\n className={cn(\n 'flex-row items-center gap-4 border-b border-border-default px-6 py-4',\n hasHandle && placement === 'bottom' && 'pt-0',\n )}\n >\n <Text\n accessibilityRole=\"header\"\n className=\"flex-1 text-lg font-semibold leading-6 text-text-primary\"\n >\n {title}\n </Text>\n <Pressable\n accessibilityRole=\"button\"\n accessibilityLabel={closeLabel}\n hitSlop={8}\n onPress={close}\n className=\"size-8 items-center justify-center rounded-full active:bg-button-secondary-bg-hover\"\n >\n <CloseIcon color={iconColor} />\n </Pressable>\n </View>\n <ScrollView\n className=\"shrink\"\n contentContainerClassName={cn(\n 'gap-4 p-6',\n hasHandle && placement === 'top' && !footer && 'pb-2',\n )}\n {...swipe.scrollProps}\n >\n {typeof children === 'string' ? (\n <Text className=\"text-sm leading-5 text-text-secondary\">{children}</Text>\n ) : (\n children\n )}\n </ScrollView>\n {footer && (\n <View\n className={cn(\n 'flex-row items-center justify-end gap-2 border-t border-border-default px-6 py-4',\n hasHandle && placement === 'top' && 'pb-0',\n )}\n >\n {footer}\n </View>\n )}\n {placement === 'top' && handle}\n </View>\n </Animated.View>\n </View>\n </CloseContext.Provider>\n </RNModal>\n </>\n )\n}\n\n/** Fecha o painel ao pressionar. Envolva um `<Button>` do rodapé (injeta `onPress`, como o `asChild`). */\nexport function DrawerClose({ children }: { children: ReactNode }) {\n const close = useContext(CloseContext)\n return <>{injectHandler(children, 'onPress', () => close?.())}</>\n}\n"],"mappings":"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"}
@@ -0,0 +1,143 @@
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
+ import { Animated, PanResponder } from "react-native";
3
+ //#region src/components/drawer/use-swipe-to-close.ts
4
+ /** Eixo do arraste e sinal do movimento do dedo que fecha (em direção à borda do `side`). */
5
+ const closing = {
6
+ right: {
7
+ axis: "x",
8
+ sign: 1
9
+ },
10
+ left: {
11
+ axis: "x",
12
+ sign: -1
13
+ },
14
+ bottom: {
15
+ axis: "y",
16
+ sign: 1
17
+ },
18
+ top: {
19
+ axis: "y",
20
+ sign: -1
21
+ }
22
+ };
23
+ /** Fração do painel que precisa sair, ou a velocidade (px/ms) do gesto, para soltar e fechar. */
24
+ const CLOSE_DISTANCE = .4;
25
+ const CLOSE_VELOCITY = .5;
26
+ /** Movimento mínimo (px) antes de o painel tomar o gesto. */
27
+ const SLOP = 6;
28
+ /**
29
+ * Fecha o painel arrastando-o para a borda de onde ele veio (mesmo comportamento do web).
30
+ *
31
+ * `PanResponder` na fase de captura, para tomar o gesto do `ScrollView` do corpo, mas só quando o
32
+ * dedo vai no sentido de fechar, o gesto é mais ao longo do eixo do que de lado e o corpo não rola
33
+ * mais naquele sentido (no `bottom`, rolagem no topo; no `top`, no fim). Como a captura é refeita a
34
+ * cada movimento, um gesto que começou rolando passa ao painel quando o conteúdo chega à borda; o
35
+ * arraste conta daí (o grant zera `dx`/`dy`), sem salto. O `Slider` não perde o toque: ele não aceita ser
36
+ * tomado (`onResponderTerminationRequest` retorna `false`).
37
+ *
38
+ * Devolve `drag` (px arrastados, ≥ 0) e `dragFade` (1 → 0 conforme o painel sai, para o backdrop),
39
+ * os `panHandlers` do painel, o `onLayout` que mede o painel e as props do `ScrollView` do corpo.
40
+ */
41
+ function useSwipeToClose({ side, enabled, fallbackSize, onClose, isOpen }) {
42
+ const drag = useRef(new Animated.Value(0)).current;
43
+ const [panelSize, setPanelSize] = useState(fallbackSize);
44
+ const size = useRef(fallbackSize);
45
+ const scroll = useRef({
46
+ offset: 0,
47
+ content: 0,
48
+ layout: 0
49
+ });
50
+ const state = useRef({ offset: 0 });
51
+ const latest = useRef({
52
+ side,
53
+ enabled,
54
+ onClose,
55
+ isOpen
56
+ });
57
+ latest.current = {
58
+ side,
59
+ enabled,
60
+ onClose,
61
+ isOpen
62
+ };
63
+ const reset = useCallback(() => {
64
+ state.current.offset = 0;
65
+ Animated.timing(drag, {
66
+ toValue: 0,
67
+ duration: 200,
68
+ useNativeDriver: true
69
+ }).start();
70
+ }, [drag]);
71
+ const panResponder = useMemo(() => {
72
+ const delta = (dx, dy) => {
73
+ const { axis, sign } = closing[latest.current.side];
74
+ return {
75
+ main: (axis === "x" ? dx : dy) * sign,
76
+ cross: axis === "x" ? dy : dx
77
+ };
78
+ };
79
+ const bodyScrolls = () => {
80
+ const { axis } = closing[latest.current.side];
81
+ if (axis === "x") return false;
82
+ const { offset, content, layout } = scroll.current;
83
+ return latest.current.side === "bottom" ? offset > 1 : offset + layout < content - 1;
84
+ };
85
+ return PanResponder.create({
86
+ onMoveShouldSetPanResponderCapture: (_, g) => {
87
+ if (!latest.current.enabled) return false;
88
+ const { main, cross } = delta(g.dx, g.dy);
89
+ if (main <= SLOP || Math.abs(main) <= Math.abs(cross) || bodyScrolls()) return false;
90
+ return true;
91
+ },
92
+ onPanResponderTerminationRequest: () => false,
93
+ onPanResponderMove: (_, g) => {
94
+ const { main } = delta(g.dx, g.dy);
95
+ state.current.offset = Math.max(0, main);
96
+ drag.setValue(state.current.offset);
97
+ },
98
+ onPanResponderRelease: (_, g) => {
99
+ const { axis, sign } = closing[latest.current.side];
100
+ const velocity = (axis === "x" ? g.vx : g.vy) * sign;
101
+ if (state.current.offset > size.current * .4 || velocity > .5) {
102
+ latest.current.onClose();
103
+ requestAnimationFrame(() => latest.current.isOpen() && reset());
104
+ } else reset();
105
+ },
106
+ onPanResponderTerminate: reset
107
+ });
108
+ }, [drag, reset]);
109
+ return {
110
+ drag,
111
+ dragFade: drag.interpolate({
112
+ inputRange: [0, Math.max(panelSize, 1)],
113
+ outputRange: [1, 0],
114
+ extrapolate: "clamp"
115
+ }),
116
+ panHandlers: panResponder.panHandlers,
117
+ onPanelLayout: (e) => {
118
+ const { width, height } = e.nativeEvent.layout;
119
+ const next = (closing[latest.current.side].axis === "x" ? width : height) || size.current;
120
+ size.current = next;
121
+ setPanelSize(next);
122
+ },
123
+ scrollProps: {
124
+ scrollEventThrottle: 16,
125
+ onScroll: (e) => {
126
+ scroll.current.offset = e.nativeEvent.contentOffset.y;
127
+ scroll.current.content = e.nativeEvent.contentSize.height;
128
+ scroll.current.layout = e.nativeEvent.layoutMeasurement.height;
129
+ },
130
+ onContentSizeChange: (_w, h) => {
131
+ scroll.current.content = h;
132
+ },
133
+ onLayout: (e) => {
134
+ scroll.current.layout = e.nativeEvent.layout.height;
135
+ }
136
+ },
137
+ resetDrag: useCallback(() => drag.setValue(0), [drag])
138
+ };
139
+ }
140
+ //#endregion
141
+ export { CLOSE_DISTANCE, CLOSE_VELOCITY, useSwipeToClose };
142
+
143
+ //# sourceMappingURL=use-swipe-to-close.js.map