@rojaostudio/ds 2.0.0-next.11 → 2.0.0-next.13

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 (33) hide show
  1. package/README.md +6 -1
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/dist/components/choice-carousel.d.ts +1 -1
  4. package/dist/components/choice-carousel.js +2 -4
  5. package/dist/components/choice-preview-card.d.ts +1 -7
  6. package/dist/components/currency-input.d.ts +0 -2
  7. package/dist/components/data-table-header.js +11 -5
  8. package/dist/components/floating-stepper.d.ts +11 -28
  9. package/dist/components/floating-stepper.js +2 -30
  10. package/dist/components/image-crop-dialog.d.ts +60 -0
  11. package/dist/components/image-crop-dialog.js +240 -0
  12. package/dist/components/image-crop-modal.d.ts +12 -55
  13. package/dist/components/image-crop-modal.js +2 -249
  14. package/dist/components/image-upload.d.ts +6 -15
  15. package/dist/components/image-upload.js +2 -2
  16. package/dist/components/index.d.ts +2 -1
  17. package/dist/components/index.js +2 -0
  18. package/dist/components/internal/icons.d.ts +9 -1
  19. package/dist/components/internal/icons.js +16 -0
  20. package/dist/components/number-input.d.ts +1 -1
  21. package/dist/components/section-header.d.ts +4 -3
  22. package/dist/components/selectable-card.d.ts +2 -10
  23. package/dist/components/stepper.d.ts +33 -0
  24. package/dist/components/stepper.js +36 -0
  25. package/dist/components/toast.d.ts +16 -5
  26. package/dist/components/toast.js +5 -3
  27. package/dist/components/toggle-card-compact.d.ts +6 -25
  28. package/dist/components/toggle-card-compact.js +8 -35
  29. package/dist/components/toggle-card.d.ts +23 -9
  30. package/dist/components/toggle-card.js +38 -2
  31. package/package.json +2 -3
  32. package/styles/rds/components-styles.css +78 -69
  33. package/styles/rds/components.css +4 -0
