@innovui/react-native 0.1.0 → 0.3.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 (58) hide show
  1. package/README.md +1 -0
  2. package/dist/components/_shared/anchored-overlay.js +37 -12
  3. package/dist/components/_shared/anchored-overlay.js.map +1 -1
  4. package/dist/components/_shared/media-icons.js +125 -0
  5. package/dist/components/_shared/media-icons.js.map +1 -0
  6. package/dist/components/_shared/media.js +114 -0
  7. package/dist/components/_shared/media.js.map +1 -0
  8. package/dist/components/_shared/sliding-indicator.js +98 -0
  9. package/dist/components/_shared/sliding-indicator.js.map +1 -0
  10. package/dist/components/_shared/use-transition.js +53 -0
  11. package/dist/components/_shared/use-transition.js.map +1 -0
  12. package/dist/components/audio-player/audio-player.d.ts +34 -0
  13. package/dist/components/audio-player/audio-player.js +89 -0
  14. package/dist/components/audio-player/audio-player.js.map +1 -0
  15. package/dist/components/audio-player/index.d.ts +2 -0
  16. package/dist/components/carousel/carousel.d.ts +34 -0
  17. package/dist/components/carousel/carousel.js +110 -0
  18. package/dist/components/carousel/carousel.js.map +1 -0
  19. package/dist/components/carousel/index.d.ts +2 -0
  20. package/dist/components/drawer/drawer.d.ts +11 -1
  21. package/dist/components/drawer/drawer.js +37 -22
  22. package/dist/components/drawer/drawer.js.map +1 -1
  23. package/dist/components/drawer/use-swipe-to-close.js +143 -0
  24. package/dist/components/drawer/use-swipe-to-close.js.map +1 -0
  25. package/dist/components/gallery/gallery.d.ts +21 -0
  26. package/dist/components/gallery/gallery.js +54 -0
  27. package/dist/components/gallery/gallery.js.map +1 -0
  28. package/dist/components/gallery/index.d.ts +2 -0
  29. package/dist/components/input/input.d.ts +8 -2
  30. package/dist/components/input/input.js +10 -2
  31. package/dist/components/input/input.js.map +1 -1
  32. package/dist/components/lightbox/lightbox.d.ts +30 -0
  33. package/dist/components/lightbox/lightbox.js +147 -0
  34. package/dist/components/lightbox/lightbox.js.map +1 -0
  35. package/dist/components/modal/modal.js +59 -39
  36. package/dist/components/modal/modal.js.map +1 -1
  37. package/dist/components/segmented-control/segmented-control.d.ts +2 -2
  38. package/dist/components/segmented-control/segmented-control.js +20 -7
  39. package/dist/components/segmented-control/segmented-control.js.map +1 -1
  40. package/dist/components/tabs/tabs.d.ts +2 -2
  41. package/dist/components/tabs/tabs.js +32 -9
  42. package/dist/components/tabs/tabs.js.map +1 -1
  43. package/dist/components/toast/toast.js +66 -5
  44. package/dist/components/toast/toast.js.map +1 -1
  45. package/dist/components/tooltip/tooltip.d.ts +7 -3
  46. package/dist/components/tooltip/tooltip.js +10 -4
  47. package/dist/components/tooltip/tooltip.js.map +1 -1
  48. package/dist/components/video-player/index.d.ts +2 -0
  49. package/dist/components/video-player/video-player.d.ts +37 -0
  50. package/dist/components/video-player/video-player.js +178 -0
  51. package/dist/components/video-player/video-player.js.map +1 -0
  52. package/dist/index.d.ts +10 -1
  53. package/dist/index.js +6 -1
  54. package/dist/tokens/generated.d.ts +8 -0
  55. package/dist/tokens/generated.js +10 -2
  56. package/dist/tokens/generated.js.map +1 -1
  57. package/package.json +1 -1
  58. package/theme.css +8 -0
