@hoardodile/ui 0.0.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.
- package/LICENSE +18 -0
- package/README.md +70 -0
- package/dist/components/alert.d.ts +13 -0
- package/dist/components/alert.js +97 -0
- package/dist/components/alert.js.map +1 -0
- package/dist/components/app-dialog.d.ts +62 -0
- package/dist/components/app-dialog.js +656 -0
- package/dist/components/app-dialog.js.map +1 -0
- package/dist/components/badge.d.ts +11 -0
- package/dist/components/badge.js +70 -0
- package/dist/components/badge.js.map +1 -0
- package/dist/components/breadcrumb.d.ts +12 -0
- package/dist/components/breadcrumb.js +617 -0
- package/dist/components/breadcrumb.js.map +1 -0
- package/dist/components/button.d.ts +17 -0
- package/dist/components/button.js +78 -0
- package/dist/components/button.js.map +1 -0
- package/dist/components/caption-bar.d.ts +44 -0
- package/dist/components/caption-bar.js +827 -0
- package/dist/components/caption-bar.js.map +1 -0
- package/dist/components/chart-card.d.ts +15 -0
- package/dist/components/chart-card.js +27 -0
- package/dist/components/chart-card.js.map +1 -0
- package/dist/components/checkbox.d.ts +6 -0
- package/dist/components/checkbox.js +76 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/chip-button.d.ts +28 -0
- package/dist/components/chip-button.js +87 -0
- package/dist/components/chip-button.js.map +1 -0
- package/dist/components/close-confirm-dialog.d.ts +19 -0
- package/dist/components/close-confirm-dialog.js +743 -0
- package/dist/components/close-confirm-dialog.js.map +1 -0
- package/dist/components/color-picker.d.ts +32 -0
- package/dist/components/color-picker.js +667 -0
- package/dist/components/color-picker.js.map +1 -0
- package/dist/components/combobox.d.ts +32 -0
- package/dist/components/combobox.js +1211 -0
- package/dist/components/combobox.js.map +1 -0
- package/dist/components/confirm-by-typing-dialog.d.ts +48 -0
- package/dist/components/confirm-by-typing-dialog.js +759 -0
- package/dist/components/confirm-by-typing-dialog.js.map +1 -0
- package/dist/components/confirm-dialog.d.ts +34 -0
- package/dist/components/confirm-dialog.js +710 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/count-badge.d.ts +13 -0
- package/dist/components/count-badge.js +39 -0
- package/dist/components/count-badge.js.map +1 -0
- package/dist/components/dialog.d.ts +40 -0
- package/dist/components/dialog.js +571 -0
- package/dist/components/dialog.js.map +1 -0
- package/dist/components/dropdown-menu.d.ts +45 -0
- package/dist/components/dropdown-menu.js +1003 -0
- package/dist/components/dropdown-menu.js.map +1 -0
- package/dist/components/dropdown-select.d.ts +29 -0
- package/dist/components/dropdown-select.js +1036 -0
- package/dist/components/dropdown-select.js.map +1 -0
- package/dist/components/empty.d.ts +15 -0
- package/dist/components/empty.js +119 -0
- package/dist/components/empty.js.map +1 -0
- package/dist/components/filter-rail.d.ts +48 -0
- package/dist/components/filter-rail.js +231 -0
- package/dist/components/filter-rail.js.map +1 -0
- package/dist/components/fixed-action-bar.d.ts +24 -0
- package/dist/components/fixed-action-bar.js +48 -0
- package/dist/components/fixed-action-bar.js.map +1 -0
- package/dist/components/font-picker.d.ts +45 -0
- package/dist/components/font-picker.js +759 -0
- package/dist/components/font-picker.js.map +1 -0
- package/dist/components/form.d.ts +17 -0
- package/dist/components/form.js +150 -0
- package/dist/components/form.js.map +1 -0
- package/dist/components/group-label.d.ts +12 -0
- package/dist/components/group-label.js +10 -0
- package/dist/components/group-label.js.map +1 -0
- package/dist/components/icon-tile.d.ts +16 -0
- package/dist/components/icon-tile.js +67 -0
- package/dist/components/icon-tile.js.map +1 -0
- package/dist/components/icon-toggle.d.ts +24 -0
- package/dist/components/icon-toggle.js +81 -0
- package/dist/components/icon-toggle.js.map +1 -0
- package/dist/components/icon.d.ts +2 -0
- package/dist/components/icon.js +49 -0
- package/dist/components/icon.js.map +1 -0
- package/dist/components/image-crop-panel.d.ts +80 -0
- package/dist/components/image-crop-panel.js +1104 -0
- package/dist/components/image-crop-panel.js.map +1 -0
- package/dist/components/image-cropper.d.ts +63 -0
- package/dist/components/image-cropper.js +166 -0
- package/dist/components/image-cropper.js.map +1 -0
- package/dist/components/input-group.d.ts +22 -0
- package/dist/components/input-group.js +270 -0
- package/dist/components/input-group.js.map +1 -0
- package/dist/components/input.d.ts +16 -0
- package/dist/components/input.js +60 -0
- package/dist/components/input.js.map +1 -0
- package/dist/components/label.d.ts +5 -0
- package/dist/components/label.js +40 -0
- package/dist/components/label.js.map +1 -0
- package/dist/components/list-empty-row.d.ts +16 -0
- package/dist/components/list-empty-row.js +40 -0
- package/dist/components/list-empty-row.js.map +1 -0
- package/dist/components/management-empty.d.ts +14 -0
- package/dist/components/management-empty.js +40 -0
- package/dist/components/management-empty.js.map +1 -0
- package/dist/components/management-skeleton.d.ts +11 -0
- package/dist/components/management-skeleton.js +72 -0
- package/dist/components/management-skeleton.js.map +1 -0
- package/dist/components/meta-chip.d.ts +17 -0
- package/dist/components/meta-chip.js +49 -0
- package/dist/components/meta-chip.js.map +1 -0
- package/dist/components/mobile-drawer.d.ts +29 -0
- package/dist/components/mobile-drawer.js +331 -0
- package/dist/components/mobile-drawer.js.map +1 -0
- package/dist/components/page-header.d.ts +20 -0
- package/dist/components/page-header.js +44 -0
- package/dist/components/page-header.js.map +1 -0
- package/dist/components/page-scaffold.d.ts +34 -0
- package/dist/components/page-scaffold.js +157 -0
- package/dist/components/page-scaffold.js.map +1 -0
- package/dist/components/pagination-bar.d.ts +22 -0
- package/dist/components/pagination-bar.js +703 -0
- package/dist/components/pagination-bar.js.map +1 -0
- package/dist/components/pagination.d.ts +22 -0
- package/dist/components/pagination.js +680 -0
- package/dist/components/pagination.js.map +1 -0
- package/dist/components/panel-toolbar.d.ts +37 -0
- package/dist/components/panel-toolbar.js +781 -0
- package/dist/components/panel-toolbar.js.map +1 -0
- package/dist/components/pill-tabs.d.ts +33 -0
- package/dist/components/pill-tabs.js +66 -0
- package/dist/components/pill-tabs.js.map +1 -0
- package/dist/components/plugin-download-consent.d.ts +52 -0
- package/dist/components/plugin-download-consent.js +780 -0
- package/dist/components/plugin-download-consent.js.map +1 -0
- package/dist/components/popover.d.ts +26 -0
- package/dist/components/popover.js +343 -0
- package/dist/components/popover.js.map +1 -0
- package/dist/components/progress.d.ts +10 -0
- package/dist/components/progress.js +103 -0
- package/dist/components/progress.js.map +1 -0
- package/dist/components/query-state-view.d.ts +36 -0
- package/dist/components/query-state-view.js +17 -0
- package/dist/components/query-state-view.js.map +1 -0
- package/dist/components/radio-group.d.ts +8 -0
- package/dist/components/radio-group.js +60 -0
- package/dist/components/radio-group.js.map +1 -0
- package/dist/components/scroll-area.d.ts +7 -0
- package/dist/components/scroll-area.js +80 -0
- package/dist/components/scroll-area.js.map +1 -0
- package/dist/components/search-field.d.ts +49 -0
- package/dist/components/search-field.js +586 -0
- package/dist/components/search-field.js.map +1 -0
- package/dist/components/secondary-stat.d.ts +13 -0
- package/dist/components/secondary-stat.js +56 -0
- package/dist/components/secondary-stat.js.map +1 -0
- package/dist/components/section-header.d.ts +23 -0
- package/dist/components/section-header.js +76 -0
- package/dist/components/section-header.js.map +1 -0
- package/dist/components/section-label.d.ts +20 -0
- package/dist/components/section-label.js +46 -0
- package/dist/components/section-label.js.map +1 -0
- package/dist/components/section-tabs.d.ts +47 -0
- package/dist/components/section-tabs.js +145 -0
- package/dist/components/section-tabs.js.map +1 -0
- package/dist/components/separator.d.ts +14 -0
- package/dist/components/separator.js +49 -0
- package/dist/components/separator.js.map +1 -0
- package/dist/components/sheet.d.ts +22 -0
- package/dist/components/sheet.js +469 -0
- package/dist/components/sheet.js.map +1 -0
- package/dist/components/sidebar.d.ts +69 -0
- package/dist/components/sidebar.js +1645 -0
- package/dist/components/sidebar.js.map +1 -0
- package/dist/components/skeleton.d.ts +8 -0
- package/dist/components/skeleton.js +41 -0
- package/dist/components/skeleton.js.map +1 -0
- package/dist/components/slider.d.ts +6 -0
- package/dist/components/slider.js +75 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sortable-chip-list.d.ts +26 -0
- package/dist/components/sortable-chip-list.js +59 -0
- package/dist/components/sortable-chip-list.js.map +1 -0
- package/dist/components/spinner.d.ts +5 -0
- package/dist/components/spinner.js +65 -0
- package/dist/components/spinner.js.map +1 -0
- package/dist/components/surface.d.ts +13 -0
- package/dist/components/surface.js +51 -0
- package/dist/components/surface.js.map +1 -0
- package/dist/components/switch.d.ts +8 -0
- package/dist/components/switch.js +53 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/table.d.ts +12 -0
- package/dist/components/table.js +129 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/textarea.d.ts +15 -0
- package/dist/components/textarea.js +58 -0
- package/dist/components/textarea.js.map +1 -0
- package/dist/components/toast.d.ts +19 -0
- package/dist/components/toast.js +805 -0
- package/dist/components/toast.js.map +1 -0
- package/dist/components/toggle.d.ts +12 -0
- package/dist/components/toggle.js +63 -0
- package/dist/components/toggle.js.map +1 -0
- package/dist/components/tooltip.d.ts +9 -0
- package/dist/components/tooltip.js +82 -0
- package/dist/components/tooltip.js.map +1 -0
- package/dist/hooks/use-dialog-footer-actions.d.ts +18 -0
- package/dist/hooks/use-dialog-footer-actions.js +11 -0
- package/dist/hooks/use-dialog-footer-actions.js.map +1 -0
- package/dist/hooks/use-form-field.d.ts +33 -0
- package/dist/hooks/use-form-field.js +36 -0
- package/dist/hooks/use-form-field.js.map +1 -0
- package/dist/hooks/use-mobile.d.ts +34 -0
- package/dist/hooks/use-mobile.js +29 -0
- package/dist/hooks/use-mobile.js.map +1 -0
- package/dist/hooks/use-pinch-pan.d.ts +56 -0
- package/dist/hooks/use-pinch-pan.js +223 -0
- package/dist/hooks/use-pinch-pan.js.map +1 -0
- package/dist/hooks/useMobileBackToClose.d.ts +61 -0
- package/dist/hooks/useMobileBackToClose.js +234 -0
- package/dist/hooks/useMobileBackToClose.js.map +1 -0
- package/dist/icon-BOgL5bpL.d.ts +85 -0
- package/dist/icons/actions.d.ts +4 -0
- package/dist/icons/actions.js +520 -0
- package/dist/icons/actions.js.map +1 -0
- package/dist/icons/icon-style.d.ts +2 -0
- package/dist/icons/icon-style.js +51 -0
- package/dist/icons/icon-style.js.map +1 -0
- package/dist/icons/marks.d.ts +15 -0
- package/dist/icons/marks.js +48 -0
- package/dist/icons/marks.js.map +1 -0
- package/dist/icons/registry.d.ts +177 -0
- package/dist/icons/registry.js +1180 -0
- package/dist/icons/registry.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +653 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/merge-config.d.ts +15 -0
- package/dist/lib/merge-config.js +20 -0
- package/dist/lib/merge-config.js.map +1 -0
- package/dist/lib/pagination.d.ts +19 -0
- package/dist/lib/pagination.js +26 -0
- package/dist/lib/pagination.js.map +1 -0
- package/dist/lib/utils.d.ts +5 -0
- package/dist/lib/utils.js +26 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/styles/theme.css +480 -0
- package/dist/styles/theme.css.map +1 -0
- package/dist/styles/theme.d.ts +2 -0
- package/dist/viewport.d.ts +64 -0
- package/dist/viewport.js +13 -0
- package/dist/viewport.js.map +1 -0
- package/package.json +122 -0
- package/src/components/alert.tsx +76 -0
- package/src/components/app-dialog.tsx +187 -0
- package/src/components/badge.tsx +52 -0
- package/src/components/breadcrumb.tsx +129 -0
- package/src/components/button.tsx +70 -0
- package/src/components/caption-bar.test.tsx +151 -0
- package/src/components/caption-bar.tsx +392 -0
- package/src/components/chart-card.tsx +31 -0
- package/src/components/checkbox.tsx +28 -0
- package/src/components/chip-button.tsx +69 -0
- package/src/components/close-confirm-dialog.tsx +99 -0
- package/src/components/color-picker.tsx +186 -0
- package/src/components/combobox.tsx +335 -0
- package/src/components/confirm-by-typing-dialog.tsx +130 -0
- package/src/components/confirm-dialog.test.tsx +39 -0
- package/src/components/confirm-dialog.tsx +98 -0
- package/src/components/count-badge.tsx +23 -0
- package/src/components/dialog.tsx +315 -0
- package/src/components/dropdown-menu.tsx +311 -0
- package/src/components/dropdown-select.tsx +211 -0
- package/src/components/empty.tsx +104 -0
- package/src/components/filter-rail.tsx +139 -0
- package/src/components/fixed-action-bar.tsx +46 -0
- package/src/components/font-picker.tsx +257 -0
- package/src/components/form.tsx +127 -0
- package/src/components/group-label.tsx +13 -0
- package/src/components/icon-tile.tsx +35 -0
- package/src/components/icon-toggle.tsx +57 -0
- package/src/components/icon.tsx +66 -0
- package/src/components/image-crop-panel.test.tsx +167 -0
- package/src/components/image-crop-panel.tsx +465 -0
- package/src/components/image-cropper.test.tsx +144 -0
- package/src/components/image-cropper.tsx +265 -0
- package/src/components/input-group.tsx +156 -0
- package/src/components/input.tsx +45 -0
- package/src/components/label.tsx +20 -0
- package/src/components/list-empty-row.tsx +27 -0
- package/src/components/management-empty.tsx +25 -0
- package/src/components/management-skeleton.tsx +23 -0
- package/src/components/meta-chip.tsx +40 -0
- package/src/components/mobile-drawer.test.tsx +52 -0
- package/src/components/mobile-drawer.tsx +98 -0
- package/src/components/page-header.tsx +47 -0
- package/src/components/page-scaffold.test.tsx +24 -0
- package/src/components/page-scaffold.tsx +131 -0
- package/src/components/pagination-bar.test.tsx +87 -0
- package/src/components/pagination-bar.tsx +139 -0
- package/src/components/pagination.tsx +139 -0
- package/src/components/panel-toolbar.tsx +99 -0
- package/src/components/pill-tabs.test.tsx +84 -0
- package/src/components/pill-tabs.tsx +77 -0
- package/src/components/plugin-download-consent.test.tsx +90 -0
- package/src/components/plugin-download-consent.tsx +183 -0
- package/src/components/popover.tsx +133 -0
- package/src/components/progress.tsx +88 -0
- package/src/components/query-state-view.tsx +47 -0
- package/src/components/radio-group.tsx +36 -0
- package/src/components/scroll-area.tsx +52 -0
- package/src/components/search-field.test.tsx +86 -0
- package/src/components/search-field.tsx +119 -0
- package/src/components/secondary-stat.tsx +24 -0
- package/src/components/section-header.tsx +54 -0
- package/src/components/section-label.tsx +40 -0
- package/src/components/section-tabs.test.tsx +154 -0
- package/src/components/section-tabs.tsx +190 -0
- package/src/components/separator.tsx +38 -0
- package/src/components/sheet.tsx +170 -0
- package/src/components/sidebar.tsx +725 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/slider.tsx +52 -0
- package/src/components/sortable-chip-list.tsx +56 -0
- package/src/components/spinner.tsx +35 -0
- package/src/components/surface.tsx +37 -0
- package/src/components/switch.tsx +30 -0
- package/src/components/table.tsx +116 -0
- package/src/components/textarea.tsx +41 -0
- package/src/components/toast.tsx +239 -0
- package/src/components/toggle.tsx +45 -0
- package/src/components/tooltip.tsx +64 -0
- package/src/hooks/use-dialog-footer-actions.ts +23 -0
- package/src/hooks/use-form-field.ts +62 -0
- package/src/hooks/use-mobile.ts +54 -0
- package/src/hooks/use-pinch-pan.test.tsx +42 -0
- package/src/hooks/use-pinch-pan.ts +340 -0
- package/src/hooks/useMobileBackToClose.test.tsx +140 -0
- package/src/hooks/useMobileBackToClose.ts +508 -0
- package/src/icons/actions.ts +10 -0
- package/src/icons/icon-style.test.tsx +69 -0
- package/src/icons/icon-style.ts +104 -0
- package/src/icons/marks.tsx +72 -0
- package/src/icons/registry.test.ts +65 -0
- package/src/icons/registry.ts +1163 -0
- package/src/index.ts +22 -0
- package/src/lib/merge-config.test.ts +109 -0
- package/src/lib/merge-config.ts +22 -0
- package/src/lib/pagination.test.ts +55 -0
- package/src/lib/pagination.ts +42 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/scan-contract.test.ts +74 -0
- package/src/styles/theme.css +798 -0
- package/src/test/i18n.tsx +22 -0
- package/src/test/setup.ts +104 -0
- package/src/viewport.ts +69 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ComponentType } from "react"
|
|
2
|
+
import { cn } from "../lib/utils.ts"
|
|
3
|
+
import type { IconMode } from "../icons/icon-style.ts"
|
|
4
|
+
|
|
5
|
+
/** Anything renderable through `Icon`: a Solar icon (imported from the
|
|
6
|
+
central registry on the web side) or a local utility mark (marks.tsx) —
|
|
7
|
+
both take `className`; registry icons take `mode` (marks ignore it). */
|
|
8
|
+
export type IconType = ComponentType<{ className?: string; mode?: IconMode }>
|
|
9
|
+
|
|
10
|
+
/** The three sanctioned icon sizes: `sm` 12 inside pills and meta lines,
|
|
11
|
+
`md` 16 by default, `lg` 20 for primary toolbar actions. */
|
|
12
|
+
export type IconSize = "sm" | "md" | "lg"
|
|
13
|
+
|
|
14
|
+
const sizeClasses = {
|
|
15
|
+
sm: "size-3",
|
|
16
|
+
md: "size-4",
|
|
17
|
+
lg: "size-5",
|
|
18
|
+
} as const satisfies Record<IconSize, string>
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Solar icons with the design system's defaults baked in: callers pass
|
|
22
|
+
* the registry icon — by default it renders the boldDuotone weight,
|
|
23
|
+
* whose second tone takes the palette's `--icon-tone` hue (theme.css
|
|
24
|
+
* recolors the duotone accent via `--solar-secondary-color` under the
|
|
25
|
+
* `hd-icon` hook), so colored palettes feel alive while mono stays
|
|
26
|
+
* neutral.
|
|
27
|
+
*
|
|
28
|
+
* Mode is one channel: `mode` picks the weight directly; `selected` maps
|
|
29
|
+
* to `"bold"` (the filled, single-color weight — the only sanctioned use
|
|
30
|
+
* of Bold); otherwise the active icon style preference decides.
|
|
31
|
+
*
|
|
32
|
+
* Sizing is class-based, three tiers only — `sm`/`md`/`lg` map to
|
|
33
|
+
* `size-3/4/5` (12/16/20). Escape the tiers with
|
|
34
|
+
* `className="size-[18px]"` — twMerge lets the later class win.
|
|
35
|
+
*
|
|
36
|
+
* ✓/×/+ semantics never use Solar's Circle/Square composites — the
|
|
37
|
+
* container is full-bleed while the informative mark is barely a third
|
|
38
|
+
* of the box, so it goes muddy at small sizes. The plain marks (Check,
|
|
39
|
+
* Cross, Plus from marks.tsx) cover these at every size; `sm` otherwise
|
|
40
|
+
* belongs to simple single-stroke glyphs (arrows, dots, grips).
|
|
41
|
+
*/
|
|
42
|
+
export function Icon({
|
|
43
|
+
icon: IconComponent,
|
|
44
|
+
mode,
|
|
45
|
+
size = "md",
|
|
46
|
+
selected,
|
|
47
|
+
className,
|
|
48
|
+
...rest
|
|
49
|
+
}: {
|
|
50
|
+
icon: IconType
|
|
51
|
+
/** Explicit weight override; `selected` and the style preference lose
|
|
52
|
+
to it. */
|
|
53
|
+
mode?: IconMode
|
|
54
|
+
size?: IconSize
|
|
55
|
+
/** Selected state — renders the icon's `"bold"` weight. */
|
|
56
|
+
selected?: boolean
|
|
57
|
+
className?: string
|
|
58
|
+
} & Record<string, unknown>) {
|
|
59
|
+
return (
|
|
60
|
+
<IconComponent
|
|
61
|
+
mode={mode ?? (selected === true ? "bold" : undefined)}
|
|
62
|
+
className={cn("hd-icon", sizeClasses[size], className)}
|
|
63
|
+
{...rest}
|
|
64
|
+
/>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { screen, waitFor, within } from "@testing-library/react"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { renderWithI18n } from "../test/i18n"
|
|
4
|
+
import { AppDialog } from "./app-dialog"
|
|
5
|
+
import { ImageCropPanel } from "./image-crop-panel"
|
|
6
|
+
|
|
7
|
+
const TALL_IMAGE_SRC =
|
|
8
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1000' height='2000'%3E%3Crect width='100%25' height='100%25' fill='red'/%3E%3C/svg%3E"
|
|
9
|
+
|
|
10
|
+
describe("ImageCropPanel", () => {
|
|
11
|
+
it("constrains the image within the provided crop stage bounds", async () => {
|
|
12
|
+
renderWithI18n(
|
|
13
|
+
<ImageCropPanel
|
|
14
|
+
initialSrc={TALL_IMAGE_SRC}
|
|
15
|
+
cropStageWidth={260}
|
|
16
|
+
cropStageHeight={500}
|
|
17
|
+
hideActionButton
|
|
18
|
+
onSave={vi.fn()}
|
|
19
|
+
/>,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const img = await screen.findByRole("img")
|
|
23
|
+
expect(img).toHaveStyle({
|
|
24
|
+
"max-width": "260px",
|
|
25
|
+
"max-height": "500px",
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("uses default bounds when crop stage dimensions are omitted", async () => {
|
|
30
|
+
renderWithI18n(
|
|
31
|
+
<ImageCropPanel
|
|
32
|
+
initialSrc={TALL_IMAGE_SRC}
|
|
33
|
+
hideActionButton
|
|
34
|
+
onSave={vi.fn()}
|
|
35
|
+
/>,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const img = await screen.findByRole("img")
|
|
39
|
+
expect(img).toHaveStyle({
|
|
40
|
+
"max-width": "320px",
|
|
41
|
+
"max-height": "240px",
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("keeps the crop stage wrapper within the effective bounds", async () => {
|
|
46
|
+
const { container } = renderWithI18n(
|
|
47
|
+
<ImageCropPanel
|
|
48
|
+
initialSrc={TALL_IMAGE_SRC}
|
|
49
|
+
cropStageWidth={200}
|
|
50
|
+
cropStageHeight={200}
|
|
51
|
+
hideActionButton
|
|
52
|
+
onSave={vi.fn()}
|
|
53
|
+
/>,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const stage = container.querySelector("[data-testid='image-crop-frame']")
|
|
57
|
+
expect(stage).toBeNull()
|
|
58
|
+
|
|
59
|
+
const wrapper = container.querySelector(
|
|
60
|
+
".overflow-hidden.rounded-md.border",
|
|
61
|
+
)
|
|
62
|
+
await waitFor(() => expect(wrapper).toBeInTheDocument())
|
|
63
|
+
expect(wrapper).toHaveStyle({
|
|
64
|
+
width: "200px",
|
|
65
|
+
height: "200px",
|
|
66
|
+
"max-width": "200px",
|
|
67
|
+
"max-height": "200px",
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("does not render the live preview when hidden", async () => {
|
|
72
|
+
renderWithI18n(
|
|
73
|
+
<ImageCropPanel
|
|
74
|
+
initialSrc={TALL_IMAGE_SRC}
|
|
75
|
+
hideActionButton
|
|
76
|
+
hidePreview
|
|
77
|
+
onSave={vi.fn()}
|
|
78
|
+
/>,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
await screen.findByRole("img")
|
|
82
|
+
expect(screen.queryByTestId("image-crop-preview")).not.toBeInTheDocument()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("stretches the stage to fill its parent width when filling", async () => {
|
|
86
|
+
const { container } = renderWithI18n(
|
|
87
|
+
<ImageCropPanel
|
|
88
|
+
initialSrc={TALL_IMAGE_SRC}
|
|
89
|
+
cropStageWidth={13}
|
|
90
|
+
cropStageHeight={25}
|
|
91
|
+
hideActionButton
|
|
92
|
+
fillWidth
|
|
93
|
+
onSave={vi.fn()}
|
|
94
|
+
/>,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const img = await screen.findByRole("img")
|
|
98
|
+
expect(img).toHaveStyle({ "max-width": "100%" })
|
|
99
|
+
|
|
100
|
+
const wrapper = container.querySelector(
|
|
101
|
+
".overflow-hidden.rounded-md.border",
|
|
102
|
+
)
|
|
103
|
+
await waitFor(() => expect(wrapper).toBeInTheDocument())
|
|
104
|
+
expect(wrapper).toHaveStyle({ "aspect-ratio": "13 / 25" })
|
|
105
|
+
expect(wrapper).not.toHaveStyle({ width: "13px" })
|
|
106
|
+
expect(wrapper?.parentElement).toHaveClass("w-full")
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("renders the save button on the same footer row as the dialog's cancel", async () => {
|
|
110
|
+
renderWithI18n(
|
|
111
|
+
<AppDialog
|
|
112
|
+
open
|
|
113
|
+
title="Cover"
|
|
114
|
+
onOpenChange={() => {}}
|
|
115
|
+
footer={
|
|
116
|
+
<button type="button" data-testid="dialog-cancel">
|
|
117
|
+
Cancel
|
|
118
|
+
</button>
|
|
119
|
+
}
|
|
120
|
+
>
|
|
121
|
+
<ImageCropPanel
|
|
122
|
+
initialSrc={TALL_IMAGE_SRC}
|
|
123
|
+
onSave={vi.fn()}
|
|
124
|
+
/>
|
|
125
|
+
</AppDialog>,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
await screen.findByRole("img")
|
|
129
|
+
// The dialog renders into a portal, so the footer lives on the
|
|
130
|
+
// document rather than the render container.
|
|
131
|
+
const footer = document.querySelector('[data-slot="dialog-footer"]')
|
|
132
|
+
expect(footer).not.toBeNull()
|
|
133
|
+
// The panel contributes its action through DialogFooterActions, so
|
|
134
|
+
// save and cancel share the dialog's footer row.
|
|
135
|
+
expect(
|
|
136
|
+
within(footer as HTMLElement).getByTestId("image-crop-save"),
|
|
137
|
+
).toHaveTextContent("Save")
|
|
138
|
+
expect(
|
|
139
|
+
within(footer as HTMLElement).getByTestId("dialog-cancel"),
|
|
140
|
+
).toBeInTheDocument()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("renders the action button as an inline panel row outside a dialog", () => {
|
|
144
|
+
const { container } = renderWithI18n(
|
|
145
|
+
<ImageCropPanel onClear={vi.fn()} onSave={vi.fn()} />,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expect(screen.getByTestId("image-crop-frame")).toBeInTheDocument()
|
|
149
|
+
// No image selected: the action button becomes "Remove".
|
|
150
|
+
const remove = screen.getByTestId("image-crop-save")
|
|
151
|
+
expect(remove).toHaveTextContent("Remove")
|
|
152
|
+
expect(container.querySelector('[data-slot="dialog-footer"]')).toBeNull()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("hides the action button when hideActionButton is set", async () => {
|
|
156
|
+
renderWithI18n(
|
|
157
|
+
<ImageCropPanel
|
|
158
|
+
initialSrc={TALL_IMAGE_SRC}
|
|
159
|
+
hideActionButton
|
|
160
|
+
onSave={vi.fn()}
|
|
161
|
+
/>,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
await screen.findByRole("img")
|
|
165
|
+
expect(screen.queryByTestId("image-crop-save")).not.toBeInTheDocument()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DialogFooterActions,
|
|
3
|
+
useDialogFooterActions,
|
|
4
|
+
} from "@hoardodile/ui/components/app-dialog"
|
|
5
|
+
import { Button } from "@hoardodile/ui/components/button"
|
|
6
|
+
import { Label } from "@hoardodile/ui/components/label"
|
|
7
|
+
import { Switch } from "@hoardodile/ui/components/switch"
|
|
8
|
+
import { toast } from "@hoardodile/ui/components/toast"
|
|
9
|
+
import { Gallery, RefreshCircle, Upload } from "@hoardodile/ui/icons/registry"
|
|
10
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
11
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react"
|
|
12
|
+
import { useTranslation } from "react-i18next"
|
|
13
|
+
import type { CroppedImage } from "./image-cropper"
|
|
14
|
+
import { ImageCropper } from "./image-cropper"
|
|
15
|
+
|
|
16
|
+
export type ImageCropPanelProps = {
|
|
17
|
+
readonly aspect?: number
|
|
18
|
+
readonly mimeType?: "image/png" | "image/jpeg" | "image/webp"
|
|
19
|
+
readonly accept?: string
|
|
20
|
+
readonly previewShape?: "square" | "circle"
|
|
21
|
+
readonly previewWidth?: number
|
|
22
|
+
readonly previewHeight?: number
|
|
23
|
+
/**
|
|
24
|
+
* Called when the user clicks "save". Receives the cropped image
|
|
25
|
+
* payload. Should return a promise that resolves once the upload is
|
|
26
|
+
* complete; the panel then notifies via {@link onSaved}.
|
|
27
|
+
*
|
|
28
|
+
* @throws The implementation may throw to surface an error toast.
|
|
29
|
+
*/
|
|
30
|
+
readonly onSave: (cropped: CroppedImage) => Promise<unknown>
|
|
31
|
+
readonly onSaved?: () => void
|
|
32
|
+
/**
|
|
33
|
+
* Called when the user clicks the action button but no image has been
|
|
34
|
+
* selected. The parent (e.g. {@link ImageEditPanel}) can treat this as a
|
|
35
|
+
* request to remove the existing image.
|
|
36
|
+
*/
|
|
37
|
+
readonly onClear?: () => void
|
|
38
|
+
/**
|
|
39
|
+
* When set (e.g. canvas snapshot object URL), skips the empty picker and
|
|
40
|
+
* shows the cropper immediately.
|
|
41
|
+
*/
|
|
42
|
+
readonly initialSrc?: string
|
|
43
|
+
/**
|
|
44
|
+
* When false, hides file pick / drag-drop and "reselect" (preset image only).
|
|
45
|
+
*/
|
|
46
|
+
readonly allowChangeSource?: boolean
|
|
47
|
+
/** Notifies parent when an async save starts / finishes (for dialog guards). */
|
|
48
|
+
readonly onSavingChange?: (saving: boolean) => void
|
|
49
|
+
/** Exact size (px) of the empty picker frame / crop stage. Overrides the default 240×240. */
|
|
50
|
+
readonly cropStageWidth?: number
|
|
51
|
+
readonly cropStageHeight?: number
|
|
52
|
+
/**
|
|
53
|
+
* When true, the right-hand live preview is hidden until the user turns
|
|
54
|
+
* the switch on (saves work over large captures / WebGL snapshots).
|
|
55
|
+
*/
|
|
56
|
+
readonly showPreviewSwitch?: boolean
|
|
57
|
+
/**
|
|
58
|
+
* When true, the live preview pane is never rendered — the crop stage is
|
|
59
|
+
* the only surface. Used by creation forms whose panel column has no
|
|
60
|
+
* room for a side-by-side preview.
|
|
61
|
+
*/
|
|
62
|
+
readonly hidePreview?: boolean
|
|
63
|
+
/**
|
|
64
|
+
* When true, the crop stage fills its parent width and keeps the aspect
|
|
65
|
+
* ratio implied by {@link cropStageWidth} / {@link cropStageHeight}
|
|
66
|
+
* (full-bleed `aspect-square w-full` panels). Ignored when only
|
|
67
|
+
* one stage dimension is given.
|
|
68
|
+
*/
|
|
69
|
+
readonly fillWidth?: boolean
|
|
70
|
+
/** Extra classes on the root panel wrapper. */
|
|
71
|
+
readonly panelClassName?: string
|
|
72
|
+
/** Extra classes on the row that holds the crop stage + preview. */
|
|
73
|
+
readonly containerClassName?: string
|
|
74
|
+
/** Extra classes on the column wrapping the crop stage (e.g. flex-1 min-w-0). */
|
|
75
|
+
readonly cropColumnClassName?: string
|
|
76
|
+
/** Extra classes on the bordered crop stage box (merged with defaults via tailwind-merge). */
|
|
77
|
+
readonly cropStageClassName?: string
|
|
78
|
+
/** When true, the bottom save/remove action button is hidden. */
|
|
79
|
+
readonly hideActionButton?: boolean
|
|
80
|
+
/**
|
|
81
|
+
* When true, automatically calls {@link onSave} after the user finishes
|
|
82
|
+
* adjusting the crop (debounced by 600 ms). Useful when the action button
|
|
83
|
+
* is hidden and the parent only needs the final crop at form-submit time.
|
|
84
|
+
*/
|
|
85
|
+
readonly autoSaveOnCrop?: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Inline image picker + cropper with live preview.
|
|
90
|
+
*/
|
|
91
|
+
export function ImageCropPanel(props: ImageCropPanelProps) {
|
|
92
|
+
const {
|
|
93
|
+
aspect,
|
|
94
|
+
mimeType = "image/png",
|
|
95
|
+
accept = "image/*",
|
|
96
|
+
previewShape = "square",
|
|
97
|
+
previewWidth: previewWidthProp,
|
|
98
|
+
previewHeight: previewHeightProp,
|
|
99
|
+
onSave,
|
|
100
|
+
onSaved,
|
|
101
|
+
onClear,
|
|
102
|
+
initialSrc,
|
|
103
|
+
allowChangeSource = true,
|
|
104
|
+
onSavingChange,
|
|
105
|
+
cropStageWidth,
|
|
106
|
+
cropStageHeight,
|
|
107
|
+
showPreviewSwitch = false,
|
|
108
|
+
hidePreview = false,
|
|
109
|
+
fillWidth = false,
|
|
110
|
+
panelClassName,
|
|
111
|
+
containerClassName,
|
|
112
|
+
cropColumnClassName,
|
|
113
|
+
cropStageClassName,
|
|
114
|
+
hideActionButton = false,
|
|
115
|
+
autoSaveOnCrop = false,
|
|
116
|
+
} = props
|
|
117
|
+
const { t } = useTranslation("ui", { useSuspense: false })
|
|
118
|
+
const footerSlot = useDialogFooterActions()
|
|
119
|
+
const previewWidth = previewWidthProp ?? cropStageWidth ?? 200
|
|
120
|
+
const previewHeight = previewHeightProp ?? cropStageHeight ?? 200
|
|
121
|
+
const effectiveCropWidth = cropStageWidth ?? 320
|
|
122
|
+
const effectiveCropHeight = cropStageHeight ?? 240
|
|
123
|
+
// Full-bleed stage: the aspect ratio of the requested stage dimensions,
|
|
124
|
+
// stretched to whatever width the parent column gives us.
|
|
125
|
+
const stageAspectRatio =
|
|
126
|
+
fillWidth && cropStageWidth !== undefined && cropStageHeight !== undefined
|
|
127
|
+
? `${cropStageWidth} / ${cropStageHeight}`
|
|
128
|
+
: undefined
|
|
129
|
+
const previewToggleId = useId()
|
|
130
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
131
|
+
const [imageSrc, setImageSrc] = useState<string | undefined>(initialSrc)
|
|
132
|
+
const renderRef = useRef<(() => Promise<CroppedImage>) | undefined>(undefined)
|
|
133
|
+
const [previewDataUrl, setPreviewDataUrl] = useState<string | undefined>(
|
|
134
|
+
undefined,
|
|
135
|
+
)
|
|
136
|
+
const [previewPaneOn, setPreviewPaneOn] = useState(false)
|
|
137
|
+
const [dragOver, setDragOver] = useState(false)
|
|
138
|
+
const [saving, setSaving] = useState(false)
|
|
139
|
+
const previewVisible = !hidePreview && (!showPreviewSwitch || previewPaneOn)
|
|
140
|
+
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
141
|
+
undefined,
|
|
142
|
+
)
|
|
143
|
+
const callbacksRef = useRef({ onSave, onSaved, onSavingChange, t })
|
|
144
|
+
callbacksRef.current = { onSave, onSaved, onSavingChange, t }
|
|
145
|
+
|
|
146
|
+
useEffect(
|
|
147
|
+
function syncInitialSrc() {
|
|
148
|
+
if (initialSrc !== undefined) setImageSrc(initialSrc)
|
|
149
|
+
},
|
|
150
|
+
[initialSrc],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const handleCropReady = useCallback(
|
|
154
|
+
(render: () => Promise<CroppedImage>) => {
|
|
155
|
+
renderRef.current = render
|
|
156
|
+
if (autoSaveOnCrop) {
|
|
157
|
+
if (autoSaveTimerRef.current) {
|
|
158
|
+
clearTimeout(autoSaveTimerRef.current)
|
|
159
|
+
}
|
|
160
|
+
autoSaveTimerRef.current = setTimeout(() => {
|
|
161
|
+
render()
|
|
162
|
+
.then(async (cropped) => {
|
|
163
|
+
const { onSave, onSaved, onSavingChange, t } =
|
|
164
|
+
callbacksRef.current
|
|
165
|
+
try {
|
|
166
|
+
setSaving(true)
|
|
167
|
+
onSavingChange?.(true)
|
|
168
|
+
await onSave(cropped)
|
|
169
|
+
onSaved?.()
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
172
|
+
toast.add({
|
|
173
|
+
title: msg || t("imageCrop.saveFailed"),
|
|
174
|
+
type: "error",
|
|
175
|
+
})
|
|
176
|
+
} finally {
|
|
177
|
+
setSaving(false)
|
|
178
|
+
onSavingChange?.(false)
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
.catch((err) => {
|
|
182
|
+
const { t } = callbacksRef.current
|
|
183
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
184
|
+
toast.add({
|
|
185
|
+
title: msg || t("imageCrop.saveFailed"),
|
|
186
|
+
type: "error",
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
}, 600)
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
[autoSaveOnCrop],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const handlePreviewChange = useCallback((dataUrl: string) => {
|
|
196
|
+
setPreviewDataUrl(dataUrl)
|
|
197
|
+
}, [])
|
|
198
|
+
|
|
199
|
+
function handlePreviewToggle(on: boolean) {
|
|
200
|
+
setPreviewPaneOn(on)
|
|
201
|
+
if (!on) setPreviewDataUrl(undefined)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
205
|
+
const file = e.target.files?.[0]
|
|
206
|
+
if (file === undefined) return
|
|
207
|
+
readFileAsDataUrl(file)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function handleDrop(e: React.DragEvent<HTMLElement>) {
|
|
211
|
+
e.preventDefault()
|
|
212
|
+
setDragOver(false)
|
|
213
|
+
const file = e.dataTransfer.files?.[0]
|
|
214
|
+
if (file !== undefined) readFileAsDataUrl(file)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readFileAsDataUrl(file: File) {
|
|
218
|
+
const reader = new FileReader()
|
|
219
|
+
reader.addEventListener("load", () => {
|
|
220
|
+
const result = reader.result
|
|
221
|
+
if (typeof result === "string") setImageSrc(result)
|
|
222
|
+
})
|
|
223
|
+
reader.readAsDataURL(file)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function handleReselect() {
|
|
227
|
+
if (autoSaveTimerRef.current) {
|
|
228
|
+
clearTimeout(autoSaveTimerRef.current)
|
|
229
|
+
autoSaveTimerRef.current = undefined
|
|
230
|
+
}
|
|
231
|
+
setImageSrc(undefined)
|
|
232
|
+
renderRef.current = undefined
|
|
233
|
+
setPreviewDataUrl(undefined)
|
|
234
|
+
setPreviewPaneOn(false)
|
|
235
|
+
fileInputRef.current?.click()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function handleSave() {
|
|
239
|
+
const render = renderRef.current
|
|
240
|
+
if (render === undefined) {
|
|
241
|
+
onClear?.()
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
setSaving(true)
|
|
245
|
+
onSavingChange?.(true)
|
|
246
|
+
try {
|
|
247
|
+
const cropped = await render()
|
|
248
|
+
await onSave(cropped)
|
|
249
|
+
onSaved?.()
|
|
250
|
+
} catch (err) {
|
|
251
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
252
|
+
toast.add({
|
|
253
|
+
title: msg || t("imageCrop.saveFailed"),
|
|
254
|
+
type: "error",
|
|
255
|
+
})
|
|
256
|
+
} finally {
|
|
257
|
+
setSaving(false)
|
|
258
|
+
onSavingChange?.(false)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const previewRadiusClass =
|
|
263
|
+
previewShape === "circle" ? "rounded-full" : "rounded"
|
|
264
|
+
|
|
265
|
+
const actionButton = (
|
|
266
|
+
<Button
|
|
267
|
+
type="button"
|
|
268
|
+
onClick={handleSave}
|
|
269
|
+
disabled={saving}
|
|
270
|
+
data-testid="image-crop-save"
|
|
271
|
+
>
|
|
272
|
+
{saving
|
|
273
|
+
? t("imageCrop.saving")
|
|
274
|
+
: imageSrc === undefined
|
|
275
|
+
? t("imageCrop.remove")
|
|
276
|
+
: t("imageCrop.save")}
|
|
277
|
+
</Button>
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div className={cn("flex flex-col gap-4", panelClassName)}>
|
|
282
|
+
{allowChangeSource ? (
|
|
283
|
+
<input
|
|
284
|
+
ref={fileInputRef}
|
|
285
|
+
type="file"
|
|
286
|
+
accept={accept}
|
|
287
|
+
className="hidden"
|
|
288
|
+
onChange={handleFileChange}
|
|
289
|
+
data-testid="image-crop-file-input"
|
|
290
|
+
/>
|
|
291
|
+
) : null}
|
|
292
|
+
|
|
293
|
+
<div
|
|
294
|
+
className={cn(
|
|
295
|
+
"flex flex-col items-center gap-6 py-2 sm:flex-row sm:items-center sm:justify-center",
|
|
296
|
+
containerClassName,
|
|
297
|
+
)}
|
|
298
|
+
>
|
|
299
|
+
<div
|
|
300
|
+
className={cn(
|
|
301
|
+
"flex flex-col items-center gap-2",
|
|
302
|
+
stageAspectRatio !== undefined && "w-full",
|
|
303
|
+
cropColumnClassName,
|
|
304
|
+
)}
|
|
305
|
+
>
|
|
306
|
+
{imageSrc === undefined ? (
|
|
307
|
+
allowChangeSource ? (
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
onClick={() => fileInputRef.current?.click()}
|
|
311
|
+
onDragOver={(e) => {
|
|
312
|
+
e.preventDefault()
|
|
313
|
+
setDragOver(true)
|
|
314
|
+
}}
|
|
315
|
+
onDragLeave={() => setDragOver(false)}
|
|
316
|
+
onDrop={handleDrop}
|
|
317
|
+
className={cn(
|
|
318
|
+
"flex select-none flex-col items-center justify-center gap-2 rounded-md border border-border bg-muted text-muted-foreground transition-colors duration-300 hover:bg-accent hover:text-accent-foreground",
|
|
319
|
+
dragOver && "bg-accent text-accent-foreground",
|
|
320
|
+
stageAspectRatio !== undefined && "w-full",
|
|
321
|
+
)}
|
|
322
|
+
data-testid="image-crop-frame"
|
|
323
|
+
style={
|
|
324
|
+
stageAspectRatio !== undefined
|
|
325
|
+
? { aspectRatio: stageAspectRatio }
|
|
326
|
+
: {
|
|
327
|
+
width: cropStageWidth ?? 240,
|
|
328
|
+
height: cropStageHeight ?? 240,
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
>
|
|
332
|
+
<Upload className="size-5" />
|
|
333
|
+
<p className="text-sm">{t("imageCrop.pickHint")}</p>
|
|
334
|
+
</button>
|
|
335
|
+
) : (
|
|
336
|
+
<div
|
|
337
|
+
className={cn(
|
|
338
|
+
"flex items-center justify-center rounded-md border border-dashed border-border text-sm text-muted-foreground",
|
|
339
|
+
stageAspectRatio !== undefined && "w-full",
|
|
340
|
+
)}
|
|
341
|
+
style={
|
|
342
|
+
stageAspectRatio !== undefined
|
|
343
|
+
? { aspectRatio: stageAspectRatio }
|
|
344
|
+
: {
|
|
345
|
+
width: cropStageWidth ?? 240,
|
|
346
|
+
height: cropStageHeight ?? 240,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
>
|
|
350
|
+
{t("imageCrop.pickHint")}
|
|
351
|
+
</div>
|
|
352
|
+
)
|
|
353
|
+
) : (
|
|
354
|
+
<div
|
|
355
|
+
className={cn(
|
|
356
|
+
"flex min-h-0 items-center justify-center overflow-hidden rounded-md border border-border bg-card",
|
|
357
|
+
stageAspectRatio === undefined && "max-w-90",
|
|
358
|
+
cropStageClassName,
|
|
359
|
+
stageAspectRatio !== undefined && "w-full",
|
|
360
|
+
)}
|
|
361
|
+
style={
|
|
362
|
+
stageAspectRatio !== undefined
|
|
363
|
+
? { aspectRatio: stageAspectRatio }
|
|
364
|
+
: {
|
|
365
|
+
minHeight: `${cropStageHeight}px`,
|
|
366
|
+
minWidth: `${cropStageWidth}px`,
|
|
367
|
+
width: effectiveCropWidth,
|
|
368
|
+
height: effectiveCropHeight,
|
|
369
|
+
maxWidth: effectiveCropWidth,
|
|
370
|
+
maxHeight: effectiveCropHeight,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
>
|
|
374
|
+
<ImageCropper
|
|
375
|
+
src={imageSrc}
|
|
376
|
+
aspect={aspect}
|
|
377
|
+
mimeType={mimeType}
|
|
378
|
+
displayMaxWidth={
|
|
379
|
+
stageAspectRatio !== undefined ? "100%" : effectiveCropWidth
|
|
380
|
+
}
|
|
381
|
+
displayMaxHeight={
|
|
382
|
+
stageAspectRatio !== undefined
|
|
383
|
+
? undefined
|
|
384
|
+
: effectiveCropHeight
|
|
385
|
+
}
|
|
386
|
+
onCropReady={handleCropReady}
|
|
387
|
+
onPreviewChange={
|
|
388
|
+
previewVisible ? handlePreviewChange : undefined
|
|
389
|
+
}
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
{imageSrc !== undefined && allowChangeSource ? (
|
|
394
|
+
<Button
|
|
395
|
+
type="button"
|
|
396
|
+
size="sm"
|
|
397
|
+
variant="ghost"
|
|
398
|
+
onClick={handleReselect}
|
|
399
|
+
className="text-muted-foreground text-xs"
|
|
400
|
+
>
|
|
401
|
+
<RefreshCircle className="mr-1 h-3.5 w-3.5" />
|
|
402
|
+
{t("imageCrop.reselect")}
|
|
403
|
+
</Button>
|
|
404
|
+
) : null}
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
{previewVisible ? (
|
|
408
|
+
<>
|
|
409
|
+
<div className={cn("hidden w-px bg-border sm:block h-40")} />
|
|
410
|
+
<div className={cn("block bg-border sm:hidden h-px w-32")} />
|
|
411
|
+
|
|
412
|
+
<div className={cn("flex shrink-0 flex-col items-center gap-1.5")}>
|
|
413
|
+
<div
|
|
414
|
+
style={{ width: previewWidth, height: previewHeight }}
|
|
415
|
+
className={cn(
|
|
416
|
+
"flex items-center justify-center overflow-hidden border border-border bg-muted text-muted-foreground",
|
|
417
|
+
previewRadiusClass,
|
|
418
|
+
)}
|
|
419
|
+
data-testid="image-crop-preview"
|
|
420
|
+
title={t("imageCrop.previewLabel")}
|
|
421
|
+
>
|
|
422
|
+
{previewDataUrl !== undefined ? (
|
|
423
|
+
<img
|
|
424
|
+
src={previewDataUrl}
|
|
425
|
+
alt={t("imageCrop.previewAlt")}
|
|
426
|
+
className="max-h-full max-w-full object-cover"
|
|
427
|
+
/>
|
|
428
|
+
) : (
|
|
429
|
+
<Gallery className="h-10 w-10 opacity-60" />
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
</>
|
|
434
|
+
) : null}
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
{showPreviewSwitch && imageSrc !== undefined ? (
|
|
438
|
+
<div className={cn("flex items-center justify-center gap-2")}>
|
|
439
|
+
<Switch
|
|
440
|
+
id={previewToggleId}
|
|
441
|
+
checked={previewPaneOn}
|
|
442
|
+
onCheckedChange={handlePreviewToggle}
|
|
443
|
+
data-testid="image-crop-preview-toggle"
|
|
444
|
+
/>
|
|
445
|
+
<Label
|
|
446
|
+
htmlFor={previewToggleId}
|
|
447
|
+
className="text-muted-foreground cursor-pointer text-sm font-normal"
|
|
448
|
+
>
|
|
449
|
+
{t("imageCrop.showPreview")}
|
|
450
|
+
</Label>
|
|
451
|
+
</div>
|
|
452
|
+
) : null}
|
|
453
|
+
|
|
454
|
+
{/* The save/remove action lands on the surrounding dialog's
|
|
455
|
+
footer row — side by side with Cancel. Outside a dialog it
|
|
456
|
+
falls back to an inline row at the panel's bottom. */}
|
|
457
|
+
{!hideActionButton &&
|
|
458
|
+
(footerSlot === null ? (
|
|
459
|
+
<div className={cn("flex justify-end pt-2")}>{actionButton}</div>
|
|
460
|
+
) : (
|
|
461
|
+
<DialogFooterActions>{actionButton}</DialogFooterActions>
|
|
462
|
+
))}
|
|
463
|
+
</div>
|
|
464
|
+
)
|
|
465
|
+
}
|