hazo_ui 4.1.1 → 4.2.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/CHANGE_LOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v4.2.0 (2026-06-14)
9
+
10
+ ### New
11
+ - `HazoUiImageCropper` — round profile-photo cropper built on `react-easy-crop`.
12
+ Pan and zoom a source image inside a 320 px stage; uses `forwardRef` to expose a
13
+ `getCroppedBlob()` imperative handle so parent components (e.g. a dialog confirm
14
+ button) can request the WebP blob on demand. Zoom control uses the existing
15
+ `Slider` primitive. All user-facing strings are props with English defaults.
16
+ - `HazoUiImageCropperDialog` — dialog wrapper (built on `HazoUiDialog`) that manages
17
+ the File → object URL lifecycle, triggers `getCroppedBlob()` on Confirm, shows a
18
+ loading spinner while the blob is produced, and closes automatically after
19
+ `onConfirm` resolves. Label props (`title`, `confirmLabel`, `cancelLabel`,
20
+ `zoomLabel`) accept translated strings so no next-intl dependency is needed.
21
+
22
+ ### Dependencies
23
+ - **`react-easy-crop` (^6.0.2) added to `dependencies`.**
24
+
8
25
  ## v4.1.1 (2026-06-14)
9
26
 
10
27
  ### New
package/README.md CHANGED
@@ -204,6 +204,8 @@ The following components support both global config and prop-level color overrid
204
204
 