@@ -0,0 +1,89 @@
1
+ import { cn } from "../../utils/cn.js";
2
+ import { useToken } from "../_shared/use-token.js";
3
+ import { DEFAULT_WAVEFORM, formatTime, resamplePeaks, useSeek } from "../_shared/media.js";
4
+ import { PauseIcon, PlayIcon } from "../_shared/media-icons.js";
5
+ import { useState } from "react";
6
+ import { Pressable, Text, View } from "react-native";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/components/audio-player/audio-player.tsx
9
+ const BAR_MIN = 6;
10
+ const BAR_RANGE = 28;
11
+ /**
12
+ * Casca de player de áudio: botão, onda, tempo e velocidade. Não toca nada: quem usa guarda o
13
+ * estado (por exemplo com `expo-audio`) e o passa por props, ligando `onPlayPause`, `onSeek` e
14
+ * `onRateChange` ao player de verdade.
15
+ */
16
+ function AudioPlayer({ className, label = "Áudio", playing = false, position = 0, duration = 0, rate, speeds = [
17
+ 1,
18
+ 1.5,
19
+ 2
20
+ ], waveform, bars = 52, onPlayPause, onSeek, onRateChange, ...props }) {
21
+ const [chosen, setChosen] = useState(speeds[0] ?? 1);
22
+ const speed = rate ?? chosen;
23
+ const peaks = resamplePeaks(waveform ?? DEFAULT_WAVEFORM, bars);
24
+ const played = duration ? Math.round(position / duration * bars) : 0;
25
+ const iconColor = useToken("--color-media-on-surface");
26
+ const seek = useSeek({
27
+ position,
28
+ duration,
29
+ label: "Posição do áudio",
30
+ onSeek
31
+ });
32
+ function nextSpeed() {
33
+ const next = speeds[(speeds.indexOf(speed) + 1) % speeds.length] ?? 1;
34
+ setChosen(next);
35
+ onRateChange?.(next);
36
+ }
37
+ return /* @__PURE__ */ jsxs(View, {
38
+ accessibilityLabel: label,
39
+ className: cn("w-full max-w-[480px] flex-row items-center gap-3 rounded-lg border border-border-default bg-surface-elevated p-4 shadow-sm", className),
40
+ ...props,
41
+ children: [
42
+ /* @__PURE__ */ jsx(Pressable, {
43
+ accessibilityRole: "button",
44
+ accessibilityLabel: playing ? "Pausar" : "Reproduzir",
45
+ onPress: onPlayPause,
46
+ className: "size-11 shrink-0 items-center justify-center rounded-full bg-button-primary-bg active:bg-button-primary-bg-active",
47
+ children: playing ? /* @__PURE__ */ jsx(PauseIcon, { color: iconColor }) : /* @__PURE__ */ jsx(PlayIcon, { color: iconColor })
48
+ }),
49
+ /* @__PURE__ */ jsxs(View, {
50
+ className: "min-w-0 flex-1 flex-row items-center gap-2",
51
+ children: [/* @__PURE__ */ jsx(View, {
52
+ ...seek.props,
53
+ className: "h-[34px] min-w-0 flex-1 justify-center",
54
+ children: /* @__PURE__ */ jsx(View, {
55
+ pointerEvents: "none",
56
+ className: "h-full flex-row items-center gap-0.5",
57
+ children: peaks.map((peak, i) => /* @__PURE__ */ jsx(View, {
58
+ className: cn("min-w-0.5 flex-1 rounded-full", i < played ? "bg-button-primary-bg" : "bg-media-wave-rest"),
59
+ style: { height: BAR_MIN + peak * BAR_RANGE }
60
+ }, i))
61
+ })
62
+ }), /* @__PURE__ */ jsxs(Text, {
63
+ accessible: false,
64
+ className: "shrink-0 text-xs font-medium leading-4 text-text-secondary",
65
+ style: { fontVariant: ["tabular-nums"] },
66
+ children: [
67
+ formatTime(position),
68
+ " / ",
69
+ formatTime(duration)
70
+ ]
71
+ })]
72
+ }),
73
+ /* @__PURE__ */ jsx(Pressable, {
74
+ accessibilityRole: "button",
75
+ accessibilityLabel: `Velocidade de reprodução: ${speed}x`,
76
+ onPress: nextSpeed,
77
+ className: "h-8 w-16 shrink-0 items-center justify-center rounded-full border border-border-default bg-surface-elevated active:bg-bg-tertiary",
78
+ children: /* @__PURE__ */ jsxs(Text, {
79
+ className: "text-sm font-medium leading-5 text-text-secondary",
80
+ children: [speed, "x"]
81
+ })
82
+ })
83
+ ]
84
+ });
85
+ }
86
+ //#endregion
87
+ export { AudioPlayer };
88
+
89
+ //# sourceMappingURL=audio-player.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio-player.js","names":[],"sources":["../../../src/components/audio-player/audio-player.tsx"],"sourcesContent":["/// <reference types=\"uniwind/types\" />\n\nimport { useState } from 'react'\nimport { Pressable, Text, View, type ViewProps } from 'react-native'\nimport { cn } from '../../utils/cn'\nimport { DEFAULT_WAVEFORM, formatTime, resamplePeaks, useSeek } from '../_shared/media'\nimport { PauseIcon, PlayIcon } from '../_shared/media-icons'\nimport { useToken } from '../_shared/use-token'\n\n// Alturas das barras no Figma: de 6px (silêncio) a 34px (pico).\nconst BAR_MIN = 6\nconst BAR_RANGE = 28\n\nexport type AudioPlayerProps = Omit<ViewProps, 'children' | 'style'> & {\n className?: string\n /** Nome acessível do player. */\n label?: string\n /** Tocando agora: mostra o botão de pausar. */\n playing?: boolean\n /** Posição atual, em segundos. */\n position?: number\n /** Duração total, em segundos. */\n duration?: number\n /** Velocidade atual (controlado). Sem ela, o player guarda a escolhida. */\n rate?: number\n /** Velocidades do botão (cada toque passa para a próxima). Padrão: 1x, 1.5x e 2x. */\n speeds?: number[]\n /** Picos da onda, de 0 a 1. Sem eles, usa uma onda padrão. */\n waveform?: number[]\n /** Quantidade de barras da onda. Padrão: 52. */\n bars?: number\n onPlayPause?: () => void\n /** Chamado com a posição (em segundos) tocada ou arrastada na onda. */\n onSeek?: (seconds: number) => void\n /** Chamado com a nova velocidade quando o botão é tocado. */\n onRateChange?: (rate: number) => void\n}\n\n/**\n * Casca de player de áudio: botão, onda, tempo e velocidade. Não toca nada: quem usa guarda o\n * estado (por exemplo com `expo-audio`) e o passa por props, ligando `onPlayPause`, `onSeek` e\n * `onRateChange` ao player de verdade.\n */\nexport function AudioPlayer({\n className,\n label = 'Áudio',\n playing = false,\n position = 0,\n duration = 0,\n rate,\n speeds = [1, 1.5, 2],\n waveform,\n bars = 52,\n onPlayPause,\n onSeek,\n onRateChange,\n ...props\n}: AudioPlayerProps) {\n const [chosen, setChosen] = useState(speeds[0] ?? 1)\n const speed = rate ?? chosen\n const peaks = resamplePeaks(waveform ?? DEFAULT_WAVEFORM, bars)\n const played = duration ? Math.round((position / duration) * bars) : 0\n const iconColor = useToken('--color-media-on-surface')\n const seek = useSeek({ position, duration, label: 'Posição do áudio', onSeek })\n\n function nextSpeed() {\n const next = speeds[(speeds.indexOf(speed) + 1) % speeds.length] ?? 1\n setChosen(next)\n onRateChange?.(next)\n }\n\n return (\n <View\n accessibilityLabel={label}\n className={cn(\n 'w-full max-w-[480px] flex-row items-center gap-3 rounded-lg border border-border-default bg-surface-elevated p-4 shadow-sm',\n className,\n )}\n {...props}\n >\n <Pressable\n accessibilityRole=\"button\"\n accessibilityLabel={playing ? 'Pausar' : 'Reproduzir'}\n onPress={onPlayPause}\n className=\"size-11 shrink-0 items-center justify-center rounded-full bg-button-primary-bg active:bg-button-primary-bg-active\"\n >\n {playing ? <PauseIcon color={iconColor} /> : <PlayIcon color={iconColor} />}\n </Pressable>\n <View className=\"min-w-0 flex-1 flex-row items-center gap-2\">\n <View {...seek.props} className=\"h-[34px] min-w-0 flex-1 justify-center\">\n {/* Filhos ignoram toques para que `locationX` seja sempre relativo à onda. */}\n <View pointerEvents=\"none\" className=\"h-full flex-row items-center gap-0.5\">\n {peaks.map((peak, i) => (\n <View\n // biome-ignore lint/suspicious/noArrayIndexKey: as barras são posicionais e fixas\n key={i}\n className={cn(\n 'min-w-0.5 flex-1 rounded-full',\n i < played ? 'bg-button-primary-bg' : 'bg-media-wave-rest',\n )}\n style={{ height: BAR_MIN + peak * BAR_RANGE }}\n />\n ))}\n </View>\n </View>\n <Text\n accessible={false}\n className=\"shrink-0 text-xs font-medium leading-4 text-text-secondary\"\n style={{ fontVariant: ['tabular-nums'] }}\n >\n {formatTime(position)} / {formatTime(duration)}\n </Text>\n </View>\n <Pressable\n accessibilityRole=\"button\"\n accessibilityLabel={`Velocidade de reprodução: ${speed}x`}\n onPress={nextSpeed}\n className=\"h-8 w-16 shrink-0 items-center justify-center rounded-full border border-border-default bg-surface-elevated active:bg-bg-tertiary\"\n >\n <Text className=\"text-sm font-medium leading-5 text-text-secondary\">{speed}x</Text>\n </Pressable>\n </View>\n )\n}\n"],"mappings":";;;;;;;;AAUA,MAAM,UAAU;AAChB,MAAM,YAAY;;;;;;AAgClB,SAAgB,YAAY,EAC1B,WACA,QAAQ,SACR,UAAU,OACV,WAAW,GACX,WAAW,GACX,MACA,SAAS;CAAC;CAAG;CAAK;AAAC,GACnB,UACA,OAAO,IACP,aACA,QACA,cACA,GAAG,SACgB;CACnB,MAAM,CAAC,QAAQ,aAAa,SAAS,OAAO,MAAM,CAAC;CACnD,MAAM,QAAQ,QAAQ;CACtB,MAAM,QAAQ,cAAc,YAAY,kBAAkB,IAAI;CAC9D,MAAM,SAAS,WAAW,KAAK,MAAO,WAAW,WAAY,IAAI,IAAI;CACrE,MAAM,YAAY,SAAS,0BAA0B;CACrD,MAAM,OAAO,QAAQ;EAAE;EAAU;EAAU,OAAO;EAAoB;CAAO,CAAC;CAE9E,SAAS,YAAY;EACnB,MAAM,OAAO,QAAQ,OAAO,QAAQ,KAAK,IAAI,KAAK,OAAO,WAAW;EACpE,UAAU,IAAI;EACd,eAAe,IAAI;CACrB;CAEA,OACE,qBAAC,MAAD;EACE,oBAAoB;EACpB,WAAW,GACT,8HACA,SACF;EACA,GAAI;EANN,UAAA;GAQE,oBAAC,WAAD;IACE,mBAAkB;IAClB,oBAAoB,UAAU,WAAW;IACzC,SAAS;IACT,WAAU;IAET,UAAA,UAAU,oBAAC,WAAD,EAAW,OAAO,UAAY,CAAA,IAAI,oBAAC,UAAD,EAAU,OAAO,UAAY,CAAA;GACjE,CAAA;GACX,qBAAC,MAAD;IAAM,WAAU;IAAhB,UAAA,CACE,oBAAC,MAAD;KAAM,GAAI,KAAK;KAAO,WAAU;KAE9B,UAAA,oBAAC,MAAD;MAAM,eAAc;MAAO,WAAU;MAClC,UAAA,MAAM,KAAK,MAAM,MAChB,oBAAC,MAAD;OAGE,WAAW,GACT,iCACA,IAAI,SAAS,yBAAyB,oBACxC;OACA,OAAO,EAAE,QAAQ,UAAU,OAAO,UAAU;MAC7C,GANM,CAMN,CACF;KACG,CAAA;IACF,CAAA,GACN,qBAAC,MAAD;KACE,YAAY;KACZ,WAAU;KACV,OAAO,EAAE,aAAa,CAAC,cAAc,EAAE;KAHzC,UAAA;MAKG,WAAW,QAAQ;MAAE;MAAI,WAAW,QAAQ;KACzC;IACF,CAAA,CAAA;;GACN,oBAAC,WAAD;IACE,mBAAkB;IAClB,oBAAoB,6BAA6B,MAAM;IACvD,SAAS;IACT,WAAU;IAEV,UAAA,qBAAC,MAAD;KAAM,WAAU;KAAhB,UAAA,CAAqE,OAAM,GAAO;;GACzE,CAAA;EACP;;AAEV"}
@@ -0,0 +1,2 @@
1
+ import { AudioPlayer, AudioPlayerProps } from "./audio-player.js";
2
+ export { AudioPlayer, type AudioPlayerProps };
@@ -0,0 +1,34 @@
1
+ import { ReactNode } from "react";
2
+ import { ViewProps } from "react-native";
3
+ //#region src/components/carousel/carousel.d.ts
4
+ export type CarouselProps = Omit<ViewProps, 'children' | 'style'> & {
5
+ className?: string;
6
+ /** Nome acessível do carrossel (ex.: "Modelos de mensagem"). */
7
+ label: string;
8
+ /** Os itens: use `CarouselItem`. */
9
+ children: ReactNode;
10
+ /** Mostra os pontos de posição. Padrão: `true`. */
11
+ showDots?: boolean;
12
+ /** Mostra as setas anterior/próximo. Padrão: `true`. */
13
+ showArrows?: boolean;
14
+ /** Largura de cada item, de 0 a 1 da largura do carrossel. Padrão: 0.78 no celular e 0.52 a partir de 768px de tela. */
15
+ itemRatio?: number;
16
+ /** Chamado com o índice do item em destaque sempre que ele muda. */
17
+ onIndexChange?: (index: number) => void;
18
+ previousLabel?: string;
19
+ nextLabel?: string;
20
+ /** Monta o nome acessível de cada ponto. Padrão: "Ir para o item N". */
21
+ dotLabel?: (index: number) => string;
22
+ };
23
+ /**
24
+ * Fileira de itens que desliza com o dedo e encaixa item a item, com pontos e setas. Mostra um
25
+ * pedaço do próximo item. O último item "chega ao fim" mesmo que não caiba alinhado à esquerda:
26
+ * o ponto e a seta refletem isso.
27
+ */
28
+ export declare function Carousel({ className, label, children, showDots, showArrows, itemRatio, onIndexChange, previousLabel, nextLabel, dotLabel, ...props }: CarouselProps): import("react").JSX.Element;
29
+ /** Um item do `Carousel`. A largura vem do carrossel (`itemRatio`); o item preenche toda a altura. */
30
+ export declare function CarouselItem({ className, ...props }: Omit<ViewProps, 'style'> & {
31
+ className?: string;
32
+ }): import("react").JSX.Element;
33
+ //#endregion
34
+ //# sourceMappingURL=carousel.d.ts.map
@@ -0,0 +1,110 @@
1
+ import { cn } from "../../utils/cn.js";
2
+ import { useToken } from "../_shared/use-token.js";
3
+ import { IconButton } from "../icon-button/icon-button.js";
4
+ import { NextIcon, PreviousIcon } from "../_shared/media-icons.js";
5
+ import { Children, useRef, useState } from "react";
6
+ import { Pressable, ScrollView, View, useWindowDimensions } from "react-native";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/components/carousel/carousel.tsx
9
+ const GAP = 16;
10
+ const TABLET = 768;
11
+ /**
12
+ * Fileira de itens que desliza com o dedo e encaixa item a item, com pontos e setas. Mostra um
13
+ * pedaço do próximo item. O último item "chega ao fim" mesmo que não caiba alinhado à esquerda:
14
+ * o ponto e a seta refletem isso.
15
+ */
16
+ function Carousel({ className, label, children, showDots = true, showArrows = true, itemRatio, onIndexChange, previousLabel = "Item anterior", nextLabel = "Próximo item", dotLabel = (index) => `Ir para o item ${index + 1}`, ...props }) {
17
+ const items = Children.toArray(children);
18
+ const count = items.length;
19
+ const scroll = useRef(null);
20
+ const { width: screen } = useWindowDimensions();
21
+ const [width, setWidth] = useState(0);
22
+ const [active, setActive] = useState(0);
23
+ const arrowColor = useToken("--color-text-primary");
24
+ const arrowDisabled = useToken("--color-button-disabled-text");
25
+ const itemWidth = width * (itemRatio ?? (screen >= TABLET ? .52 : .78));
26
+ const step = itemWidth + GAP;
27
+ const max = Math.max(count * step - GAP - width, 0);
28
+ const offsets = Array.from({ length: count }, (_, i) => Math.min(i * step, max));
29
+ function indexAt(x) {
30
+ if (max > 0 && x >= max - 1) return count - 1;
31
+ let best = 0;
32
+ offsets.forEach((offset, i) => {
33
+ if (Math.abs(offset - x) < Math.abs((offsets[best] ?? 0) - x)) best = i;
34
+ });
35
+ return best;
36
+ }
37
+ function onScroll(event) {
38
+ const next = indexAt(event.nativeEvent.contentOffset.x);
39
+ if (next === active) return;
40
+ setActive(next);
41
+ onIndexChange?.(next);
42
+ }
43
+ function goTo(index) {
44
+ const target = Math.min(Math.max(index, 0), count - 1);
45
+ scroll.current?.scrollTo({
46
+ x: offsets[target] ?? 0,
47
+ animated: true
48
+ });
49
+ }
50
+ return /* @__PURE__ */ jsxs(View, {
51
+ className: cn("gap-6", className),
52
+ ...props,
53
+ children: [/* @__PURE__ */ jsx(ScrollView, {
54
+ ref: scroll,
55
+ horizontal: true,
56
+ accessibilityLabel: label,
57
+ showsHorizontalScrollIndicator: false,
58
+ decelerationRate: "fast",
59
+ snapToOffsets: offsets,
60
+ scrollEventThrottle: 16,
61
+ onLayout: (event) => setWidth(event.nativeEvent.layout.width),
62
+ onScroll,
63
+ contentContainerClassName: "gap-4",
64
+ children: items.map((child, i) => /* @__PURE__ */ jsx(View, {
65
+ accessibilityLabel: `${i + 1} de ${count}`,
66
+ style: { width: itemWidth },
67
+ children: child
68
+ }, i))
69
+ }), (showDots || showArrows) && count > 0 && /* @__PURE__ */ jsxs(View, {
70
+ className: "flex-row items-center justify-between",
71
+ children: [showDots ? /* @__PURE__ */ jsx(View, {
72
+ className: "flex-row items-center gap-2",
73
+ children: items.map((_, i) => /* @__PURE__ */ jsx(Pressable, {
74
+ accessibilityRole: "button",
75
+ accessibilityLabel: dotLabel(i),
76
+ accessibilityState: { selected: i === active },
77
+ onPress: () => goTo(i),
78
+ className: "h-11 items-center justify-center",
79
+ children: /* @__PURE__ */ jsx(View, { className: cn("h-2 rounded-full", i === active ? "w-6 bg-interactive-primary" : "w-2 bg-border-strong") })
80
+ }, i))
81
+ }) : /* @__PURE__ */ jsx(View, {}), showArrows && /* @__PURE__ */ jsxs(View, {
82
+ className: "flex-row gap-2",
83
+ children: [/* @__PURE__ */ jsx(IconButton, {
84
+ accessibilityLabel: previousLabel,
85
+ variant: "tertiary",
86
+ disabled: active <= 0,
87
+ onPress: () => goTo(active - 1),
88
+ children: /* @__PURE__ */ jsx(PreviousIcon, { color: active <= 0 ? arrowDisabled : arrowColor })
89
+ }), /* @__PURE__ */ jsx(IconButton, {
90
+ accessibilityLabel: nextLabel,
91
+ variant: "tertiary",
92
+ disabled: active >= count - 1,
93
+ onPress: () => goTo(active + 1),
94
+ children: /* @__PURE__ */ jsx(NextIcon, { color: active >= count - 1 ? arrowDisabled : arrowColor })
95
+ })]
96
+ })]
97
+ })]
98
+ });
99
+ }
100
+ /** Um item do `Carousel`. A largura vem do carrossel (`itemRatio`); o item preenche toda a altura. */
101
+ function CarouselItem({ className, ...props }) {
102
+ return /* @__PURE__ */ jsx(View, {
103
+ className: cn("flex-1", className),
104
+ ...props
105
+ });
106
+ }
107
+ //#endregion
108
+ export { Carousel, CarouselItem };
109
+
110
+ //# sourceMappingURL=carousel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"carousel.js","names":[],"sources":["../../../src/components/carousel/carousel.tsx"],"sourcesContent":["/// <reference types=\"uniwind/types\" />\n\nimport { Children, type ReactNode, useRef, useState } from 'react'\nimport {\n type LayoutChangeEvent,\n type NativeScrollEvent,\n type NativeSyntheticEvent,\n Pressable,\n ScrollView,\n useWindowDimensions,\n View,\n type ViewProps,\n} from 'react-native'\nimport { cn } from '../../utils/cn'\nimport { NextIcon, PreviousIcon } from '../_shared/media-icons'\nimport { useToken } from '../_shared/use-token'\nimport { IconButton } from '../icon-button'\n\nconst GAP = 16\nconst TABLET = 768\n\nexport type CarouselProps = Omit<ViewProps, 'children' | 'style'> & {\n className?: string\n /** Nome acessível do carrossel (ex.: \"Modelos de mensagem\"). */\n label: string\n /** Os itens: use `CarouselItem`. */\n children: ReactNode\n /** Mostra os pontos de posição. Padrão: `true`. */\n showDots?: boolean\n /** Mostra as setas anterior/próximo. Padrão: `true`. */\n showArrows?: boolean\n /** Largura de cada item, de 0 a 1 da largura do carrossel. Padrão: 0.78 no celular e 0.52 a partir de 768px de tela. */\n itemRatio?: number\n /** Chamado com o índice do item em destaque sempre que ele muda. */\n onIndexChange?: (index: number) => void\n previousLabel?: string\n nextLabel?: string\n /** Monta o nome acessível de cada ponto. Padrão: \"Ir para o item N\". */\n dotLabel?: (index: number) => string\n}\n\n/**\n * Fileira de itens que desliza com o dedo e encaixa item a item, com pontos e setas. Mostra um\n * pedaço do próximo item. O último item \"chega ao fim\" mesmo que não caiba alinhado à esquerda:\n * o ponto e a seta refletem isso.\n */\nexport function Carousel({\n className,\n label,\n children,\n showDots = true,\n showArrows = true,\n itemRatio,\n onIndexChange,\n previousLabel = 'Item anterior',\n nextLabel = 'Próximo item',\n dotLabel = (index) => `Ir para o item ${index + 1}`,\n ...props\n}: CarouselProps) {\n const items = Children.toArray(children)\n const count = items.length\n const scroll = useRef<ScrollView>(null)\n const { width: screen } = useWindowDimensions()\n const [width, setWidth] = useState(0)\n const [active, setActive] = useState(0)\n const arrowColor = useToken('--color-text-primary')\n const arrowDisabled = useToken('--color-button-disabled-text')\n\n const itemWidth = width * (itemRatio ?? (screen >= TABLET ? 0.52 : 0.78))\n const step = itemWidth + GAP\n const max = Math.max(count * step - GAP - width, 0)\n // o último ponto de encaixe é o fim da rolagem, mesmo que o último item não chegue à esquerda\n const offsets = Array.from({ length: count }, (_, i) => Math.min(i * step, max))\n\n function indexAt(x: number) {\n if (max > 0 && x >= max - 1) return count - 1\n let best = 0\n offsets.forEach((offset, i) => {\n if (Math.abs(offset - x) < Math.abs((offsets[best] ?? 0) - x)) best = i\n })\n return best\n }\n\n function onScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {\n const next = indexAt(event.nativeEvent.contentOffset.x)\n if (next === active) return\n setActive(next)\n onIndexChange?.(next)\n }\n\n function goTo(index: number) {\n const target = Math.min(Math.max(index, 0), count - 1)\n scroll.current?.scrollTo({ x: offsets[target] ?? 0, animated: true })\n }\n\n return (\n <View className={cn('gap-6', className)} {...props}>\n <ScrollView\n ref={scroll}\n horizontal\n accessibilityLabel={label}\n showsHorizontalScrollIndicator={false}\n decelerationRate=\"fast\"\n snapToOffsets={offsets}\n scrollEventThrottle={16}\n onLayout={(event: LayoutChangeEvent) => setWidth(event.nativeEvent.layout.width)}\n onScroll={onScroll}\n contentContainerClassName=\"gap-4\"\n >\n {items.map((child, i) => (\n <View\n // biome-ignore lint/suspicious/noArrayIndexKey: os itens são posicionais\n key={i}\n accessibilityLabel={`${i + 1} de ${count}`}\n style={{ width: itemWidth }}\n >\n {child}\n </View>\n ))}\n </ScrollView>\n {(showDots || showArrows) && count > 0 && (\n <View className=\"flex-row items-center justify-between\">\n {showDots ? (\n <View className=\"flex-row items-center gap-2\">\n {items.map((_, i) => (\n <Pressable\n // biome-ignore lint/suspicious/noArrayIndexKey: os pontos são posicionais\n key={i}\n accessibilityRole=\"button\"\n accessibilityLabel={dotLabel(i)}\n accessibilityState={{ selected: i === active }}\n onPress={() => goTo(i)}\n className=\"h-11 items-center justify-center\"\n >\n <View\n className={cn(\n 'h-2 rounded-full',\n i === active ? 'w-6 bg-interactive-primary' : 'w-2 bg-border-strong',\n )}\n />\n </Pressable>\n ))}\n </View>\n ) : (\n <View />\n )}\n {showArrows && (\n <View className=\"flex-row gap-2\">\n <IconButton\n accessibilityLabel={previousLabel}\n variant=\"tertiary\"\n disabled={active <= 0}\n onPress={() => goTo(active - 1)}\n >\n <PreviousIcon color={active <= 0 ? arrowDisabled : arrowColor} />\n </IconButton>\n <IconButton\n accessibilityLabel={nextLabel}\n variant=\"tertiary\"\n disabled={active >= count - 1}\n onPress={() => goTo(active + 1)}\n >\n <NextIcon color={active >= count - 1 ? arrowDisabled : arrowColor} />\n </IconButton>\n </View>\n )}\n </View>\n )}\n </View>\n )\n}\n\n/** Um item do `Carousel`. A largura vem do carrossel (`itemRatio`); o item preenche toda a altura. */\nexport function CarouselItem({\n className,\n ...props\n}: Omit<ViewProps, 'style'> & { className?: string }) {\n return <View className={cn('flex-1', className)} {...props} />\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import { Carousel, CarouselItem, CarouselProps } from "./carousel.js";
2
+ export { Carousel, CarouselItem, type CarouselProps };
@@ -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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-swipe-to-close.js","names":[],"sources":["../../../src/components/drawer/use-swipe-to-close.ts"],"sourcesContent":["import { useCallback, useMemo, useRef, useState } from 'react'\nimport {\n Animated,\n type LayoutChangeEvent,\n type NativeScrollEvent,\n type NativeSyntheticEvent,\n PanResponder,\n} from 'react-native'\n\nexport type Side = 'top' | 'right' | 'bottom' | 'left'\n\n/** Eixo do arraste e sinal do movimento do dedo que fecha (em direção à borda do `side`). */\nconst closing = {\n right: { axis: 'x', sign: 1 },\n left: { axis: 'x', sign: -1 },\n bottom: { axis: 'y', sign: 1 },\n top: { axis: 'y', sign: -1 },\n} as const\n\n/** Fração do painel que precisa sair, ou a velocidade (px/ms) do gesto, para soltar e fechar. */\nexport const CLOSE_DISTANCE = 0.4\nexport const CLOSE_VELOCITY = 0.5\n/** Movimento mínimo (px) antes de o painel tomar o gesto. */\nconst SLOP = 6\n\ntype Options = {\n side: Side\n enabled: boolean\n /** Tamanho de reserva (px) do painel no eixo do arraste, até o `onLayout` medir. */\n fallbackSize: number\n onClose: () => void\n /** O painel ainda está aberto? (controlado, o `onClose` pode ser ignorado: aí o painel volta). */\n isOpen: () => boolean\n}\n\n/**\n * Fecha o painel arrastando-o para a borda de onde ele veio (mesmo comportamento do web).\n *\n * `PanResponder` na fase de captura, para tomar o gesto do `ScrollView` do corpo, mas só quando o\n * dedo vai no sentido de fechar, o gesto é mais ao longo do eixo do que de lado e o corpo não rola\n * mais naquele sentido (no `bottom`, rolagem no topo; no `top`, no fim). Como a captura é refeita a\n * cada movimento, um gesto que começou rolando passa ao painel quando o conteúdo chega à borda; o\n * arraste conta daí (o grant zera `dx`/`dy`), sem salto. O `Slider` não perde o toque: ele não aceita ser\n * tomado (`onResponderTerminationRequest` retorna `false`).\n *\n * Devolve `drag` (px arrastados, ≥ 0) e `dragFade` (1 → 0 conforme o painel sai, para o backdrop),\n * os `panHandlers` do painel, o `onLayout` que mede o painel e as props do `ScrollView` do corpo.\n */\nexport function useSwipeToClose({ side, enabled, fallbackSize, onClose, isOpen }: Options) {\n const drag = useRef(new Animated.Value(0)).current\n const [panelSize, setPanelSize] = useState(fallbackSize)\n const size = useRef(fallbackSize)\n const scroll = useRef({ offset: 0, content: 0, layout: 0 })\n const state = useRef({ offset: 0 })\n // `PanResponder.create` roda uma vez: o que muda a cada render vai por aqui.\n const latest = useRef({ side, enabled, onClose, isOpen })\n latest.current = { side, enabled, onClose, isOpen }\n\n const reset = useCallback(() => {\n state.current.offset = 0\n Animated.timing(drag, { toValue: 0, duration: 200, useNativeDriver: true }).start()\n }, [drag])\n\n const panResponder = useMemo(() => {\n const delta = (dx: number, dy: number) => {\n const { axis, sign } = closing[latest.current.side]\n return { main: (axis === 'x' ? dx : dy) * sign, cross: axis === 'x' ? dy : dx }\n }\n // o corpo ainda rola no sentido de fechar? Então o dedo é da rolagem, não do painel\n const bodyScrolls = () => {\n const { axis } = closing[latest.current.side]\n if (axis === 'x') return false // o corpo rola na vertical\n const { offset, content, layout } = scroll.current\n return latest.current.side === 'bottom' ? offset > 1 : offset + layout < content - 1\n }\n\n return PanResponder.create({\n onMoveShouldSetPanResponderCapture: (_, g) => {\n if (!latest.current.enabled) return false\n const { main, cross } = delta(g.dx, g.dy)\n if (main <= SLOP || Math.abs(main) <= Math.abs(cross) || bodyScrolls()) return false\n return true\n },\n onPanResponderTerminationRequest: () => false,\n onPanResponderMove: (_, g) => {\n const { main } = delta(g.dx, g.dy)\n // o `PanResponder` zera `dx`/`dy` no grant: o arraste conta de onde o painel assumiu\n state.current.offset = Math.max(0, main)\n drag.setValue(state.current.offset)\n },\n onPanResponderRelease: (_, g) => {\n const { axis, sign } = closing[latest.current.side]\n const velocity = (axis === 'x' ? g.vx : g.vy) * sign\n if (state.current.offset > size.current * CLOSE_DISTANCE || velocity > CLOSE_VELOCITY) {\n // a saída continua de onde o painel foi solto; `drag` zera quando o Modal desmonta\n latest.current.onClose()\n requestAnimationFrame(() => latest.current.isOpen() && reset())\n } else reset()\n },\n onPanResponderTerminate: reset,\n })\n }, [drag, reset])\n\n return {\n drag,\n // o backdrop clareia à medida que o painel sai\n dragFade: drag.interpolate({\n inputRange: [0, Math.max(panelSize, 1)],\n outputRange: [1, 0],\n extrapolate: 'clamp',\n }),\n panHandlers: panResponder.panHandlers,\n onPanelLayout: (e: LayoutChangeEvent) => {\n const { width, height } = e.nativeEvent.layout\n const next = (closing[latest.current.side].axis === 'x' ? width : height) || size.current\n size.current = next\n setPanelSize(next)\n },\n scrollProps: {\n scrollEventThrottle: 16,\n onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => {\n scroll.current.offset = e.nativeEvent.contentOffset.y\n scroll.current.content = e.nativeEvent.contentSize.height\n scroll.current.layout = e.nativeEvent.layoutMeasurement.height\n },\n onContentSizeChange: (_w: number, h: number) => {\n scroll.current.content = h\n },\n onLayout: (e: LayoutChangeEvent) => {\n scroll.current.layout = e.nativeEvent.layout.height\n },\n },\n resetDrag: useCallback(() => drag.setValue(0), [drag]),\n }\n}\n"],"mappings":"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"}