@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,144 @@
|
|
|
1
|
+
import { fireEvent, render, waitFor } from "@testing-library/react"
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { type CroppedImage, ImageCropper } from "./image-cropper"
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
const context = {
|
|
7
|
+
drawImage: vi.fn(),
|
|
8
|
+
imageSmoothingQuality: "",
|
|
9
|
+
}
|
|
10
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(() => context) as never
|
|
11
|
+
HTMLCanvasElement.prototype.toDataURL = vi.fn(
|
|
12
|
+
() => "data:image/png;base64,",
|
|
13
|
+
) as never
|
|
14
|
+
HTMLCanvasElement.prototype.toBlob = vi.fn((callback) => {
|
|
15
|
+
callback?.(new Blob([""], { type: "image/png" }))
|
|
16
|
+
}) as never
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const TALL_IMAGE_SRC =
|
|
20
|
+
"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"
|
|
21
|
+
const SQUARE_IMAGE_SRC =
|
|
22
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='804' height='804'%3E%3Crect width='100%25' height='100%25' fill='red'/%3E%3C/svg%3E"
|
|
23
|
+
|
|
24
|
+
describe("ImageCropper", () => {
|
|
25
|
+
it("fills a tall image with a square aspect crop", async () => {
|
|
26
|
+
let renderCrop: (() => Promise<CroppedImage>) | undefined
|
|
27
|
+
|
|
28
|
+
render(
|
|
29
|
+
<ImageCropper
|
|
30
|
+
src={TALL_IMAGE_SRC}
|
|
31
|
+
aspect={1}
|
|
32
|
+
onCropReady={(render) => {
|
|
33
|
+
renderCrop = render
|
|
34
|
+
}}
|
|
35
|
+
/>,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const img = await waitFor(() => {
|
|
39
|
+
const el = document.querySelector("img")
|
|
40
|
+
expect(el).toBeInTheDocument()
|
|
41
|
+
return el as HTMLImageElement
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Natural size is 1000x2000, but the image is displayed at 200x400.
|
|
45
|
+
// The output must still be a 1000x1000 square based on natural pixels.
|
|
46
|
+
setImageSize(img, {
|
|
47
|
+
naturalWidth: 1000,
|
|
48
|
+
naturalHeight: 2000,
|
|
49
|
+
width: 200,
|
|
50
|
+
height: 400,
|
|
51
|
+
})
|
|
52
|
+
fireEvent.load(img)
|
|
53
|
+
|
|
54
|
+
await waitFor(() => expect(renderCrop).toBeDefined())
|
|
55
|
+
const cropped = await renderCrop!()
|
|
56
|
+
|
|
57
|
+
expect(cropped.width).toBe(1000)
|
|
58
|
+
expect(cropped.height).toBe(1000)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("keeps a square image square with a 1:1 aspect crop", async () => {
|
|
62
|
+
let renderCrop: (() => Promise<CroppedImage>) | undefined
|
|
63
|
+
|
|
64
|
+
render(
|
|
65
|
+
<ImageCropper
|
|
66
|
+
src={SQUARE_IMAGE_SRC}
|
|
67
|
+
aspect={1}
|
|
68
|
+
onCropReady={(render) => {
|
|
69
|
+
renderCrop = render
|
|
70
|
+
}}
|
|
71
|
+
/>,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const img = await waitFor(() => {
|
|
75
|
+
const el = document.querySelector("img")
|
|
76
|
+
expect(el).toBeInTheDocument()
|
|
77
|
+
return el as HTMLImageElement
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Natural size is 804x804, displayed much smaller (e.g. 200x200).
|
|
81
|
+
// The output must remain exactly 804x804.
|
|
82
|
+
setImageSize(img, {
|
|
83
|
+
naturalWidth: 804,
|
|
84
|
+
naturalHeight: 804,
|
|
85
|
+
width: 200,
|
|
86
|
+
height: 200,
|
|
87
|
+
})
|
|
88
|
+
fireEvent.load(img)
|
|
89
|
+
|
|
90
|
+
await waitFor(() => expect(renderCrop).toBeDefined())
|
|
91
|
+
const cropped = await renderCrop!()
|
|
92
|
+
|
|
93
|
+
expect(cropped.width).toBe(804)
|
|
94
|
+
expect(cropped.height).toBe(804)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("uses the full image when no aspect is provided", async () => {
|
|
98
|
+
let renderCrop: (() => Promise<CroppedImage>) | undefined
|
|
99
|
+
|
|
100
|
+
render(
|
|
101
|
+
<ImageCropper
|
|
102
|
+
src={TALL_IMAGE_SRC}
|
|
103
|
+
onCropReady={(render) => {
|
|
104
|
+
renderCrop = render
|
|
105
|
+
}}
|
|
106
|
+
/>,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const img = await waitFor(() => {
|
|
110
|
+
const el = document.querySelector("img")
|
|
111
|
+
expect(el).toBeInTheDocument()
|
|
112
|
+
return el as HTMLImageElement
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
setImageSize(img, {
|
|
116
|
+
naturalWidth: 1000,
|
|
117
|
+
naturalHeight: 2000,
|
|
118
|
+
width: 200,
|
|
119
|
+
height: 400,
|
|
120
|
+
})
|
|
121
|
+
fireEvent.load(img)
|
|
122
|
+
|
|
123
|
+
await waitFor(() => expect(renderCrop).toBeDefined())
|
|
124
|
+
const cropped = await renderCrop!()
|
|
125
|
+
|
|
126
|
+
expect(cropped.width).toBe(1000)
|
|
127
|
+
expect(cropped.height).toBe(2000)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
function setImageSize(
|
|
132
|
+
img: HTMLImageElement,
|
|
133
|
+
size: {
|
|
134
|
+
naturalWidth: number
|
|
135
|
+
naturalHeight: number
|
|
136
|
+
width: number
|
|
137
|
+
height: number
|
|
138
|
+
},
|
|
139
|
+
): void {
|
|
140
|
+
Object.defineProperty(img, "naturalWidth", { value: size.naturalWidth })
|
|
141
|
+
Object.defineProperty(img, "naturalHeight", { value: size.naturalHeight })
|
|
142
|
+
Object.defineProperty(img, "width", { value: size.width })
|
|
143
|
+
Object.defineProperty(img, "height", { value: size.height })
|
|
144
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
2
|
+
import ReactCrop, { type Crop } from "react-image-crop"
|
|
3
|
+
import "react-image-crop/dist/ReactCrop.css"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Output of {@link ImageCropper} once the user releases the selection
|
|
7
|
+
* box. Contains the cropped pixel data ready to upload (PNG by default)
|
|
8
|
+
* plus its dimensions.
|
|
9
|
+
*/
|
|
10
|
+
export type CroppedImage = {
|
|
11
|
+
readonly blob: Blob
|
|
12
|
+
readonly width: number
|
|
13
|
+
readonly height: number
|
|
14
|
+
readonly mimeType: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ImageCropperProps = {
|
|
18
|
+
/** The source image as a data URL or object URL. */
|
|
19
|
+
readonly src: string
|
|
20
|
+
/**
|
|
21
|
+
* Optional fixed aspect ratio (width / height). When omitted, the
|
|
22
|
+
* user can crop freely.
|
|
23
|
+
*/
|
|
24
|
+
readonly aspect?: number
|
|
25
|
+
/**
|
|
26
|
+
* Output mime type. Defaults to `image/png`. PNG preserves quality;
|
|
27
|
+
* JPEG produces smaller files for photographic content.
|
|
28
|
+
*/
|
|
29
|
+
readonly mimeType?: "image/png" | "image/jpeg" | "image/webp"
|
|
30
|
+
/** JPEG/WebP quality (0..1). Ignored for PNG. */
|
|
31
|
+
readonly quality?: number
|
|
32
|
+
/**
|
|
33
|
+
* Called whenever the cropped pixel rectangle changes and is non-empty.
|
|
34
|
+
* The callback receives a function that, when invoked, produces the
|
|
35
|
+
* final {@link CroppedImage}. The render is deferred to avoid running
|
|
36
|
+
* the canvas pipeline on every drag tick.
|
|
37
|
+
*/
|
|
38
|
+
readonly onCropReady: (render: () => Promise<CroppedImage>) => void
|
|
39
|
+
/**
|
|
40
|
+
* Called synchronously with a PNG `data:` URL whenever the crop
|
|
41
|
+
* selection changes. PNG preserves alpha for the live preview (JPEG
|
|
42
|
+
* would flatten transparency to black). Unlike blob URLs, `data:`
|
|
43
|
+
* URLs never appear in the browser network tab and setting them as
|
|
44
|
+
* image `src` cannot cause an infinite re-render loop.
|
|
45
|
+
*/
|
|
46
|
+
readonly onPreviewChange?: (dataUrl: string) => void
|
|
47
|
+
/**
|
|
48
|
+
* Max CSS width for the source image in the crop stage. When omitted the
|
|
49
|
+
* image relies on `max-w-full` to fill the parent width.
|
|
50
|
+
*/
|
|
51
|
+
readonly displayMaxWidth?: number | string
|
|
52
|
+
/**
|
|
53
|
+
* Max CSS height for the source image in the crop stage (e.g. `80vh` for
|
|
54
|
+
* large canvas captures). Defaults to `70vh`.
|
|
55
|
+
*/
|
|
56
|
+
readonly displayMaxHeight?: number | string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Free-aspect (or fixed-aspect when {@link ImageCropperProps.aspect} is
|
|
61
|
+
* provided) image cropper. Wraps `react-image-crop` and exposes a
|
|
62
|
+
* deferred `render()` callback so callers can rasterise the selection on
|
|
63
|
+
* demand (typically when the user clicks "save").
|
|
64
|
+
*/
|
|
65
|
+
export function ImageCropper(props: ImageCropperProps) {
|
|
66
|
+
const {
|
|
67
|
+
src,
|
|
68
|
+
aspect,
|
|
69
|
+
mimeType = "image/png",
|
|
70
|
+
quality = 0.92,
|
|
71
|
+
displayMaxWidth,
|
|
72
|
+
displayMaxHeight = "70vh",
|
|
73
|
+
} = props
|
|
74
|
+
|
|
75
|
+
const imgRef = useRef<HTMLImageElement | null>(null)
|
|
76
|
+
const [crop, setCrop] = useState<Crop | undefined>(undefined)
|
|
77
|
+
const [completed, setCompleted] = useState<Crop | undefined>(undefined)
|
|
78
|
+
|
|
79
|
+
const handleImageLoad = useCallback(
|
|
80
|
+
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
81
|
+
const { naturalWidth, naturalHeight } = e.currentTarget
|
|
82
|
+
const initial = buildInitialCrop(naturalWidth, naturalHeight, aspect)
|
|
83
|
+
setCrop(initial)
|
|
84
|
+
setCompleted(initial)
|
|
85
|
+
},
|
|
86
|
+
[aspect],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const onCropReady = props.onCropReady
|
|
90
|
+
const onPreviewChange = props.onPreviewChange
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const img = imgRef.current
|
|
94
|
+
if (img === null || completed === undefined || completed.width === 0) {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
onCropReady(() => renderCroppedImage(img, completed, mimeType, quality))
|
|
98
|
+
if (onPreviewChange !== undefined) {
|
|
99
|
+
onPreviewChange(renderPreviewDataUrl(img, completed))
|
|
100
|
+
}
|
|
101
|
+
}, [completed, mimeType, quality, onCropReady, onPreviewChange])
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="react-crop-theme flex min-h-0 w-full max-w-full flex-col items-center gap-2 overflow-hidden">
|
|
105
|
+
<ReactCrop
|
|
106
|
+
className="max-w-full"
|
|
107
|
+
crop={crop}
|
|
108
|
+
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
|
109
|
+
onComplete={(_, percentCrop) => setCompleted(percentCrop)}
|
|
110
|
+
aspect={aspect}
|
|
111
|
+
keepSelection
|
|
112
|
+
ruleOfThirds
|
|
113
|
+
>
|
|
114
|
+
{/* biome-ignore lint/a11y/useAltText: cropper preview lacks user-supplied alt */}
|
|
115
|
+
<img
|
|
116
|
+
ref={imgRef}
|
|
117
|
+
src={src}
|
|
118
|
+
onLoad={handleImageLoad}
|
|
119
|
+
className="h-auto max-w-full"
|
|
120
|
+
style={{
|
|
121
|
+
maxWidth: displayMaxWidth,
|
|
122
|
+
maxHeight: displayMaxHeight,
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
</ReactCrop>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildInitialCrop(
|
|
131
|
+
imgWidth: number,
|
|
132
|
+
imgHeight: number,
|
|
133
|
+
aspect: number | undefined,
|
|
134
|
+
): Crop {
|
|
135
|
+
if (aspect === undefined) {
|
|
136
|
+
return { unit: "%" as const, x: 0, y: 0, width: 100, height: 100 }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const imgAspect = imgWidth / imgHeight
|
|
140
|
+
let widthPct: number
|
|
141
|
+
let heightPct: number
|
|
142
|
+
|
|
143
|
+
if (imgAspect >= aspect) {
|
|
144
|
+
// Image is wider (or equal) than the target aspect: fit to height,
|
|
145
|
+
// crop the sides.
|
|
146
|
+
heightPct = 100
|
|
147
|
+
widthPct = (100 * aspect) / imgAspect
|
|
148
|
+
} else {
|
|
149
|
+
// Image is taller than the target aspect: fit to width, crop top/bottom.
|
|
150
|
+
widthPct = 100
|
|
151
|
+
heightPct = (100 * imgAspect) / aspect
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
unit: "%" as const,
|
|
156
|
+
x: (100 - widthPct) / 2,
|
|
157
|
+
y: (100 - heightPct) / 2,
|
|
158
|
+
width: widthPct,
|
|
159
|
+
height: heightPct,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
class CroppedImageRenderError extends Error {
|
|
164
|
+
constructor(message: string) {
|
|
165
|
+
super(message)
|
|
166
|
+
this.name = "CroppedImageRenderError"
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @throws CroppedImageRenderError when the canvas context is unavailable
|
|
172
|
+
* or the browser refuses to encode the selected blob.
|
|
173
|
+
*/
|
|
174
|
+
async function renderCroppedImage(
|
|
175
|
+
image: HTMLImageElement,
|
|
176
|
+
crop: Crop,
|
|
177
|
+
mimeType: string,
|
|
178
|
+
quality: number,
|
|
179
|
+
): Promise<CroppedImage> {
|
|
180
|
+
const sourceRect = cropToNaturalRect(image, crop)
|
|
181
|
+
|
|
182
|
+
const canvas = document.createElement("canvas")
|
|
183
|
+
canvas.width = sourceRect.sw
|
|
184
|
+
canvas.height = sourceRect.sh
|
|
185
|
+
const ctx = canvas.getContext("2d")
|
|
186
|
+
if (ctx === null) {
|
|
187
|
+
throw new CroppedImageRenderError("2d context is unavailable")
|
|
188
|
+
}
|
|
189
|
+
ctx.imageSmoothingQuality = "high"
|
|
190
|
+
ctx.drawImage(
|
|
191
|
+
image,
|
|
192
|
+
sourceRect.sx,
|
|
193
|
+
sourceRect.sy,
|
|
194
|
+
sourceRect.sw,
|
|
195
|
+
sourceRect.sh,
|
|
196
|
+
0,
|
|
197
|
+
0,
|
|
198
|
+
sourceRect.sw,
|
|
199
|
+
sourceRect.sh,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const blob = await new Promise<Blob | null>((resolve) => {
|
|
203
|
+
canvas.toBlob(resolve, mimeType, quality)
|
|
204
|
+
})
|
|
205
|
+
if (blob === null) {
|
|
206
|
+
throw new CroppedImageRenderError("toBlob returned null")
|
|
207
|
+
}
|
|
208
|
+
return { blob, width: sourceRect.sw, height: sourceRect.sh, mimeType }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Render the current crop to a PNG `data:` URL synchronously.
|
|
213
|
+
*
|
|
214
|
+
* Unlike {@link renderCroppedImage}, this function does NOT create a
|
|
215
|
+
* `blob:` URL and therefore does not appear in the browser network tab.
|
|
216
|
+
* Preview uses PNG (not JPEG) so transparent regions stay transparent.
|
|
217
|
+
*/
|
|
218
|
+
function renderPreviewDataUrl(image: HTMLImageElement, crop: Crop): string {
|
|
219
|
+
const sourceRect = cropToNaturalRect(image, crop)
|
|
220
|
+
|
|
221
|
+
const canvas = document.createElement("canvas")
|
|
222
|
+
canvas.width = sourceRect.sw
|
|
223
|
+
canvas.height = sourceRect.sh
|
|
224
|
+
const ctx = canvas.getContext("2d", { alpha: true })
|
|
225
|
+
if (ctx === null) return ""
|
|
226
|
+
ctx.imageSmoothingQuality = "high"
|
|
227
|
+
ctx.drawImage(
|
|
228
|
+
image,
|
|
229
|
+
sourceRect.sx,
|
|
230
|
+
sourceRect.sy,
|
|
231
|
+
sourceRect.sw,
|
|
232
|
+
sourceRect.sh,
|
|
233
|
+
0,
|
|
234
|
+
0,
|
|
235
|
+
sourceRect.sw,
|
|
236
|
+
sourceRect.sh,
|
|
237
|
+
)
|
|
238
|
+
return canvas.toDataURL("image/png")
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function cropToNaturalRect(
|
|
242
|
+
image: HTMLImageElement,
|
|
243
|
+
crop: Crop,
|
|
244
|
+
): { sx: number; sy: number; sw: number; sh: number } {
|
|
245
|
+
const naturalWidth = image.naturalWidth
|
|
246
|
+
const naturalHeight = image.naturalHeight
|
|
247
|
+
|
|
248
|
+
if (crop.unit === "px") {
|
|
249
|
+
return {
|
|
250
|
+
sx: crop.x,
|
|
251
|
+
sy: crop.y,
|
|
252
|
+
sw: Math.max(1, Math.round(crop.width)),
|
|
253
|
+
sh: Math.max(1, Math.round(crop.height)),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const sx = (crop.x / 100) * naturalWidth
|
|
258
|
+
const sy = (crop.y / 100) * naturalHeight
|
|
259
|
+
const swRaw = (crop.width / 100) * naturalWidth
|
|
260
|
+
const shRaw = (crop.height / 100) * naturalHeight
|
|
261
|
+
const sw = Math.max(1, Math.round(swRaw))
|
|
262
|
+
const sh = Math.max(1, Math.round(shRaw))
|
|
263
|
+
|
|
264
|
+
return { sx, sy, sw, sh }
|
|
265
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
5
|
+
import { Button } from "@hoardodile/ui/components/button"
|
|
6
|
+
import { Input } from "@hoardodile/ui/components/input"
|
|
7
|
+
import { Textarea } from "@hoardodile/ui/components/textarea"
|
|
8
|
+
|
|
9
|
+
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
data-slot="input-group"
|
|
13
|
+
role="group"
|
|
14
|
+
className={cn(
|
|
15
|
+
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const inputGroupAddonVariants = cva(
|
|
24
|
+
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
|
25
|
+
{
|
|
26
|
+
variants: {
|
|
27
|
+
align: {
|
|
28
|
+
"inline-start":
|
|
29
|
+
"order-first pl-2 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]",
|
|
30
|
+
"inline-end":
|
|
31
|
+
"order-last pr-2 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]",
|
|
32
|
+
"block-start":
|
|
33
|
+
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
|
34
|
+
"block-end":
|
|
35
|
+
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
defaultVariants: {
|
|
39
|
+
align: "inline-start",
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
function InputGroupAddon({
|
|
45
|
+
className,
|
|
46
|
+
align = "inline-start",
|
|
47
|
+
...props
|
|
48
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
role="group"
|
|
52
|
+
data-slot="input-group-addon"
|
|
53
|
+
data-align={align}
|
|
54
|
+
className={cn(inputGroupAddonVariants({ align }), className)}
|
|
55
|
+
onClick={(e) => {
|
|
56
|
+
if ((e.target as HTMLElement).closest("button")) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
|
60
|
+
}}
|
|
61
|
+
{...props}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const inputGroupButtonVariants = cva(
|
|
67
|
+
"flex items-center gap-2 text-sm shadow-none",
|
|
68
|
+
{
|
|
69
|
+
variants: {
|
|
70
|
+
size: {
|
|
71
|
+
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
|
72
|
+
sm: "",
|
|
73
|
+
"icon-xs":
|
|
74
|
+
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
|
75
|
+
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
defaultVariants: {
|
|
79
|
+
size: "xs",
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
function InputGroupButton({
|
|
85
|
+
className,
|
|
86
|
+
type = "button",
|
|
87
|
+
variant = "ghost",
|
|
88
|
+
size = "xs",
|
|
89
|
+
...props
|
|
90
|
+
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
|
91
|
+
VariantProps<typeof inputGroupButtonVariants> & {
|
|
92
|
+
type?: "button" | "submit" | "reset"
|
|
93
|
+
}) {
|
|
94
|
+
return (
|
|
95
|
+
<Button
|
|
96
|
+
type={type}
|
|
97
|
+
data-size={size}
|
|
98
|
+
variant={variant}
|
|
99
|
+
className={cn(inputGroupButtonVariants({ size }), className)}
|
|
100
|
+
{...props}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
|
106
|
+
return (
|
|
107
|
+
<span
|
|
108
|
+
className={cn(
|
|
109
|
+
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
|
110
|
+
className
|
|
111
|
+
)}
|
|
112
|
+
{...props}
|
|
113
|
+
/>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function InputGroupInput({
|
|
118
|
+
className,
|
|
119
|
+
...props
|
|
120
|
+
}: Omit<React.ComponentProps<"input">, "size">) {
|
|
121
|
+
return (
|
|
122
|
+
<Input
|
|
123
|
+
data-slot="input-group-control"
|
|
124
|
+
className={cn(
|
|
125
|
+
"flex-1 rounded-none bg-transparent aria-invalid:ring-0 dark:bg-transparent",
|
|
126
|
+
className
|
|
127
|
+
)}
|
|
128
|
+
{...props}
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function InputGroupTextarea({
|
|
134
|
+
className,
|
|
135
|
+
...props
|
|
136
|
+
}: React.ComponentProps<"textarea">) {
|
|
137
|
+
return (
|
|
138
|
+
<Textarea
|
|
139
|
+
data-slot="input-group-control"
|
|
140
|
+
className={cn(
|
|
141
|
+
"flex-1 resize-none rounded-none bg-transparent py-2 aria-invalid:ring-0 dark:bg-transparent",
|
|
142
|
+
className
|
|
143
|
+
)}
|
|
144
|
+
{...props}
|
|
145
|
+
/>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export {
|
|
150
|
+
InputGroup,
|
|
151
|
+
InputGroupAddon,
|
|
152
|
+
InputGroupButton,
|
|
153
|
+
InputGroupText,
|
|
154
|
+
InputGroupInput,
|
|
155
|
+
InputGroupTextarea,
|
|
156
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Input as InputPrimitive } from "@base-ui/react/input"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Muted-fill control (the ControlStub / forms anatomy): fill background,
|
|
8
|
+
* `rounded-lg`, no border — the same anatomy as the search field. Three
|
|
9
|
+
* height tiers ride the control tokens: `sm` h-chip, `md` h-control,
|
|
10
|
+
* `lg` h-10.
|
|
11
|
+
*/
|
|
12
|
+
const inputVariants = cva(
|
|
13
|
+
"w-full min-w-0 rounded-lg bg-muted text-ui text-foreground outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-[3px] aria-invalid:ring-destructive/20",
|
|
14
|
+
{
|
|
15
|
+
variants: {
|
|
16
|
+
size: {
|
|
17
|
+
sm: "h-chip px-2.5 text-xs",
|
|
18
|
+
md: "h-control px-3",
|
|
19
|
+
lg: "h-10 px-3.5",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: {
|
|
23
|
+
size: "md",
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
function Input({
|
|
29
|
+
className,
|
|
30
|
+
size,
|
|
31
|
+
type,
|
|
32
|
+
...props
|
|
33
|
+
}: Omit<React.ComponentProps<"input">, "size"> &
|
|
34
|
+
VariantProps<typeof inputVariants>) {
|
|
35
|
+
return (
|
|
36
|
+
<InputPrimitive
|
|
37
|
+
type={type}
|
|
38
|
+
data-slot="input"
|
|
39
|
+
className={cn(inputVariants({ size }), className)}
|
|
40
|
+
{...props}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { Input, inputVariants }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
6
|
+
|
|
7
|
+
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
|
8
|
+
return (
|
|
9
|
+
<label
|
|
10
|
+
data-slot="label"
|
|
11
|
+
className={cn(
|
|
12
|
+
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
13
|
+
className
|
|
14
|
+
)}
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { Label }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
export type ListEmptyRowProps = {
|
|
5
|
+
readonly testId?: string
|
|
6
|
+
readonly className?: string
|
|
7
|
+
readonly children: ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Dashed empty-list row — a quiet centered slot that reads as an empty
|
|
12
|
+
* list item rather than a gap. Shared by comment, character, resource
|
|
13
|
+
* and tag-rule lists.
|
|
14
|
+
*/
|
|
15
|
+
export function ListEmptyRow(props: ListEmptyRowProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className={cn(
|
|
19
|
+
"flex min-h-9 items-center justify-center rounded-lg border border-dashed border-border-strong px-3 py-2 text-xs text-muted-foreground",
|
|
20
|
+
props.className,
|
|
21
|
+
)}
|
|
22
|
+
data-testid={props.testId}
|
|
23
|
+
>
|
|
24
|
+
{props.children}
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
export type ManagementEmptyProps = {
|
|
5
|
+
readonly testId?: string
|
|
6
|
+
readonly className?: string
|
|
7
|
+
readonly children: ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Dashed empty-state placeholder shared by entity-management panels.
|
|
12
|
+
*/
|
|
13
|
+
export function ManagementEmpty(props: ManagementEmptyProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className={cn(
|
|
17
|
+
"w-full rounded-lg border border-dashed bg-muted/20 px-4 py-3 text-sm text-muted-foreground",
|
|
18
|
+
props.className,
|
|
19
|
+
)}
|
|
20
|
+
data-testid={props.testId}
|
|
21
|
+
>
|
|
22
|
+
{props.children}
|
|
23
|
+
</div>
|
|
24
|
+
)
|
|
25
|
+
}
|