205
205
  - **[HazoUiConfirmDialog](#hazouiconfirmdialog)** - A compact, opinionated confirmation dialog with accent top border, variant system (destructive, warning, info, success), async loading support, and configurable buttons. Perfect for delete confirmations, unsaved changes warnings, and simple acknowledgments.
206
206
 
207
+ - **[HazoUiImageCropper / HazoUiImageCropperDialog](#hazouiimagecropper--hazouiimagecropperdialog)** (v4.2.0) - Round profile-photo cropper with pan/zoom built on `react-easy-crop`. Outputs a 512×512 WebP Blob. The standalone `HazoUiImageCropper` exposes a `getCroppedBlob()` imperative handle; `HazoUiImageCropperDialog` wraps it in a dialog with a managed object-URL lifecycle and loading state.
208
+
207
209
  - **[HazoUiTable](#hazouitable--column-config-driven-data-table-v2140)** - A column-config-driven data table built on a shadcn `Table` primitive family. Sortable headers, debounced search, multi-column filter / sort dialogs, pagination, row click (mouse + keyboard), loading / empty / no-results states, and a card-per-row mobile fallback. Optional server-side `onLoad`.
208
210
 
209
211
  - **[Drawer](#drawer)** - A `vaul`-backed bottom sheet primitive for mobile UIs. Pair with `useMediaQuery` to swap between `Dialog` and `Drawer` based on viewport width.
@@ -2124,6 +2126,101 @@ Use the `loading` prop to override this behavior with external control.
2124
2126
 
2125
2127
  ---
2126
2128
 
2129
+ ## HazoUiImageCropper / HazoUiImageCropperDialog
2130
+
2131
+ Round profile-photo cropper (v4.2.0). Pan and zoom a source image inside a circular
2132
+ stage; outputs a square WebP `Blob` at a configurable resolution (default 512×512).
2133
+
2134
+ ### Basic usage — standalone cropper with imperative handle
2135
+
2136
+ ```tsx
2137
+ import { useRef } from 'react';
2138
+ import { HazoUiImageCropper, type HazoUiImageCropperHandle } from 'hazo_ui';
2139
+
2140
+ function MyPage() {
2141
+ const cropperRef = useRef<HazoUiImageCropperHandle>(null);
2142
+
2143
+ const handleSave = async () => {
2144
+ const blob = await cropperRef.current!.getCroppedBlob();
2145
+ // Upload blob, create preview, etc.
2146
+ };
2147
+
2148
+ return (
2149
+ <>
2150
+ <HazoUiImageCropper ref={cropperRef} imageSrc={objectUrl} />
2151
+ <button onClick={handleSave}>Save</button>
2152
+ </>
2153
+ );
2154
+ }
2155
+ ```
2156
+
2157
+ ### Basic usage — dialog wrapper
2158
+
2159
+ ```tsx
2160
+ import { useState } from 'react';
2161
+ import { HazoUiImageCropperDialog } from 'hazo_ui';
2162
+
2163
+ function ProfileEditor() {
2164
+ const [file, setFile] = useState<File | null>(null);
2165
+ const [open, setOpen] = useState(false);
2166
+
2167
+ return (
2168
+ <>
2169
+ <input type="file" accept="image/*"
2170
+ onChange={e => { setFile(e.target.files![0]); setOpen(true); }} />
2171
+
2172
+ <HazoUiImageCropperDialog
2173
+ open={open}
2174
+ onOpenChange={setOpen}
2175
+ file={file}
2176
+ onConfirm={async (blob) => {
2177
+ await uploadAvatar(blob);
2178
+ }}
2179
+ title="Crop photo"
2180
+ confirmLabel="Save"
2181
+ cancelLabel="Cancel"
2182
+ zoomLabel="Zoom"
2183
+ />
2184
+ </>
2185
+ );
2186
+ }
2187
+ ```
2188
+
2189
+ ### HazoUiImageCropper Props
2190
+
2191
+ | Prop | Type | Default | Description |
2192
+ |------|------|---------|-------------|
2193
+ | `imageSrc` | `string` | — | **Required.** Source image URL or object URL |
2194
+ | `onCropped` | `(blob: Blob) => void \| Promise<void>` | — | Optional convenience callback when blob is produced |
2195
+ | `outputSize` | `number` | `512` | Output canvas dimensions in pixels (square) |
2196
+ | `quality` | `number` | `0.9` | WebP quality (0–1) |
2197
+ | `zoomLabel` | `string` | `"Zoom"` | Zoom slider label (pass a translated string) |
2198
+ | `className` | `string` | — | Additional className for the root element |
2199
+
2200
+ The component accepts a `ref` of type `HazoUiImageCropperHandle` exposing:
2201
+
2202
+ | Method | Returns | Description |
2203
+ |--------|---------|-------------|
2204
+ | `getCroppedBlob()` | `Promise<Blob>` | Produces a WebP blob from the current crop area |
2205
+
2206
+ ### HazoUiImageCropperDialog Props
2207
+
2208
+ | Prop | Type | Default | Description |
2209
+ |------|------|---------|-------------|
2210
+ | `open` | `boolean` | — | **Required.** Controls dialog visibility |
2211
+ | `onOpenChange` | `(open: boolean) => void` | — | **Required.** Called when dialog open state changes |
2212
+ | `file` | `File \| null` | — | **Required.** The image file to crop |
2213
+ | `onConfirm` | `(blob: Blob) => void \| Promise<void>` | — | **Required.** Called with the cropped blob on Save |
2214
+ | `onCancel` | `() => void` | — | Called when the user cancels |
2215
+ | `title` | `string` | `"Crop photo"` | Dialog title |
2216
+ | `confirmLabel` | `string` | `"Save"` | Confirm button text |
2217
+ | `cancelLabel` | `string` | `"Cancel"` | Cancel button text |
2218
+ | `zoomLabel` | `string` | `"Zoom"` | Zoom slider label |
2219
+ | `outputSize` | `number` | `512` | Output size in pixels |
2220
+ | `quality` | `number` | `0.9` | WebP quality (0–1) |
2221
+
2222
+ ---
2223
+
2127
2224
  ## HazoUiDialog
2128
2225
 
2129
2226
  A flexible, standardized dialog component with customizable animations, sizes, and theming. Built on Radix UI Dialog primitives with a consistent header/body/footer layout.
package/dist/index.cjs CHANGED
@@ -42,7 +42,7 @@ var Link = require('@tiptap/extension-link');
42
42
  var TextAlign = require('@tiptap/extension-text-align');
43
43
  var Highlight = require('@tiptap/extension-highlight');
44
44
  var Color = require('@tiptap/extension-color');
45
- var Image = require('@tiptap/extension-image');
45
+ var Image2 = require('@tiptap/extension-image');
46
46
  var Placeholder = require('@tiptap/extension-placeholder');
47
47
  var HorizontalRule = require('@tiptap/extension-horizontal-rule');
48
48
  var extensionTable = require('@tiptap/extension-table');
@@ -61,6 +61,8 @@ require('@uiw/react-markdown-preview/markdown.css');
61
61
  var Suggestion = require('@tiptap/suggestion');
62
62
  var state = require('@tiptap/pm/state');
63
63
  var reactDom = require('react-dom');
64
+ var Cropper = require('react-easy-crop');
65
+ var SliderPrimitive = require('@radix-ui/react-slider');
64
66
  var vaul = require('vaul');
65
67
  var AccordionPrimitive = require('@radix-ui/react-accordion');
66
68
  var CheckboxPrimitive = require('@radix-ui/react-checkbox');
@@ -73,7 +75,6 @@ var ScrollAreaPrimitive = require('@radix-ui/react-scroll-area');
73
75
  var TogglePrimitive = require('@radix-ui/react-toggle');
74
76
  var ToggleGroupPrimitive = require('@radix-ui/react-toggle-group');
75
77
  var AlertDialogPrimitive = require('@radix-ui/react-alert-dialog');
76
- var SliderPrimitive = require('@radix-ui/react-slider');
77
78
  var sonner = require('sonner');
78
79
  var client$1 = require('hazo_state/client');
79
80
 
@@ -123,7 +124,7 @@ var Link__default = /*#__PURE__*/_interopDefault(Link);
123
124
  var TextAlign__default = /*#__PURE__*/_interopDefault(TextAlign);
124
125
  var Highlight__default = /*#__PURE__*/_interopDefault(Highlight);
125
126
  var Color__default = /*#__PURE__*/_interopDefault(Color);
126
- var Image__default = /*#__PURE__*/_interopDefault(Image);
127
+ var Image2__default = /*#__PURE__*/_interopDefault(Image2);
127
128
  var Placeholder__default = /*#__PURE__*/_interopDefault(Placeholder);
128
129
  var HorizontalRule__default = /*#__PURE__*/_interopDefault(HorizontalRule);
129
130
  var TableRow__default = /*#__PURE__*/_interopDefault(TableRow);
@@ -133,6 +134,8 @@ var TaskList__default = /*#__PURE__*/_interopDefault(TaskList);
133
134
  var TaskItem__default = /*#__PURE__*/_interopDefault(TaskItem);
134
135
  var TabsPrimitive__namespace = /*#__PURE__*/_interopNamespace(TabsPrimitive);
135
136
  var Suggestion__default = /*#__PURE__*/_interopDefault(Suggestion);
137
+ var Cropper__default = /*#__PURE__*/_interopDefault(Cropper);
138
+ var SliderPrimitive__namespace = /*#__PURE__*/_interopNamespace(SliderPrimitive);
136
139
  var AccordionPrimitive__namespace = /*#__PURE__*/_interopNamespace(AccordionPrimitive);
137
140
  var CheckboxPrimitive__namespace = /*#__PURE__*/_interopNamespace(CheckboxPrimitive);
138
141
  var DropdownMenuPrimitive__namespace = /*#__PURE__*/_interopNamespace(DropdownMenuPrimitive);
@@ -144,7 +147,6 @@ var ScrollAreaPrimitive__namespace = /*#__PURE__*/_interopNamespace(ScrollAreaPr
144
147
  var TogglePrimitive__namespace = /*#__PURE__*/_interopNamespace(TogglePrimitive);
145
148
  var ToggleGroupPrimitive__namespace = /*#__PURE__*/_interopNamespace(ToggleGroupPrimitive);
146
149
  var AlertDialogPrimitive__namespace = /*#__PURE__*/_interopNamespace(AlertDialogPrimitive);
147
- var SliderPrimitive__namespace = /*#__PURE__*/_interopNamespace(SliderPrimitive);
148
150
 
149
151
  var __create = Object.create;
150
152
  var __defProp = Object.defineProperty;
@@ -4366,7 +4368,7 @@ var HazoUiRte = ({
4366
4368
  multicolor: true
4367
4369
  }),
4368
4370
  Color__default.default,
4369
- Image__default.default.configure({
4371
+ Image2__default.default.configure({
4370
4372
  inline: true,
4371
4373
  allowBase64: true,
4372
4374
  HTMLAttributes: {
@@ -6914,6 +6916,210 @@ function HazoUiConfirmDialog({
6914
6916
  )
6915
6917
  ] }) });
6916
6918
  }
6919
+ var Slider = React26__namespace.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsxRuntime.jsxs(
6920
+ SliderPrimitive__namespace.Root,
6921
+ {
6922
+ ref,
6923
+ className: cn("relative flex w-full touch-none select-none items-center", className),
6924
+ ...props,
6925
+ children: [
6926
+ /* @__PURE__ */ jsxRuntime.jsx(SliderPrimitive__namespace.Track, { className: "relative h-2 w-full grow overflow-hidden rounded-full bg-secondary", children: /* @__PURE__ */ jsxRuntime.jsx(SliderPrimitive__namespace.Range, { className: "absolute h-full bg-primary" }) }),
6927
+ /* @__PURE__ */ jsxRuntime.jsx(SliderPrimitive__namespace.Thumb, { className: "block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" })
6928
+ ]
6929
+ }
6930
+ ));
6931
+ Slider.displayName = SliderPrimitive__namespace.Root.displayName;
6932
+ async function getCroppedImg(imageSrc, areaPixels, outputSize, quality) {
6933
+ const img = await new Promise((resolve, reject) => {
6934
+ const image = new Image();
6935
+ image.crossOrigin = "anonymous";
6936
+ image.onload = () => resolve(image);
6937
+ image.onerror = (e) => reject(e);
6938
+ image.src = imageSrc;
6939
+ });
6940
+ const canvas = document.createElement("canvas");
6941
+ canvas.width = outputSize;
6942
+ canvas.height = outputSize;
6943
+ const ctx = canvas.getContext("2d");
6944
+ if (!ctx) {
6945
+ throw new Error("HazoUiImageCropper: canvas 2d context unavailable");
6946
+ }
6947
+ ctx.drawImage(
6948
+ img,
6949
+ areaPixels.x,
6950
+ areaPixels.y,
6951
+ areaPixels.width,
6952
+ areaPixels.height,
6953
+ 0,
6954
+ 0,
6955
+ outputSize,
6956
+ outputSize
6957
+ );
6958
+ return new Promise((resolve, reject) => {
6959
+ canvas.toBlob(
6960
+ (blob) => {
6961
+ if (blob) {
6962
+ resolve(blob);
6963
+ } else {
6964
+ reject(new Error("HazoUiImageCropper: canvas.toBlob returned null"));
6965
+ }
6966
+ },
6967
+ "image/webp",
6968
+ quality
6969
+ );
6970
+ });
6971
+ }
6972
+ var HazoUiImageCropper = React26__namespace.forwardRef(function HazoUiImageCropper2({
6973
+ imageSrc,
6974
+ onCropped,
6975
+ outputSize = 512,
6976
+ quality = 0.9,
6977
+ zoomLabel = "Zoom",
6978
+ className
6979
+ }, ref) {
6980
+ const [crop, set_crop] = React26__namespace.useState({
6981
+ x: 0,
6982
+ y: 0
6983
+ });
6984
+ const [zoom, set_zoom] = React26__namespace.useState(1);
6985
+ const cropped_area_pixels_ref = React26__namespace.useRef(null);
6986
+ const handle_crop_complete = React26__namespace.useCallback(
6987
+ (_croppedArea, croppedAreaPixels) => {
6988
+ cropped_area_pixels_ref.current = croppedAreaPixels;
6989
+ },
6990
+ []
6991
+ );
6992
+ React26__namespace.useImperativeHandle(
6993
+ ref,
6994
+ () => ({
6995
+ getCroppedBlob: async () => {
6996
+ if (!cropped_area_pixels_ref.current) {
6997
+ throw new Error(
6998
+ "HazoUiImageCropper: crop area not yet initialised \u2014 wait for the cropper to mount before calling getCroppedBlob."
6999
+ );
7000
+ }
7001
+ const blob = await getCroppedImg(
7002
+ imageSrc,
7003
+ cropped_area_pixels_ref.current,
7004
+ outputSize,
7005
+ quality
7006
+ );
7007
+ await onCropped?.(blob);
7008
+ return blob;
7009
+ }
7010
+ }),
7011
+ [imageSrc, onCropped, outputSize, quality]
7012
+ );
7013
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cn("cls_image_cropper_root flex flex-col gap-4", className), children: [
7014
+ /* @__PURE__ */ jsxRuntime.jsx(
7015
+ "div",
7016
+ {
7017
+ className: "cls_cropper_stage relative w-full overflow-hidden rounded-lg bg-muted",
7018
+ style: { height: "320px" },
7019
+ children: /* @__PURE__ */ jsxRuntime.jsx(
7020
+ Cropper__default.default,
7021
+ {
7022
+ image: imageSrc,
7023
+ crop,
7024
+ zoom,
7025
+ aspect: 1,
7026
+ cropShape: "round",
7027
+ showGrid: false,
7028
+ onCropChange: set_crop,
7029
+ onZoomChange: set_zoom,
7030
+ onCropComplete: handle_crop_complete
7031
+ }
7032
+ )
7033
+ }
7034
+ ),
7035
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cls_zoom_control flex flex-col gap-1.5", children: [
7036
+ /* @__PURE__ */ jsxRuntime.jsx(
7037
+ "label",
7038
+ {
7039
+ className: "cls_zoom_label text-sm font-medium text-foreground",
7040
+ id: "hazo-cropper-zoom-label",
7041
+ children: zoomLabel
7042
+ }
7043
+ ),
7044
+ /* @__PURE__ */ jsxRuntime.jsx(
7045
+ Slider,
7046
+ {
7047
+ "aria-labelledby": "hazo-cropper-zoom-label",
7048
+ min: 1,
7049
+ max: 3,
7050
+ step: 0.1,
7051
+ value: [zoom],
7052
+ onValueChange: (values) => set_zoom(values[0])
7053
+ }
7054
+ )
7055
+ ] })
7056
+ ] });
7057
+ });
7058
+ HazoUiImageCropper.displayName = "HazoUiImageCropper";
7059
+ function HazoUiImageCropperDialog({
7060
+ open,
7061
+ onOpenChange,
7062
+ file,
7063
+ onConfirm,
7064
+ onCancel,
7065
+ title = "Crop photo",
7066
+ confirmLabel = "Save",
7067
+ cancelLabel = "Cancel",
7068
+ zoomLabel = "Zoom",
7069
+ outputSize = 512,
7070
+ quality = 0.9
7071
+ }) {
7072
+ const cropper_ref = React26__namespace.useRef(null);
7073
+ const [image_src, set_image_src] = React26__namespace.useState(null);
7074
+ const [is_confirming, set_is_confirming] = React26__namespace.useState(false);
7075
+ React26__namespace.useEffect(() => {
7076
+ if (!file) {
7077
+ set_image_src(null);
7078
+ return;
7079
+ }
7080
+ const url = URL.createObjectURL(file);
7081
+ set_image_src(url);
7082
+ return () => {
7083
+ URL.revokeObjectURL(url);
7084
+ };
7085
+ }, [file]);
7086
+ const handle_confirm = async () => {
7087
+ if (!cropper_ref.current) return;
7088
+ set_is_confirming(true);
7089
+ try {
7090
+ const blob = await cropper_ref.current.getCroppedBlob();
7091
+ await onConfirm(blob);
7092
+ onOpenChange(false);
7093
+ } finally {
7094
+ set_is_confirming(false);
7095
+ }
7096
+ };
7097
+ return /* @__PURE__ */ jsxRuntime.jsx(
7098
+ HazoUiDialog,
7099
+ {
7100
+ open,
7101
+ onOpenChange,
7102
+ title,
7103
+ actionButtonText: confirmLabel,
7104
+ cancelButtonText: cancelLabel,
7105
+ onConfirm: handle_confirm,
7106
+ onCancel,
7107
+ actionButtonLoading: is_confirming,
7108
+ sizeWidth: "min(90vw, 520px)",
7109
+ contentClassName: "cls_image_cropper_dialog_body",
7110
+ children: image_src && /* @__PURE__ */ jsxRuntime.jsx(
7111
+ HazoUiImageCropper,
7112
+ {
7113
+ ref: cropper_ref,
7114
+ imageSrc: image_src,
7115
+ outputSize,
7116
+ quality,
7117
+ zoomLabel
7118
+ }
7119
+ )
7120
+ }
7121
+ );
7122
+ }
6917
7123
  var Drawer = ({
6918
7124
  shouldScaleBackground = true,
6919
7125
  ...props
@@ -7460,19 +7666,6 @@ function ButtonGroupText({ className, asChild = false, ...props }) {
7460
7666
  function ButtonGroupSeparator({ className, orientation = "vertical", ...props }) {
7461
7667
  return /* @__PURE__ */ jsxRuntime.jsx(Separator3, { orientation, className: cn("bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto", className), ...props });
7462
7668
  }
7463
- var Slider = React26__namespace.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsxRuntime.jsxs(
7464
- SliderPrimitive__namespace.Root,
7465
- {
7466
- ref,
7467
- className: cn("relative flex w-full touch-none select-none items-center", className),
7468
- ...props,
7469
- children: [
7470
- /* @__PURE__ */ jsxRuntime.jsx(SliderPrimitive__namespace.Track, { className: "relative h-2 w-full grow overflow-hidden rounded-full bg-secondary", children: /* @__PURE__ */ jsxRuntime.jsx(SliderPrimitive__namespace.Range, { className: "absolute h-full bg-primary" }) }),
7471
- /* @__PURE__ */ jsxRuntime.jsx(SliderPrimitive__namespace.Thumb, { className: "block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" })
7472
- ]
7473
- }
7474
- ));
7475
- Slider.displayName = SliderPrimitive__namespace.Root.displayName;
7476
7669
  var InputAffix = React26__namespace.forwardRef(
7477
7670
  ({ className, containerClassName, prefix, suffix, type = "text", ...props }, ref) => {
7478
7671
  return /* @__PURE__ */ jsxRuntime.jsxs(
@@ -10670,6 +10863,8 @@ exports.HazoUiDialogTrigger = DialogTrigger;
10670
10863
  exports.HazoUiEtaProgress = HazoUiEtaProgress;
10671
10864
  exports.HazoUiFlexInput = HazoUiFlexInput;
10672
10865
  exports.HazoUiFlexRadio = HazoUiFlexRadio;
10866
+ exports.HazoUiImageCropper = HazoUiImageCropper;
10867
+ exports.HazoUiImageCropperDialog = HazoUiImageCropperDialog;
10673
10868
  exports.HazoUiKanban = HazoUiKanban;
10674
10869
  exports.HazoUiKanbanFilter = HazoUiKanbanFilter;
10675
10870
  exports.HazoUiMultiFilterDialog = HazoUiMultiFilterDialog;