@rojaostudio/ds 2.0.0-next.12 → 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.
- package/README.md +6 -1
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/components/floating-stepper.d.ts +11 -28
- package/dist/components/floating-stepper.js +2 -31
- package/dist/components/image-crop-dialog.d.ts +60 -0
- package/dist/components/image-crop-dialog.js +240 -0
- package/dist/components/image-crop-modal.d.ts +12 -56
- package/dist/components/image-crop-modal.js +2 -235
- package/dist/components/image-upload.d.ts +2 -2
- package/dist/components/image-upload.js +2 -2
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +2 -0
- package/dist/components/number-input.d.ts +1 -1
- package/dist/components/stepper.d.ts +33 -0
- package/dist/components/stepper.js +36 -0
- package/dist/components/toast.d.ts +16 -5
- package/dist/components/toast.js +5 -3
- package/package.json +2 -3
- package/styles/rds/components-styles.css +39 -39
- package/styles/rds/components.css +4 -4
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` (
|
|
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
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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 `
|
|
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)
|
|
@@ -1,33 +1,16 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { Stepper, StepperProps, StepperStep } from './stepper.js';
|
|
2
|
+
import 'react';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* earlier steps go back, later ones are disabled; `canNavigate` changes the rule. `action` is a last Button after
|
|
8
|
-
* a vertical Separator (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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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,36 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import "../chunk-ORMEWXMH.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import { Card } from "./card.js";
|
|
6
|
-
import { Separator } from "./separator.js";
|
|
7
|
-
function FloatingStepper({ steps, current, onNavigate, canNavigate, action, className, "aria-label": ariaLabel = "Etapas" }) {
|
|
8
|
-
const currentIdx = steps.findIndex((s) => s.key === current);
|
|
9
|
-
return /* @__PURE__ */ jsxs(Card, { as: "div", size: "sm", role: "navigation", "aria-label": ariaLabel, className: ["rds-floating-stepper", className].filter(Boolean).join(" "), children: [
|
|
10
|
-
steps.map((s, idx) => {
|
|
11
|
-
const isCurrent = idx === currentIdx;
|
|
12
|
-
const canGo = canNavigate ? canNavigate(s.key, idx, currentIdx) : idx < currentIdx;
|
|
13
|
-
return /* @__PURE__ */ jsx(
|
|
14
|
-
Button,
|
|
15
|
-
{
|
|
16
|
-
tone: "neutral",
|
|
17
|
-
variant: isCurrent ? "fill" : "ghost",
|
|
18
|
-
disabled: !canGo && !isCurrent,
|
|
19
|
-
"aria-current": isCurrent ? "step" : void 0,
|
|
20
|
-
onClick: () => {
|
|
21
|
-
if (canGo && !isCurrent) onNavigate == null ? void 0 : onNavigate(s.key);
|
|
22
|
-
},
|
|
23
|
-
children: s.label
|
|
24
|
-
},
|
|
25
|
-
s.key
|
|
26
|
-
);
|
|
27
|
-
}),
|
|
28
|
-
action && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
29
|
-
/* @__PURE__ */ jsx(Separator, { orientation: "vertical", className: "rds-floating-stepper__divider" }),
|
|
30
|
-
/* @__PURE__ */ jsx(Button, { tone: "neutral", variant: "ghost", icon: action.icon, onClick: action.onClick, children: action.label })
|
|
31
|
-
] })
|
|
32
|
-
] });
|
|
33
|
-
}
|
|
3
|
+
import { Stepper } from "./stepper.js";
|
|
4
|
+
const FloatingStepper = Stepper;
|
|
34
5
|
export {
|
|
35
6
|
FloatingStepper
|
|
36
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,60 +1,16 @@
|
|
|
1
|
-
import
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
* background are its own tokens (Overlays, image-crop-modal/*). Styles: image-crop-modal.css.
|
|
8
|
-
*
|
|
9
|
-
* Uso:
|
|
10
|
-
* <ImageCropModal
|
|
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`.
|
|
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`.
|
|
19
9
|
*/
|
|
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 ImageCropModalProps {
|
|
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 ImageCropModal({ open, file, position, onCancel, onConfirm, presets, minAspect, maxAspect, title, confirmLabel, maxSizeBytes, }: ImageCropModalProps): react.JSX.Element;
|
|
59
10
|
|
|
60
|
-
|
|
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 };
|
|
@@ -1,240 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import "../chunk-ORMEWXMH.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
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 ImageCropModal({
|
|
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
|
-
}
|
|
3
|
+
import { ImageCropDialog } from "./image-crop-dialog.js";
|
|
4
|
+
const ImageCropModal = ImageCropDialog;
|
|
238
5
|
export {
|
|
239
6
|
ImageCropModal
|
|
240
7
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { CropPreset } from './image-crop-
|
|
2
|
+
import { CropPreset } from './image-crop-dialog.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* @deprecated Use `<FileInput variant="tile">` (Figma [RDS] Forms/FileInput): the image's square with the preview
|
|
@@ -25,7 +25,7 @@ interface ImageUploadLabels {
|
|
|
25
25
|
/** The error message when the upload fails without one. */
|
|
26
26
|
error: string;
|
|
27
27
|
}
|
|
28
|
-
/** When given, the chosen image goes through the
|
|
28
|
+
/** When given, the chosen image goes through the ImageCropDialog before the upload. */
|
|
29
29
|
interface ImageUploadCrop {
|
|
30
30
|
presets?: CropPreset[];
|
|
31
31
|
minAspect?: number;
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
import { useRef, useState } from "react";
|
|
7
7
|
import { FileInput } from "./file-input.js";
|
|
8
|
-
import {
|
|
8
|
+
import { ImageCropDialog } from "./image-crop-dialog.js";
|
|
9
9
|
const DEFAULT_LABELS = {
|
|
10
10
|
select: "Selecionar imagem",
|
|
11
11
|
change: "Trocar imagem",
|
|
@@ -81,7 +81,7 @@ function ImageUpload({
|
|
|
81
81
|
),
|
|
82
82
|
/* @__PURE__ */ jsx("input", { type: "hidden", name, value }),
|
|
83
83
|
crop && /* @__PURE__ */ jsx(
|
|
84
|
-
|
|
84
|
+
ImageCropDialog,
|
|
85
85
|
{
|
|
86
86
|
open: !!pendingFile,
|
|
87
87
|
file: pendingFile,
|
|
@@ -38,6 +38,7 @@ export { Empty, EmptyProps } from './empty.js';
|
|
|
38
38
|
export { InsightCard, InsightCardProps, InsightCardType } from './insight-card.js';
|
|
39
39
|
export { SectionCard, SectionCardProps } from './section-card.js';
|
|
40
40
|
export { SectionHeader, SectionHeaderProps } from './section-header.js';
|
|
41
|
+
export { Stepper, StepperProps, StepperStep } from './stepper.js';
|
|
41
42
|
export { FloatingStepper, FloatingStepperProps, FloatingStepperStep } from './floating-stepper.js';
|
|
42
43
|
export { Stat, StatProps, StatTone } from './stat.js';
|
|
43
44
|
export { SummaryBar, SummaryBarItem, SummaryBarLayout, SummaryBarProps, SummaryBarTone } from './summary-bar.js';
|
package/dist/components/index.js
CHANGED
|
@@ -45,6 +45,7 @@ import { Empty } from "./empty.js";
|
|
|
45
45
|
import { InsightCard } from "./insight-card.js";
|
|
46
46
|
import { SectionCard } from "./section-card.js";
|
|
47
47
|
import { SectionHeader } from "./section-header.js";
|
|
48
|
+
import { Stepper } from "./stepper.js";
|
|
48
49
|
import { FloatingStepper } from "./floating-stepper.js";
|
|
49
50
|
import { Stat } from "./stat.js";
|
|
50
51
|
import { SummaryBar } from "./summary-bar.js";
|
|
@@ -273,6 +274,7 @@ export {
|
|
|
273
274
|
Stat,
|
|
274
275
|
Status,
|
|
275
276
|
StepProgress,
|
|
277
|
+
Stepper,
|
|
276
278
|
SummaryBar,
|
|
277
279
|
Switch,
|
|
278
280
|
Table,
|
|
@@ -53,7 +53,7 @@ interface NumberInputProps extends Omit<FieldTextProps, 'labelPosition'> {
|
|
|
53
53
|
* booking, instalments). The number in the middle is typed; the field is a spinbutton, so the arrows move it (Page
|
|
54
54
|
* Up/Down by ten steps, Home/End to the limits) and − and + stay out of the Tab order. At a limit its button turns
|
|
55
55
|
* off. A price, a CPF or a CEP is the Input; a continuous range, the Slider; the steps of a flow, the
|
|
56
|
-
*
|
|
56
|
+
* Stepper. Styles: number-input.css and internal/field.css.
|
|
57
57
|
*/
|
|
58
58
|
declare function NumberInput({ label, hint, error, errorMessage, required, value, defaultValue, onChange, min, max, step, unit, steps, stepsLabel, debounce, decrementLabel, incrementLabel, name, placeholder, disabled, id, className, style, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, }: NumberInputProps): react.JSX.Element;
|
|
59
59
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stepper — 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 vertical Separator (Reset). Where it floats is the caller's (className or a wrapper). Styles: stepper.css.
|
|
9
|
+
*/
|
|
10
|
+
type StepperStep = {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
};
|
|
14
|
+
interface StepperProps {
|
|
15
|
+
steps: StepperStep[];
|
|
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 Stepper({ steps, current, onNavigate, canNavigate, action, className, 'aria-label': ariaLabel }: StepperProps): react.JSX.Element;
|
|
32
|
+
|
|
33
|
+
export { Stepper, type StepperProps, type StepperStep };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
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
|
+
import { Separator } from "./separator.js";
|
|
7
|
+
function Stepper({ steps, current, onNavigate, canNavigate, action, className, "aria-label": ariaLabel = "Etapas" }) {
|
|
8
|
+
const currentIdx = steps.findIndex((s) => s.key === current);
|
|
9
|
+
return /* @__PURE__ */ jsxs(Card, { as: "div", size: "sm", role: "navigation", "aria-label": ariaLabel, className: ["rds-stepper", className].filter(Boolean).join(" "), children: [
|
|
10
|
+
steps.map((s, idx) => {
|
|
11
|
+
const isCurrent = idx === currentIdx;
|
|
12
|
+
const canGo = canNavigate ? canNavigate(s.key, idx, currentIdx) : idx < currentIdx;
|
|
13
|
+
return /* @__PURE__ */ jsx(
|
|
14
|
+
Button,
|
|
15
|
+
{
|
|
16
|
+
tone: "neutral",
|
|
17
|
+
variant: isCurrent ? "fill" : "ghost",
|
|
18
|
+
disabled: !canGo && !isCurrent,
|
|
19
|
+
"aria-current": isCurrent ? "step" : void 0,
|
|
20
|
+
onClick: () => {
|
|
21
|
+
if (canGo && !isCurrent) onNavigate == null ? void 0 : onNavigate(s.key);
|
|
22
|
+
},
|
|
23
|
+
children: s.label
|
|
24
|
+
},
|
|
25
|
+
s.key
|
|
26
|
+
);
|
|
27
|
+
}),
|
|
28
|
+
action && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
29
|
+
/* @__PURE__ */ jsx(Separator, { orientation: "vertical", className: "rds-stepper__divider" }),
|
|
30
|
+
/* @__PURE__ */ jsx(Button, { tone: "neutral", variant: "ghost", icon: action.icon, onClick: action.onClick, children: action.label })
|
|
31
|
+
] })
|
|
32
|
+
] });
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
Stepper
|
|
36
|
+
};
|
|
@@ -14,12 +14,18 @@ interface ToastOptions {
|
|
|
14
14
|
variant?: ToastVariant;
|
|
15
15
|
/** The tone's icon (Figma: `showIcon`). Decorative. */
|
|
16
16
|
showIcon?: boolean;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* One action (Figma: `showAction`): "Desfazer", "Ver". A toast with an action does not close by itself (WCAG 2.2.1,
|
|
19
|
+
* Timing Adjustable): it stays until the person uses the action or the ×. Choosing the action also closes it.
|
|
20
|
+
*/
|
|
18
21
|
action?: {
|
|
19
22
|
label: string;
|
|
20
23
|
onClick: () => void;
|
|
21
24
|
};
|
|
22
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* ms on screen. Default 5 s; `Infinity` (until closed) with an action. It pauses while the mouse or the focus is on
|
|
27
|
+
* it. A number given here wins, also with an action: give one only when the action can be reached some other way.
|
|
28
|
+
*/
|
|
23
29
|
duration?: number;
|
|
24
30
|
/** Give the same id to replace a toast that is on screen instead of stacking another one. */
|
|
25
31
|
id?: string;
|
|
@@ -50,14 +56,19 @@ declare function useToast(): ToastFn;
|
|
|
50
56
|
interface ToasterProps {
|
|
51
57
|
/** The region's name, said with the F8 shortcut that takes the focus to it. */
|
|
52
58
|
label?: string;
|
|
59
|
+
/** The ×'s accessible name (and its Tooltip), on every toast. Default "Fechar". */
|
|
60
|
+
closeLabel?: string;
|
|
53
61
|
/** The app, when the Toaster wraps it. It can also be mounted alone, once, anywhere on the page. */
|
|
54
62
|
children?: ReactNode;
|
|
55
63
|
}
|
|
64
|
+
/** @internal How long a toast stays: 5 s, or until closed when it has an action (WCAG 2.2.1). */
|
|
65
|
+
declare const toastDuration: (duration: number | undefined, hasAction: boolean) => number;
|
|
56
66
|
/**
|
|
57
67
|
* Toaster — the region where the toasts of the Figma [RDS] Feedback/Toast appear (Radix Toast): the bottom-right
|
|
58
68
|
* corner, up to three at a time (a fourth pushes the oldest out), each one announced in a live region (danger
|
|
59
|
-
* assertively, the others politely). F8 takes the focus to the region; swiping right dismisses.
|
|
69
|
+
* assertively, the others politely). F8 takes the focus to the region; swiping right dismisses. A toast closes by
|
|
70
|
+
* itself after 5 s; one with an action stays until the action or the × (WCAG 2.2.1). Styles: toast.css.
|
|
60
71
|
*/
|
|
61
|
-
declare function Toaster({ label, children }: ToasterProps): react.JSX.Element;
|
|
72
|
+
declare function Toaster({ label, closeLabel, children }: ToasterProps): react.JSX.Element;
|
|
62
73
|
|
|
63
|
-
export { type ToastFn, type ToastOptions, type ToastTone, type ToastVariant, ToastView, type ToastViewProps, Toaster, type ToasterProps, toast, toastClassName, useToast };
|
|
74
|
+
export { type ToastFn, type ToastOptions, type ToastTone, type ToastVariant, ToastView, type ToastViewProps, Toaster, type ToasterProps, toast, toastClassName, toastDuration, useToast };
|
package/dist/components/toast.js
CHANGED
|
@@ -66,7 +66,8 @@ function useToast() {
|
|
|
66
66
|
return toast;
|
|
67
67
|
}
|
|
68
68
|
const MAX_VISIBLE = 3;
|
|
69
|
-
|
|
69
|
+
const toastDuration = (duration, hasAction) => duration != null ? duration : hasAction ? Infinity : 5e3;
|
|
70
|
+
function Toaster({ label = "Notifica\xE7\xF5es", closeLabel = "Fechar", children }) {
|
|
70
71
|
const [items, setItems] = useState([]);
|
|
71
72
|
useEffect(() => {
|
|
72
73
|
const listener = (event) => {
|
|
@@ -102,7 +103,7 @@ function Toaster({ label = "Notifica\xE7\xF5es", children }) {
|
|
|
102
103
|
if (!next) close(key);
|
|
103
104
|
},
|
|
104
105
|
type: tone === "danger" ? "foreground" : "background",
|
|
105
|
-
duration: duration
|
|
106
|
+
duration: toastDuration(duration, Boolean(action)),
|
|
106
107
|
className: toastClassName(tone, variant),
|
|
107
108
|
children: [
|
|
108
109
|
/* @__PURE__ */ jsx(
|
|
@@ -115,7 +116,7 @@ function Toaster({ label = "Notifica\xE7\xF5es", children }) {
|
|
|
115
116
|
}
|
|
116
117
|
),
|
|
117
118
|
action && /* @__PURE__ */ jsx(ToastPrimitive.Action, { altText: action.label, asChild: true, children: /* @__PURE__ */ jsx(Button, { tone: "neutral", variant: "ghost", className: "rds-toast__action", onClick: action.onClick, children: action.label }) }),
|
|
118
|
-
/* @__PURE__ */ jsx(Tooltip, { text:
|
|
119
|
+
/* @__PURE__ */ jsx(Tooltip, { text: closeLabel, children: /* @__PURE__ */ jsx(ToastPrimitive.Close, { asChild: true, children: /* @__PURE__ */ jsx(IconButton, { icon: /* @__PURE__ */ jsx(CloseIcon, {}), label: closeLabel, tone: "neutral", variant: "ghost", className: "rds-toast__close" }) }) })
|
|
119
120
|
]
|
|
120
121
|
},
|
|
121
122
|
key
|
|
@@ -128,5 +129,6 @@ export {
|
|
|
128
129
|
Toaster,
|
|
129
130
|
toast,
|
|
130
131
|
toastClassName,
|
|
132
|
+
toastDuration,
|
|
131
133
|
useToast
|
|
132
134
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rojaostudio/ds",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Rojão Design System — componentes React, estilos e alvo React Native. O motor vive em @rojaostudio/ds-core.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -168,7 +168,6 @@
|
|
|
168
168
|
"playwright": "^1.63.0",
|
|
169
169
|
"react-image-crop": "^11.0.10",
|
|
170
170
|
"react-international-phone": "^4.8.0",
|
|
171
|
-
"ts-morph": "^28.0.0",
|
|
172
171
|
"tsup": "^8.5.1",
|
|
173
172
|
"tsx": "^4.21.0",
|
|
174
173
|
"typescript": "^5",
|
|
@@ -208,6 +207,6 @@
|
|
|
208
207
|
"lint": "eslint .",
|
|
209
208
|
"build:brands": "tsx scripts/build-brands.ts",
|
|
210
209
|
"build:rds": "tsx scripts/build-rds-tokens.ts",
|
|
211
|
-
"migrate:consumer": "tsx
|
|
210
|
+
"migrate:consumer": "tsx ../codemod/src/cli.ts"
|
|
212
211
|
}
|
|
213
212
|
}
|
|
@@ -4399,35 +4399,6 @@ input:focus-visible + .rds-checkbox__box {
|
|
|
4399
4399
|
}
|
|
4400
4400
|
}
|
|
4401
4401
|
|
|
4402
|
-
/* components/floating-stepper.css */
|
|
4403
|
-
/* @tokens floating-stepper */
|
|
4404
|
-
/*
|
|
4405
|
-
* FloatingStepper: no tokens of its own; the Card and the Buttons bring theirs. A pill (radius/full) of 44 Buttons,
|
|
4406
|
-
* 4 inside and 4 apart, raised by elevation/overlay because it floats over the page. A vertical Separator, 24
|
|
4407
|
-
* tall, before the action.
|
|
4408
|
-
*/
|
|
4409
|
-
.rds-floating-stepper {
|
|
4410
|
-
flex-direction: row;
|
|
4411
|
-
align-items: center;
|
|
4412
|
-
gap: var(--space-4);
|
|
4413
|
-
width: max-content;
|
|
4414
|
-
max-width: 100%;
|
|
4415
|
-
padding: var(--space-4);
|
|
4416
|
-
border-radius: var(--radius-full);
|
|
4417
|
-
box-shadow: var(--elevation-overlay);
|
|
4418
|
-
overflow-x: auto;
|
|
4419
|
-
}
|
|
4420
|
-
|
|
4421
|
-
.rds-floating-stepper > .rds-button {
|
|
4422
|
-
flex: none;
|
|
4423
|
-
border-radius: var(--radius-full);
|
|
4424
|
-
}
|
|
4425
|
-
|
|
4426
|
-
.rds-floating-stepper > .rds-floating-stepper__divider {
|
|
4427
|
-
align-self: center;
|
|
4428
|
-
height: var(--size-24);
|
|
4429
|
-
}
|
|
4430
|
-
|
|
4431
4402
|
/* components/footer.css */
|
|
4432
4403
|
/* @tokens footer */
|
|
4433
4404
|
/*
|
|
@@ -4892,12 +4863,12 @@ input:focus-visible + .rds-checkbox__box {
|
|
|
4892
4863
|
}
|
|
4893
4864
|
}
|
|
4894
4865
|
|
|
4895
|
-
/* components/image-crop-
|
|
4896
|
-
/* @tokens image-crop-
|
|
4866
|
+
/* components/image-crop-dialog.css */
|
|
4867
|
+
/* @tokens image-crop-dialog */
|
|
4897
4868
|
/*
|
|
4898
|
-
*
|
|
4899
|
-
* their tokens; the crop brings its own (Overlays, image-crop-
|
|
4900
|
-
* width), the stage (320 tall, on image-crop-
|
|
4869
|
+
* ImageCropDialog (Figma [RDS] Overlays/ImageCropDialog): the Dialog, the ToggleGroup, the Alert and the Spinner bring
|
|
4870
|
+
* their tokens; the crop brings its own (Overlays, image-crop-dialog/*). 12 apart: the presets (they share the
|
|
4871
|
+
* width), the stage (320 tall, on image-crop-dialog/stage/background, the image letterboxed inside 16 of padding, a
|
|
4901
4872
|
* line in the dialog's border colour), the error or the hint. Over the image, outside the crop, the shade; the crop's
|
|
4902
4873
|
* line and its handles in their own colours. While sending, the dialog's scrim covers the stage.
|
|
4903
4874
|
*/
|
|
@@ -4927,7 +4898,7 @@ input:focus-visible + .rds-checkbox__box {
|
|
|
4927
4898
|
max-height: 60vh;
|
|
4928
4899
|
padding: var(--space-16);
|
|
4929
4900
|
overflow: hidden;
|
|
4930
|
-
background: var(--image-crop-
|
|
4901
|
+
background: var(--image-crop-dialog-stage-background);
|
|
4931
4902
|
border: var(--border-width) solid var(--dialog-border);
|
|
4932
4903
|
border-radius: var(--radius-control);
|
|
4933
4904
|
}
|
|
@@ -4941,18 +4912,18 @@ input:focus-visible + .rds-checkbox__box {
|
|
|
4941
4912
|
.rds-image-crop__crop {
|
|
4942
4913
|
max-width: 100%;
|
|
4943
4914
|
max-height: calc(min(320px, 60vh) - var(--space-32));
|
|
4944
|
-
--rc-drag-handle-bg-colour: var(--image-crop-
|
|
4945
|
-
--rc-border-color: var(--image-crop-
|
|
4915
|
+
--rc-drag-handle-bg-colour: var(--image-crop-dialog-handle);
|
|
4916
|
+
--rc-border-color: var(--image-crop-dialog-handle);
|
|
4946
4917
|
}
|
|
4947
4918
|
|
|
4948
4919
|
.rds-image-crop__crop .ReactCrop__crop-mask > rect {
|
|
4949
|
-
fill: var(--image-crop-
|
|
4920
|
+
fill: var(--image-crop-dialog-shade);
|
|
4950
4921
|
fill-opacity: 1;
|
|
4951
4922
|
}
|
|
4952
4923
|
|
|
4953
4924
|
.rds-image-crop__crop .ReactCrop__crop-selection {
|
|
4954
4925
|
background-image: none !important;
|
|
4955
|
-
border: var(--border-width) solid var(--image-crop-
|
|
4926
|
+
border: var(--border-width) solid var(--image-crop-dialog-selection);
|
|
4956
4927
|
animation: none !important;
|
|
4957
4928
|
}
|
|
4958
4929
|
|
|
@@ -9721,6 +9692,35 @@ input:focus-visible + .rds-radio__circle {
|
|
|
9721
9692
|
}
|
|
9722
9693
|
}
|
|
9723
9694
|
|
|
9695
|
+
/* components/stepper.css */
|
|
9696
|
+
/* @tokens stepper */
|
|
9697
|
+
/*
|
|
9698
|
+
* Stepper: no tokens of its own; the Card and the Buttons bring theirs. A pill (radius/full) of 44 Buttons,
|
|
9699
|
+
* 4 inside and 4 apart, raised by elevation/overlay because it floats over the page. A vertical Separator, 24
|
|
9700
|
+
* tall, before the action.
|
|
9701
|
+
*/
|
|
9702
|
+
.rds-stepper {
|
|
9703
|
+
flex-direction: row;
|
|
9704
|
+
align-items: center;
|
|
9705
|
+
gap: var(--space-4);
|
|
9706
|
+
width: max-content;
|
|
9707
|
+
max-width: 100%;
|
|
9708
|
+
padding: var(--space-4);
|
|
9709
|
+
border-radius: var(--radius-full);
|
|
9710
|
+
box-shadow: var(--elevation-overlay);
|
|
9711
|
+
overflow-x: auto;
|
|
9712
|
+
}
|
|
9713
|
+
|
|
9714
|
+
.rds-stepper > .rds-button {
|
|
9715
|
+
flex: none;
|
|
9716
|
+
border-radius: var(--radius-full);
|
|
9717
|
+
}
|
|
9718
|
+
|
|
9719
|
+
.rds-stepper > .rds-stepper__divider {
|
|
9720
|
+
align-self: center;
|
|
9721
|
+
height: var(--size-24);
|
|
9722
|
+
}
|
|
9723
|
+
|
|
9724
9724
|
/* components/summary-bar.css */
|
|
9725
9725
|
/* @tokens summary-bar */
|
|
9726
9726
|
/*
|
|
@@ -1006,9 +1006,9 @@
|
|
|
1006
1006
|
--sheet-text: var(--text-body);
|
|
1007
1007
|
--sheet-title: var(--text-heading);
|
|
1008
1008
|
--dialog-scrim: var(--surface-scrim);
|
|
1009
|
-
--image-crop-
|
|
1010
|
-
--image-crop-
|
|
1011
|
-
--image-crop-
|
|
1012
|
-
--image-crop-
|
|
1009
|
+
--image-crop-dialog-shade: var(--surface-scrim);
|
|
1010
|
+
--image-crop-dialog-selection: var(--text-on-cover);
|
|
1011
|
+
--image-crop-dialog-handle: var(--text-on-cover);
|
|
1012
|
+
--image-crop-dialog-stage-background: var(--surface-muted);
|
|
1013
1013
|
}
|
|
1014
1014
|
}
|