package/README.md CHANGED
@@ -99,7 +99,7 @@ renders itself (the × of Alert, Dialog and Toast, the Calendar and Carousel arr
99
99
  ## Requirements
100
100
 
101
101
  React 19. Tailwind CSS is an **optional** peer: only the legacy `base.css` needs it. A few components bring an
102
- optional peer of their own, installed only if you use them: `react-image-crop` (ImageCropModal, ImageUpload) and
102
+ optional peer of their own, installed only if you use them: `react-image-crop` (ImageCropDialog, ImageUpload) and
103
103
  `react-international-phone` (PhoneInput).
104
104
 
105
105
  ## Icons
@@ -115,6 +115,11 @@ import { PixIcon } from '@rojaostudio/ds/icons';
115
115
 
116
116
  ## React Native
117
117
 
118
+ > **Experimental, outside semver in 2.0.** The native target (`@rojaostudio/ds/native/*`) still follows the 1.x
119
+ > API (`Button variant="primary"`, `Modal`, `Menu`…) and the 1.x theme (`generateTheme`). It will be aligned with
120
+ > the 2.0 components in 2.1; until then it may change in any release, minor and patch included. Pin an exact
121
+ > version if you depend on it. The web components never import from it.
122
+
118
123
  ```tsx
119
124
  import { DSThemeProvider } from '@rojaostudio/ds/native/components';
120
125
 
@@ -55,7 +55,7 @@ O símbolo do Pix (`packages/ds/icons/pix.tsx`) é marca do Banco Central do Bra
55
55
  |---|---|---|
56
56
  | `@radix-ui/react-*` | MIT | comportamento acessível de overlays, menus, abas e afins |
57
57
  | `lucide-react` (peer opcional) | ISC | re-exportação de ícones em `@rojaostudio/ds/icons` |
58
- | `react-image-crop` (peer opcional) | ISC | recorte do `ImageCropModal` |
58
+ | `react-image-crop` (peer opcional) | ISC | recorte do `ImageCropDialog` |
59
59
  | `react-international-phone` (peer opcional) | MIT | campo e bandeiras do `PhoneInput` |
60
60
 
61
61
  ## Fontes (referenciadas pelo nome, não distribuídas)
@@ -18,7 +18,7 @@ interface ChoiceCarouselProps<T> {
18
18
  /**
19
19
  * ChoiceCarousel — a horizontal, scroll-snapped row of options to pick one (radiogroup) or several (group of
20
20
  * checkboxes); the options are drawn by `renderCard`. On a wide screen two outline IconButtons (with their Tooltips)
21
- * scroll it by one option; the arrow keys do the same on the row. Styles: choice-carousel.css.
21
+ * scroll it by one option, each one there only while there is more that way; the arrow keys do the same on the row. Styles: choice-carousel.css.
22
22
  */
23
23
  declare function ChoiceCarousel<T>({ options, getKey, renderCard, value, onChange, multiple, ariaLabel, isDisabled, }: ChoiceCarouselProps<T>): react.JSX.Element;
24
24
 
@@ -75,7 +75,7 @@ function ChoiceCarousel({
75
75
  }
76
76
  };
77
77
  return /* @__PURE__ */ jsxs("div", { className: "rds-choice-carousel", children: [
78
- /* @__PURE__ */ jsx(Tooltip, { text: "Anterior", children: /* @__PURE__ */ jsx(
78
+ canScrollLeft && /* @__PURE__ */ jsx(Tooltip, { text: "Anterior", children: /* @__PURE__ */ jsx(
79
79
  IconButton,
80
80
  {
81
81
  className: "rds-choice-carousel__arrow rds-choice-carousel__arrow--prev",
@@ -84,7 +84,6 @@ function ChoiceCarousel({
84
84
  variant: "outline",
85
85
  tone: "neutral",
86
86
  onClick: () => scrollByAmount(-1),
87
- disabled: !canScrollLeft,
88
87
  tabIndex: -1
89
88
  }
90
89
  ) }),
@@ -123,7 +122,7 @@ function ChoiceCarousel({
123
122
  })
124
123
  }
125
124
  ),
126
- /* @__PURE__ */ jsx(Tooltip, { text: "Pr\xF3ximo", children: /* @__PURE__ */ jsx(
125
+ canScrollRight && /* @__PURE__ */ jsx(Tooltip, { text: "Pr\xF3ximo", children: /* @__PURE__ */ jsx(
127
126
  IconButton,
128
127
  {
129
128
  className: "rds-choice-carousel__arrow rds-choice-carousel__arrow--next",
@@ -132,7 +131,6 @@ function ChoiceCarousel({
132
131
  variant: "outline",
133
132
  tone: "neutral",
134
133
  onClick: () => scrollByAmount(1),
135
- disabled: !canScrollRight,
136
134
  tabIndex: -1
137
135
  }
138
136
  ) })
@@ -9,18 +9,12 @@ interface ChoicePreviewCardProps {
9
9
  preview: ReactNode;
10
10
  label: string;
11
11
  description?: string;
12
- /** Ignored: the ChoiceCard has no locked state. */
13
- locked?: boolean;
14
12
  disabled?: boolean;
15
- /** Ignored: the ChoiceCard has no badge on the picture. */
16
- badge?: ReactNode;
17
- /** Ignored: the picture is always 16:9. */
18
- previewAspect?: 'square' | 'video' | 'wide';
19
13
  className?: string;
20
14
  }
21
15
  /**
22
16
  * @deprecated Use ChoiceCard with layout="preview" (Figma [RDS] Content/ChoiceCard). A thin wrapper over it: `label`
23
- * is the children. It is a native radio now, not a toggle button; `locked`, `badge` and `previewAspect` are ignored.
17
+ * is the children. It is a native radio now, not a toggle button (`locked`, `badge` and `previewAspect` left in 2.0).
24
18
  */
25
19
  declare function ChoicePreviewCard({ selected, onSelect, preview, label, description, disabled, className }: ChoicePreviewCardProps): react.JSX.Element;
26
20
 
@@ -30,8 +30,6 @@ interface CurrencyInputProps {
30
30
  maxDigits?: number;
31
31
  /** Shows 0,00 instead of the placeholder when the value is zero. */
32
32
  showZero?: boolean;
33
- /** @deprecated The [RDS] Input has one height (44). Ignored. */
34
- size?: 'sm' | 'md' | 'lg';
35
33
  id?: string;
36
34
  className?: string;
37
35
  }
@@ -6,7 +6,6 @@ import {
6
6
  } from "../chunk-ORMEWXMH.js";
7
7
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
8
  import { useState } from "react";
9
- import { Badge } from "./badge.js";
10
9
  import { Button } from "./button.js";
11
10
  import { Chip } from "./chip.js";
12
11
  import { Drawer } from "./drawer.js";
@@ -32,10 +31,17 @@ function FilterDropdown({ f, placement = "bottom-start" }) {
32
31
  }
33
32
  function FiltersButton(_c) {
34
33
  var _d = _c, { count } = _d, rest = __objRest(_d, ["count"]);
35
- return /* @__PURE__ */ jsxs(Button, __spreadProps(__spreadValues({}, rest), { tone: "neutral", variant: count > 0 ? "fill" : "outline", icon: /* @__PURE__ */ jsx(SlidersIcon, {}), "data-filter-toggle": "", children: [
36
- "Filtros",
37
- count > 0 && /* @__PURE__ */ jsx(Badge, { value: count, tone: "neutral", variant: "soft" })
38
- ] }));
34
+ return /* @__PURE__ */ jsx(
35
+ Button,
36
+ __spreadProps(__spreadValues({}, rest), {
37
+ tone: "neutral",
38
+ variant: count > 0 ? "fill" : "outline",
39
+ icon: /* @__PURE__ */ jsx(SlidersIcon, {}),
40
+ iconPosition: "start",
41
+ "data-filter-toggle": "",
42
+ children: count > 0 ? `Filtros \xB7 ${count}` : "Filtros"
43
+ })
44
+ );
39
45
  }
40
46
  function FilterGroups({
41
47
  filters,
@@ -1,33 +1,16 @@
1
- import * as react from 'react';
2
- import { ReactNode } from 'react';
1
+ import { Stepper, StepperProps, StepperStep } from './stepper.js';
2
+ import 'react';
3
3
 
4
4
  /**
5
- * FloatingStepper — a composition of the Card (as a pill) and Buttons (Figma [RDS] Actions/Button, neutral): the
6
- * floating steps of a flow (point of sale, checkout). The current step is the filled Button (aria-current="step");
7
- * earlier steps go back, later ones are disabled; `canNavigate` changes the rule. `action` is a last Button after
8
- * a line (Reset). Where it floats is the caller's (className or a wrapper). Styles: floating-stepper.css.
5
+ * floating-stepper — the 1.x and 2.0-next name of the Stepper, kept so `@rojaostudio/ds/components/floating-stepper`
6
+ * and `FloatingStepper` still work in 2.0. The codemod (`npx @rojaostudio/ds-codemod`) renames both.
9
7
  */
10
- type FloatingStepperStep = {
11
- key: string;
12
- label: string;
13
- };
14
- interface FloatingStepperProps {
15
- steps: FloatingStepperStep[];
16
- /** key da etapa atual */
17
- current: string;
18
- onNavigate?: (key: string) => void;
19
- /** Regra de navegação por etapa. Default: só etapas anteriores à atual. */
20
- canNavigate?: (key: string, index: number, currentIndex: number) => boolean;
21
- /** Ação à direita, separada por divisor (ex.: Reset). */
22
- action?: {
23
- label: string;
24
- icon?: ReactNode;
25
- onClick: () => void;
26
- };
27
- className?: string;
28
- /** The navigation's name. Default "Etapas". */
29
- 'aria-label'?: string;
30
- }
31
- declare function FloatingStepper({ steps, current, onNavigate, canNavigate, action, className, 'aria-label': ariaLabel }: FloatingStepperProps): react.JSX.Element;
8
+
9
+ /** @deprecated Renamed to `Stepper` (`@rojaostudio/ds/components/stepper`) in 2.0. Same props. */
10
+ declare const FloatingStepper: typeof Stepper;
11
+ /** @deprecated Renamed to `StepperProps` in 2.0. */
12
+ type FloatingStepperProps = StepperProps;
13
+ /** @deprecated Renamed to `StepperStep` in 2.0. */
14
+ type FloatingStepperStep = StepperStep;
32
15
 
33
16
  export { FloatingStepper, type FloatingStepperProps, type FloatingStepperStep };
@@ -1,35 +1,7 @@
1
1
  "use client";
2
2
  import "../chunk-ORMEWXMH.js";
3
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
- import { Button } from "./button.js";
5
- import { Card } from "./card.js";
6
- function FloatingStepper({ steps, current, onNavigate, canNavigate, action, className, "aria-label": ariaLabel = "Etapas" }) {
7
- const currentIdx = steps.findIndex((s) => s.key === current);
8
- return /* @__PURE__ */ jsxs(Card, { as: "div", size: "sm", role: "navigation", "aria-label": ariaLabel, className: ["rds-floating-stepper", className].filter(Boolean).join(" "), children: [
9
- steps.map((s, idx) => {
10
- const isCurrent = idx === currentIdx;
11
- const canGo = canNavigate ? canNavigate(s.key, idx, currentIdx) : idx < currentIdx;
12
- return /* @__PURE__ */ jsx(
13
- Button,
14
- {
15
- tone: "neutral",
16
- variant: isCurrent ? "fill" : "ghost",
17
- disabled: !canGo && !isCurrent,
18
- "aria-current": isCurrent ? "step" : void 0,
19
- onClick: () => {
20
- if (canGo && !isCurrent) onNavigate == null ? void 0 : onNavigate(s.key);
21
- },
22
- children: s.label
23
- },
24
- s.key
25
- );
26
- }),
27
- action && /* @__PURE__ */ jsxs(Fragment, { children: [
28
- /* @__PURE__ */ jsx("span", { className: "rds-floating-stepper__divider", "aria-hidden": "true" }),
29
- /* @__PURE__ */ jsx(Button, { tone: "neutral", variant: "ghost", icon: action.icon, onClick: action.onClick, children: action.label })
30
- ] })
31
- ] });
32
- }
3
+ import { Stepper } from "./stepper.js";
4
+ const FloatingStepper = Stepper;
33
5
  export {
34
6
  FloatingStepper
35
7
  };
@@ -0,0 +1,60 @@
1
+ import * as react from 'react';
2
+
3
+ /**
4
+ * ImageCropDialog — Figma [RDS] Overlays/ImageCropDialog: a composition of the Dialog, a ToggleGroup (the aspect
5
+ * presets), the Alert (an aspect out of bounds) and the Spinner: crops an image before upload. Needs
6
+ * `react-image-crop` (an optional peer, needed only here). The crop's shade, line and handles and the stage's
7
+ * background are its own tokens (Overlays, image-crop-dialog/*). Styles: image-crop-dialog.css.
8
+ *
9
+ * Uso:
10
+ * <ImageCropDialog
11
+ * open={open}
12
+ * file={selectedFile}
13
+ * onCancel={() => setOpen(false)}
14
+ * onConfirm={(blob) => uploadCroppedImage(blob)}
15
+ * />
16
+ *
17
+ * Presets default: Livre (1:2 a 2:1), 1:1, 4:5, 16:9.
18
+ * Customize via prop `presets`.
19
+ */
20
+ interface CropPreset {
21
+ id: string;
22
+ label: string;
23
+ /** Aspect ratio fixo. undefined = livre (com bounds MIN/MAX_ASPECT). */
24
+ aspect?: number;
25
+ /** Ícone opcional exibido antes do rótulo (ajuda quem não decodifica "16:9"). */
26
+ icon?: React.ReactNode;
27
+ }
28
+ interface ImageCropDialogProps {
29
+ open: boolean;
30
+ file: File | null;
31
+ /** Label de contexto pro título (ex: "Foto 2 de 4"). */
32
+ position?: string;
33
+ onCancel: () => void;
34
+ /**
35
+ * Recebe o blob do recorte. Pode ser assíncrono (ex.: upload) — o modal AGUARDA a promise
36
+ * e mantém o botão travado/carregando até resolver, evitando duplo-clique que duplicaria a
37
+ * imagem (o upload roda fora do `busy` se o modal não esperar).
38
+ */
39
+ onConfirm: (blob: Blob) => void | Promise<void>;
40
+ /** Presets disponíveis. default = Livre + 1:1 + 4:5 + 16:9. */
41
+ presets?: CropPreset[];
42
+ /** Limite mínimo de aspect ratio (alto-baixo). default = 0.5 (1:2). */
43
+ minAspect?: number;
44
+ /** Limite máximo de aspect ratio (largo-baixo). default = 2.0 (2:1). */
45
+ maxAspect?: number;
46
+ /** Título do modal. default = "Ajustar foto". */
47
+ title?: string;
48
+ /** Label do botão confirmar. default = "Aplicar e enviar". */
49
+ confirmLabel?: string;
50
+ /**
51
+ * Teto de tamanho do blob de saída (bytes). Quando definido, a saída do crop
52
+ * é forçada a JPEG e comprimida (qualidade + downscale) até caber no limite —
53
+ * evita que foto de celular grande estoure o limite de upload. Sem ele, a
54
+ * saída mantém o tipo original e qualidade fixa (comportamento inalterado).
55
+ */
56
+ maxSizeBytes?: number;
57
+ }
58
+ declare function ImageCropDialog({ open, file, position, onCancel, onConfirm, presets, minAspect, maxAspect, title, confirmLabel, maxSizeBytes, }: ImageCropDialogProps): react.JSX.Element;
59
+
60
+ export { type CropPreset, ImageCropDialog, type ImageCropDialogProps };
@@ -0,0 +1,240 @@
1
+ "use client";
2
+ import "../chunk-ORMEWXMH.js";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import ReactCrop, { centerCrop } from "react-image-crop";
6
+ import "react-image-crop/dist/ReactCrop.css";
7
+ import { Alert } from "./alert.js";
8
+ import { Button } from "./button.js";
9
+ import { Dialog } from "./dialog.js";
10
+ import { RectangleHorizontalIcon, RectangleVerticalIcon, SquareDashedIcon, SquareIcon } from "./internal/icons.js";
11
+ import { Spinner } from "./spinner.js";
12
+ import { ToggleGroup, ToggleGroupItem } from "./toggle-group.js";
13
+ const DEFAULT_PRESETS = [
14
+ { id: "square", label: "1:1", aspect: 1, icon: /* @__PURE__ */ jsx(SquareIcon, {}) },
15
+ { id: "portrait", label: "4:5", aspect: 4 / 5, icon: /* @__PURE__ */ jsx(RectangleVerticalIcon, {}) },
16
+ { id: "landscape", label: "16:9", aspect: 16 / 9, icon: /* @__PURE__ */ jsx(RectangleHorizontalIcon, {}) },
17
+ { id: "free", label: "Livre", icon: /* @__PURE__ */ jsx(SquareDashedIcon, {}) }
18
+ ];
19
+ const DEFAULT_MIN_ASPECT = 0.5;
20
+ const DEFAULT_MAX_ASPECT = 2;
21
+ function buildInitialCrop(width, height, aspect) {
22
+ if (!aspect) {
23
+ return { unit: "%", x: 0, y: 0, width: 100, height: 100 };
24
+ }
25
+ const imageAspect = width / height;
26
+ let cropW;
27
+ let cropH;
28
+ if (aspect >= imageAspect) {
29
+ cropW = 100;
30
+ cropH = imageAspect / aspect * 100;
31
+ } else {
32
+ cropH = 100;
33
+ cropW = aspect / imageAspect * 100;
34
+ }
35
+ return centerCrop({ unit: "%", x: 0, y: 0, width: cropW, height: cropH }, width, height);
36
+ }
37
+ const QUALITY_STEPS = [0.85, 0.75, 0.65, 0.55];
38
+ const MAX_SIDE_STEPS = [1600, 1200];
39
+ function canvasToBlob(canvas, mimeType, quality) {
40
+ return new Promise((resolve, reject) => {
41
+ canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("toBlob failed")), mimeType, quality);
42
+ });
43
+ }
44
+ function drawCrop(image, crop, maxSide) {
45
+ const canvas = document.createElement("canvas");
46
+ const scaleX = image.naturalWidth / image.width;
47
+ const scaleY = image.naturalHeight / image.height;
48
+ let outW = Math.round(crop.width * scaleX);
49
+ let outH = Math.round(crop.height * scaleY);
50
+ if (maxSide && Math.max(outW, outH) > maxSide) {
51
+ const ratio = maxSide / Math.max(outW, outH);
52
+ outW = Math.round(outW * ratio);
53
+ outH = Math.round(outH * ratio);
54
+ }
55
+ canvas.width = outW;
56
+ canvas.height = outH;
57
+ const ctx = canvas.getContext("2d");
58
+ if (!ctx) throw new Error("Canvas not supported");
59
+ ctx.drawImage(
60
+ image,
61
+ crop.x * scaleX,
62
+ crop.y * scaleY,
63
+ crop.width * scaleX,
64
+ crop.height * scaleY,
65
+ 0,
66
+ 0,
67
+ outW,
68
+ outH
69
+ );
70
+ return canvas;
71
+ }
72
+ async function cropToBlob(image, crop, mimeType, maxSizeBytes) {
73
+ const baseCanvas = drawCrop(image, crop);
74
+ if (!maxSizeBytes) {
75
+ return canvasToBlob(baseCanvas, mimeType, 0.92);
76
+ }
77
+ const out = await canvasToBlob(baseCanvas, "image/jpeg", 0.92);
78
+ if (out.size <= maxSizeBytes) return out;
79
+ let best = out;
80
+ for (const q of QUALITY_STEPS) {
81
+ const candidate = await canvasToBlob(baseCanvas, "image/jpeg", q);
82
+ best = candidate;
83
+ if (candidate.size <= maxSizeBytes) return candidate;
84
+ }
85
+ for (const side of MAX_SIDE_STEPS) {
86
+ const scaledCanvas = drawCrop(image, crop, side);
87
+ for (const q of QUALITY_STEPS) {
88
+ const candidate = await canvasToBlob(scaledCanvas, "image/jpeg", q);
89
+ best = candidate;
90
+ if (candidate.size <= maxSizeBytes) return candidate;
91
+ }
92
+ }
93
+ return best;
94
+ }
95
+ function ImageCropDialog({
96
+ open,
97
+ file,
98
+ position,
99
+ onCancel,
100
+ onConfirm,
101
+ presets = DEFAULT_PRESETS,
102
+ minAspect = DEFAULT_MIN_ASPECT,
103
+ maxAspect = DEFAULT_MAX_ASPECT,
104
+ title = "Ajustar foto",
105
+ confirmLabel = "Aplicar e enviar",
106
+ maxSizeBytes
107
+ }) {
108
+ const [src, setSrc] = useState(null);
109
+ const [preset, setPreset] = useState(presets[0]);
110
+ const [crop, setCrop] = useState();
111
+ const [completedCrop, setCompletedCrop] = useState(null);
112
+ const [aspectError, setAspectError] = useState(null);
113
+ const [busy, setBusy] = useState(false);
114
+ const [imgLoaded, setImgLoaded] = useState(false);
115
+ const imgRef = useRef(null);
116
+ useEffect(() => {
117
+ if (!file) {
118
+ setSrc(null);
119
+ setImgLoaded(false);
120
+ return;
121
+ }
122
+ const url = URL.createObjectURL(file);
123
+ setSrc(url);
124
+ setPreset(presets[0]);
125
+ setCrop(void 0);
126
+ setCompletedCrop(null);
127
+ setAspectError(null);
128
+ setImgLoaded(false);
129
+ return () => URL.revokeObjectURL(url);
130
+ }, [file, presets]);
131
+ function handleImageLoad(e) {
132
+ var _a, _b, _c, _d;
133
+ const { width, height } = e.currentTarget;
134
+ const initial = buildInitialCrop(width, height, preset.aspect);
135
+ setCrop(initial);
136
+ const pixelInitial = {
137
+ unit: "px",
138
+ x: ((_a = initial.x) != null ? _a : 0) / 100 * width,
139
+ y: ((_b = initial.y) != null ? _b : 0) / 100 * height,
140
+ width: ((_c = initial.width) != null ? _c : 0) / 100 * width,
141
+ height: ((_d = initial.height) != null ? _d : 0) / 100 * height
142
+ };
143
+ setCompletedCrop(pixelInitial);
144
+ setImgLoaded(true);
145
+ }
146
+ function applyPreset(p) {
147
+ setPreset(p);
148
+ setAspectError(null);
149
+ if (imgRef.current) {
150
+ const { width, height } = imgRef.current;
151
+ setCrop(buildInitialCrop(width, height, p.aspect));
152
+ }
153
+ }
154
+ function validateFreeAspect(c) {
155
+ if (preset.aspect) return true;
156
+ if (!c.width || !c.height) return false;
157
+ const ratio = c.width / c.height;
158
+ if (ratio < minAspect) {
159
+ setAspectError(`Recorte muito alto. M\xEDnimo permitido: ${minAspect.toFixed(1)}.`);
160
+ return false;
161
+ }
162
+ if (ratio > maxAspect) {
163
+ setAspectError(`Recorte muito largo. M\xE1ximo permitido: ${maxAspect.toFixed(1)}.`);
164
+ return false;
165
+ }
166
+ setAspectError(null);
167
+ return true;
168
+ }
169
+ async function handleConfirm() {
170
+ if (busy) return;
171
+ if (!completedCrop || !imgRef.current || !file) return;
172
+ if (!validateFreeAspect(completedCrop)) return;
173
+ setBusy(true);
174
+ try {
175
+ const blob = await cropToBlob(imgRef.current, completedCrop, file.type || "image/jpeg", maxSizeBytes);
176
+ await onConfirm(blob);
177
+ } catch (err) {
178
+ console.error("cropToBlob error", err);
179
+ } finally {
180
+ setBusy(false);
181
+ }
182
+ }
183
+ return /* @__PURE__ */ jsx(
184
+ Dialog,
185
+ {
186
+ open,
187
+ onOpenChange: (next) => {
188
+ if (!next) onCancel();
189
+ },
190
+ size: "lg",
191
+ title: position ? `${title} \xB7 ${position}` : title,
192
+ footer: /* @__PURE__ */ jsxs(Fragment, { children: [
193
+ /* @__PURE__ */ jsx(Button, { tone: "neutral", variant: "ghost", onClick: onCancel, children: "Cancelar" }),
194
+ /* @__PURE__ */ jsx(Button, { onClick: handleConfirm, disabled: busy || !completedCrop || !!aspectError, loading: busy, children: busy ? "Aplicando\u2026" : confirmLabel })
195
+ ] }),
196
+ children: /* @__PURE__ */ jsxs("div", { className: "rds-image-crop", children: [
197
+ presets.length > 1 && /* @__PURE__ */ jsx(
198
+ ToggleGroup,
199
+ {
200
+ type: "single",
201
+ "aria-label": "Propor\xE7\xE3o",
202
+ value: preset.id,
203
+ onValueChange: (id) => {
204
+ const next = presets.find((p) => p.id === id);
205
+ if (next) applyPreset(next);
206
+ },
207
+ className: "rds-image-crop__presets",
208
+ children: presets.map((p) => /* @__PURE__ */ jsx(ToggleGroupItem, { value: p.id, icon: p.icon, children: p.label }, p.id))
209
+ }
210
+ ),
211
+ /* @__PURE__ */ jsxs("div", { className: "rds-image-crop__stage", children: [
212
+ src && /* @__PURE__ */ jsx(
213
+ ReactCrop,
214
+ {
215
+ crop,
216
+ onChange: (_, percentCrop) => setCrop(percentCrop),
217
+ onComplete: (c) => {
218
+ setCompletedCrop(c);
219
+ validateFreeAspect(c);
220
+ },
221
+ aspect: preset.aspect,
222
+ keepSelection: true,
223
+ minWidth: 50,
224
+ minHeight: 50,
225
+ className: "rds-image-crop__crop",
226
+ children: /* @__PURE__ */ jsx("img", { ref: imgRef, src, alt: "", onLoad: handleImageLoad, className: "rds-image-crop__image" })
227
+ }
228
+ ),
229
+ !imgLoaded && /* @__PURE__ */ jsx("div", { className: "rds-image-crop__layer", children: /* @__PURE__ */ jsx(Spinner, { size: "lg", label: "Carregando imagem\u2026" }) }),
230
+ busy && /* @__PURE__ */ jsx("div", { className: "rds-image-crop__layer rds-image-crop__layer--busy", children: /* @__PURE__ */ jsx(Spinner, { size: "lg", tone: "inverse", label: "Enviando\u2026" }) })
231
+ ] }),
232
+ aspectError && /* @__PURE__ */ jsx(Alert, { tone: "danger", title: aspectError, announce: "alert" }),
233
+ !aspectError && imgLoaded && !busy && /* @__PURE__ */ jsx("p", { className: "rds-image-crop__hint", children: preset.aspect ? "Arraste as al\xE7as para ajustar o recorte." : `Livre: propor\xE7\xE3o entre ${minAspect.toFixed(1)} (vertical) e ${maxAspect.toFixed(1)} (horizontal).` })
234
+ ] })
235
+ }
236
+ );
237
+ }
238
+ export {
239
+ ImageCropDialog
240
+ };
@@ -1,59 +1,16 @@
1
- import * as react from 'react';
1
+ import { ImageCropDialog, ImageCropDialogProps } from './image-crop-dialog.js';
2
+ export { CropPreset } from './image-crop-dialog.js';
3
+ import 'react';
2
4
 
3
5
  /**
4
- * ImageCropModal — a composition of the Dialog, a ToggleGroup (the aspect presets), the Alert (an aspect out of
5
- * bounds) and the Spinner: crops an image before upload. Needs `react-image-crop` (an optional peer, needed only
6
- * here). Styles: image-crop-modal.css.
7
- *
8
- * Uso:
9
- * <ImageCropModal
10
- * open={open}
11
- * file={selectedFile}
12
- * onCancel={() => setOpen(false)}
13
- * onConfirm={(blob) => uploadCroppedImage(blob)}
14
- * />
15
- *
16
- * Presets default: Livre (1:2 a 2:1), 1:1, 4:5, 16:9.
17
- * Customize via prop `presets`.
6
+ * image-crop-modal — the 1.x and 2.0-next name of the ImageCropDialog, kept so
7
+ * `@rojaostudio/ds/components/image-crop-modal` and `ImageCropModal` still work in 2.0. The codemod
8
+ * (`npx @rojaostudio/ds-codemod`) renames both. Like the dialog, it needs the optional peer `react-image-crop`.
18
9
  */
19
- interface CropPreset {
20
- id: string;
21
- label: string;
22
- /** Aspect ratio fixo. undefined = livre (com bounds MIN/MAX_ASPECT). */
23
- aspect?: number;
24
- /** Ícone opcional exibido antes do rótulo (ajuda quem não decodifica "16:9"). */
25
- icon?: React.ReactNode;
26
- }
27
- interface ImageCropModalProps {
28
- open: boolean;
29
- file: File | null;
30
- /** Label de contexto pro título (ex: "Foto 2 de 4"). */
31
- position?: string;
32
- onCancel: () => void;
33
- /**
34
- * Recebe o blob do recorte. Pode ser assíncrono (ex.: upload) — o modal AGUARDA a promise
35
- * e mantém o botão travado/carregando até resolver, evitando duplo-clique que duplicaria a
36
- * imagem (o upload roda fora do `busy` se o modal não esperar).
37
- */
38
- onConfirm: (blob: Blob) => void | Promise<void>;
39
- /** Presets disponíveis. default = Livre + 1:1 + 4:5 + 16:9. */
40
- presets?: CropPreset[];
41
- /** Limite mínimo de aspect ratio (alto-baixo). default = 0.5 (1:2). */
42
- minAspect?: number;
43
- /** Limite máximo de aspect ratio (largo-baixo). default = 2.0 (2:1). */
44
- maxAspect?: number;
45
- /** Título do modal. default = "Ajustar foto". */
46
- title?: string;
47
- /** Label do botão confirmar. default = "Aplicar e enviar". */
48
- confirmLabel?: string;
49
- /**
50
- * Teto de tamanho do blob de saída (bytes). Quando definido, a saída do crop
51
- * é forçada a JPEG e comprimida (qualidade + downscale) até caber no limite —
52
- * evita que foto de celular grande estoure o limite de upload. Sem ele, a
53
- * saída mantém o tipo original e qualidade fixa (comportamento inalterado).
54
- */
55
- maxSizeBytes?: number;
56
- }
57
- declare function ImageCropModal({ open, file, position, onCancel, onConfirm, presets, minAspect, maxAspect, title, confirmLabel, maxSizeBytes, }: ImageCropModalProps): react.JSX.Element;
58
10
 
59
- export { type CropPreset, ImageCropModal, type ImageCropModalProps };
11
+ /** @deprecated Renamed to `ImageCropDialog` (`@rojaostudio/ds/components/image-crop-dialog`) in 2.0. Same props. */
12
+ declare const ImageCropModal: typeof ImageCropDialog;
13
+ /** @deprecated Renamed to `ImageCropDialogProps` in 2.0. */
14
+ type ImageCropModalProps = ImageCropDialogProps;
15
+
16
+ export { ImageCropModal, type ImageCropModalProps };